diff options
Diffstat (limited to 'src/components/rules')
| -rw-r--r-- | src/components/rules/Rule.module.scss | 116 | ||||
| -rw-r--r-- | src/components/rules/Rule.tsx | 149 | ||||
| -rw-r--r-- | src/components/rules/RuleProviderItem.module.scss | 108 | ||||
| -rw-r--r-- | src/components/rules/RuleProviderItem.tsx | 69 | ||||
| -rw-r--r-- | src/components/rules/Rules.module.scss | 127 | ||||
| -rw-r--r-- | src/components/rules/Rules.tsx | 135 | ||||
| -rw-r--r-- | src/components/rules/RulesHeader.tsx | 80 | ||||
| -rw-r--r-- | src/components/rules/RulesPageFab.tsx | 24 |
8 files changed, 504 insertions, 304 deletions
diff --git a/src/components/rules/Rule.module.scss b/src/components/rules/Rule.module.scss index 48a22e7..69b6f3b 100644 --- a/src/components/rules/Rule.module.scss +++ b/src/components/rules/Rule.module.scss @@ -1,81 +1,135 @@ -@use '~/styles/utils/custom-media' as *; - .rule { display: flex; align-items: center; - padding: 12px 20px; - transition: background-color 0.2s ease; - border-bottom: 1px solid var(--color-separator); + height: 100%; + padding: 10px 18px; + transition: background-color 0.15s ease; &:hover { - background-color: var(--bg-near-transparent); + background-color: var(--color-hover-soft); + } + + &.disabled { + opacity: 0.45; + } + + @media (max-width: 768px) { + padding: 10px 12px; } } -.left { +.index { width: 40px; flex-shrink: 0; color: var(--color-text-secondary); - font-size: 11px; - opacity: 0.4; + font-size: 0.68rem; + opacity: 0.5; font-family: var(--font-mono); + + @media (max-width: 768px) { + width: 28px; + } } -.right { +.main { flex: 1; min-width: 0; - margin-left: 12px; + display: flex; + flex-direction: column; + gap: 5px; } .payloadRow { display: flex; align-items: baseline; - margin-bottom: 4px; + gap: 10px; + min-width: 0; } .payload { font-family: var(--font-mono); - font-size: 13px; + font-size: 0.8rem; color: var(--color-text); - word-break: break-all; line-height: 1.4; + word-break: break-all; + // 定高行装不下第三行,超出的部分省略掉 + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } -.size { - margin-left: 12px; - font-size: 10px; - color: var(--color-text-secondary); - background: var(--bg-near-transparent); +.entryCount { + flex-shrink: 0; + font-size: 0.65rem; + color: var(--color-badge-fg); + background: var(--color-badge-bg); padding: 1px 6px; - border-radius: 3px; + border-radius: 5px; white-space: nowrap; - text-transform: uppercase; } .metaRow { display: flex; align-items: center; - gap: 12px; - font-size: 11px; + gap: 10px; + font-size: 0.7rem; + min-width: 0; } .typeTag { - display: flex; + display: inline-flex; align-items: center; gap: 4px; - color: var(--color-text-secondary); - background: var(--bg-near-transparent); - padding: 2px 8px; - border-radius: 4px; + flex-shrink: 0; + color: var(--color-badge-fg); + background: var(--color-badge-bg); + padding: 2px 7px; + border-radius: 5px; span { font-weight: 500; text-transform: uppercase; - font-size: 10px; + font-size: 0.62rem; + letter-spacing: 0.02em; } } -.proxyTag { +.proxy { font-weight: 600; - letter-spacing: 0.02em; + letter-spacing: 0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hitInfo { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-left: auto; + color: var(--color-text-secondary); + cursor: default; +} + +// DOM 里排在最前,桌面端就在行首;用 mini 尺寸的开关, +// 布局盒子和看到的一样大,不需要再拿 scale + transform-origin 去凑 +.switch { + display: flex; + align-items: center; + flex-shrink: 0; + margin-right: 12px; + + &.pending { + opacity: 0.6; + pointer-events: none; + } + + // 窄屏维持原样:拨到行尾 + @media (max-width: 768px) { + order: 1; + margin-right: 0; + margin-left: 12px; + } } diff --git a/src/components/rules/Rule.tsx b/src/components/rules/Rule.tsx index e1fd047..0cb896a 100644 --- a/src/components/rules/Rule.tsx +++ b/src/components/rules/Rule.tsx @@ -1,74 +1,147 @@ -import React from 'react'; +import cx from 'clsx'; +import { formatDistanceToNow } from 'date-fns'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; -import { FileText, Globe, Hash, Link, Shield, Zap } from '~/components/shared/FeatherIcons'; +import type { RuleExtra } from '~/api/rules'; +import { + Activity, + FileText, + Globe, + Hash, + Link, + Shield, + Zap, +} from '~/components/shared/FeatherIcons'; +import SwitchThemed from '~/components/shared/SwitchThemed'; +import { useToggleRuleDisabled } from '~/modules/rules/hooks'; +import type { RuleProviderIndex } from '~/modules/rules/utils'; +import { ClashAPIConfig } from '~/types'; -import s0 from './Rule.module.scss'; +import s from './Rule.module.scss'; -const colorMap = { - _default: 'var(--color-focus-blue)', +const proxyColor: Record<string, string> = { DIRECT: '#f5bc41', REJECT: '#cb3166', }; -function getStyleFor({ proxy }) { - let color = colorMap._default; - if (colorMap[proxy]) { - color = colorMap[proxy]; - } - return { color }; -} - function getIconFor(type: string) { switch (type) { case 'Domain': case 'DomainSuffix': case 'DomainKeyword': - return <Link size={14} />; + return <Link size={12} />; case 'IPCIDR': case 'IPCIDR6': - return <Hash size={14} />; + return <Hash size={12} />; case 'GeoSite': case 'GeoIP': - return <Globe size={14} />; + return <Globe size={12} />; case 'REJECT': - return <Shield size={14} />; + return <Shield size={12} />; case 'DIRECT': - return <Zap size={14} />; + return <Zap size={12} />; default: - return <FileText size={14} />; + return <FileText size={12} />; } } type Props = { - id?: number; - type?: string; - payload?: string; - proxy?: string; - size?: number; + id: number; + type: string; + payload: string; + proxy: string; + size: number; + extra?: RuleExtra; + apiConfig: ClashAPIConfig; + provider?: RuleProviderIndex; }; -function Rule({ type, payload, proxy, id, size }: Props) { - const styleProxy = getStyleFor({ proxy }); +/** GeoSite/GeoIP 的条目数后端直接给,RuleSet 的要去提供商表里查 */ +function getEntryCount({ + type, + payload, + size, + provider, +}: { + type: string; + payload: string; + size: number; + provider?: RuleProviderIndex; +}): number | undefined { + if ((type === 'GeoSite' || type === 'GeoIP') && size >= 0) { + return size; + } + if (type === 'RuleSet') { + return provider?.byName?.[payload]?.ruleCount; + } + return undefined; +} + +function Rule({ type, payload, proxy, id, size, extra, apiConfig, provider }: Props) { + const { t } = useTranslation(); + const { toggleRule, isPending } = useToggleRuleDisabled(apiConfig); + const disabled = extra?.disabled ?? false; + const entryCount = getEntryCount({ type, payload, size, provider }); + + const hitTitle = extra + ? extra.hitCount > 0 + ? t('rule_hit_tip', { + count: extra.hitCount, + time: formatDistanceToNow(new Date(extra.hitAt), { addSuffix: true }), + }) + : t('rule_never_hit') + : undefined; + return ( - <div className={s0.rule}> - <div className={s0.left}>{id}</div> - <div className={s0.right}> - <div className={s0.payloadRow}> - <div className={s0.payload}>{payload}</div> - {(type === 'GeoSite' || type === 'GeoIP') && <div className={s0.size}>size: {size}</div>} + <div className={cx(s.rule, { [s.disabled]: disabled })}> + {/* 桌面端在最左边,窄屏靠 .switch 的 order 拨到行尾 */} + {extra ? ( + <div + className={cx(s.switch, { [s.pending]: isPending })} + title={disabled ? t('rule_enable') : t('rule_disable')} + > + <SwitchThemed + size="mini" + name={`rule-${id}`} + checked={!disabled} + onChange={(checked: boolean) => toggleRule(id, !checked)} + /> + </div> + ) : null} + + <div className={s.index}>{id}</div> + + <div className={s.main}> + <div className={s.payloadRow}> + <div className={s.payload}>{payload}</div> + {typeof entryCount === 'number' ? ( + <div className={s.entryCount}>{t('rule_entry_count', { count: entryCount })}</div> + ) : null} </div> - <div className={s0.metaRow}> - <div className={s0.typeTag}> + + <div className={s.metaRow}> + <span className={s.typeTag}> {getIconFor(type)} <span>{type}</span> - </div> - <div className={s0.proxyTag} style={styleProxy}> + </span> + <span + className={s.proxy} + style={{ color: proxyColor[proxy] ?? 'var(--color-focus-blue)' }} + > {proxy} - </div> + </span> + {extra ? ( + <span className={s.hitInfo} title={hitTitle}> + <Activity size={12} /> + {extra.hitCount} + </span> + ) : null} </div> </div> </div> ); } -export default Rule; +// 上千行的虚拟列表,滚动时不该因为父组件重渲染而整屏重算 +export default React.memo(Rule); diff --git a/src/components/rules/RuleProviderItem.module.scss b/src/components/rules/RuleProviderItem.module.scss index 933fa08..df321ca 100644 --- a/src/components/rules/RuleProviderItem.module.scss +++ b/src/components/rules/RuleProviderItem.module.scss @@ -1,29 +1,37 @@ -.RuleProviderItem { +.item { display: flex; align-items: center; height: 100%; - padding: 16px 20px; - transition: all 0.2s ease; + padding: 12px 18px; + transition: background-color 0.15s ease; &:hover { - background: var(--bg-near-transparent); + background: var(--color-hover-soft); - .refreshButton { + .refreshBtn { opacity: 1; } } + + @media (max-width: 768px) { + padding: 12px; + } } -.left { - width: 32px; +.index { + width: 40px; flex-shrink: 0; color: var(--color-text-secondary); - font-size: 11px; - opacity: 0.4; + font-size: 0.68rem; + opacity: 0.5; font-family: var(--font-mono); + + @media (max-width: 768px) { + width: 28px; + } } -.middle { +.main { flex: 1; min-width: 0; display: flex; @@ -34,30 +42,29 @@ .nameRow { display: flex; align-items: center; - flex-wrap: wrap; gap: 8px; + min-width: 0; } .name { - font-size: 14px; + font-size: 0.85rem; font-weight: 600; color: var(--color-text-highlight); -} - -.badgeGroup { - display: flex; - gap: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .badge { - display: flex; + display: inline-flex; align-items: center; gap: 4px; - font-size: 10px; - color: var(--color-text-secondary); - background: var(--bg-near-transparent); - padding: 2px 6px; - border-radius: 4px; + flex-shrink: 0; + font-size: 0.62rem; + color: var(--color-badge-fg); + background: var(--color-badge-bg); + padding: 2px 7px; + border-radius: 5px; text-transform: uppercase; letter-spacing: 0.02em; } @@ -66,41 +73,52 @@ display: flex; align-items: center; gap: 8px; - font-size: 11px; + font-size: 0.7rem; color: var(--color-text-secondary); } .dot { - opacity: 0.5; + opacity: 0.4; } -.right { +.refreshBtn { + display: inline-flex; + align-items: center; + justify-content: center; + appearance: none; + flex-shrink: 0; margin-left: 12px; -} - -.refreshButton { - opacity: 0.4; - transition: all 0.2s ease; - padding: 8px !important; - border-radius: 50% !important; + width: 32px; + height: 32px; + border: none; + border-radius: 8px; + background: none; color: var(--color-text-secondary); + cursor: pointer; + // 悬停在整行上才完全显形,静态时不抢眼 + opacity: 0.45; + transition: + opacity 0.15s ease, + background-color 0.15s ease, + color 0.15s ease; - &:hover { - opacity: 1; + &:hover:not(:disabled) { color: var(--color-focus-blue); - background: var(--bg-near-transparent) !important; + background: var(--color-hover-soft); } -} -.rotating { - animation: rotate 1s linear infinite; -} + &:focus-visible { + opacity: 1; + outline: 2px solid var(--color-focus-blue); + outline-offset: 2px; + } -@keyframes rotate { - from { - transform: rotate(0deg); + &:disabled { + cursor: default; } - to { - transform: rotate(360deg); + + // 触屏没有 hover,直接常显 + @media (hover: none) { + opacity: 1; } } diff --git a/src/components/rules/RuleProviderItem.tsx b/src/components/rules/RuleProviderItem.tsx index 4cd5773..ec9732b 100644 --- a/src/components/rules/RuleProviderItem.tsx +++ b/src/components/rules/RuleProviderItem.tsx @@ -1,13 +1,18 @@ import { formatDistance } from 'date-fns'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; -import Button from '~/components/Button'; -import { Activity, Database, RefreshCw } from '~/components/shared/FeatherIcons'; +import type { RuleProvider } from '~/api/rule-provider'; +import { Activity, Database } from '~/components/shared/FeatherIcons'; +import { RotateIcon } from '~/components/shared/RotateIcon'; import { useUpdateRuleProviderItem } from '~/modules/rules/hooks'; +import { ClashAPIConfig } from '~/types'; import s from './RuleProviderItem.module.scss'; -export function RuleProviderItem({ +type Props = RuleProvider & { apiConfig: ClashAPIConfig }; + +function RuleProviderItemInner({ idx, name, vehicleType, @@ -15,42 +20,46 @@ export function RuleProviderItem({ updatedAt, ruleCount, apiConfig, -}) { - const [onClickRefreshButton, isRefreshing] = useUpdateRuleProviderItem(name, apiConfig); +}: Props) { + const { t } = useTranslation(); + const [refresh, isRefreshing] = useUpdateRuleProviderItem(name, apiConfig); const timeAgo = formatDistance(new Date(updatedAt), new Date()); + return ( - <div className={s.RuleProviderItem}> - <div className={s.left}>{idx}</div> - <div className={s.middle}> + <div className={s.item}> + <div className={s.index}>{idx}</div> + + <div className={s.main}> <div className={s.nameRow}> <span className={s.name}>{name}</span> - <div className={s.badgeGroup}> - <span className={s.badge}> - <Database size={12} /> - {vehicleType} - </span> - <span className={s.badge}> - <Activity size={12} /> - {behavior} - </span> - </div> + <span className={s.badge}> + <Database size={11} /> + {vehicleType} + </span> + <span className={s.badge}> + <Activity size={11} /> + {behavior} + </span> </div> <div className={s.infoRow}> - <span className={s.count}>{ruleCount} rules</span> + <span>{t('rule_entry_count', { count: ruleCount })}</span> <span className={s.dot}>•</span> - <span className={s.time}>Updated {timeAgo} ago</span> + <span>{t('updated_ago', { time: timeAgo })}</span> </div> </div> - <div className={s.right}> - <Button - kind="minimal" - onClick={onClickRefreshButton} - disabled={isRefreshing} - className={s.refreshButton} - > - <RefreshCw size={18} className={isRefreshing ? s.rotating : ''} /> - </Button> - </div> + + <button + type="button" + className={s.refreshBtn} + onClick={refresh} + disabled={isRefreshing} + aria-label={t('update_rule_provider')} + title={t('update_rule_provider')} + > + <RotateIcon isRotating={isRefreshing} /> + </button> </div> ); } + +export const RuleProviderItem = React.memo(RuleProviderItemInner); diff --git a/src/components/rules/Rules.module.scss b/src/components/rules/Rules.module.scss index f764d09..701fe14 100644 --- a/src/components/rules/Rules.module.scss +++ b/src/components/rules/Rules.module.scss @@ -1,97 +1,90 @@ -@use '~/styles/utils/custom-media' as *; - -.container { +.page { display: flex; flex-direction: column; height: 100%; + min-height: 0; } -.filterWrapper { - width: 100%; - max-width: 300px; +.listArea { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + margin: 12px 32px 24px; + + @media (max-width: 1024px) { + margin: 12px 16px 16px; + } @media (max-width: 768px) { - order: 3; - flex: 1 1 100%; - max-width: none; - margin-top: 8px; + margin: 8px 12px 10px; } } -.listWrapper { - margin: 10px 45px 20px; - background-color: var(--bg-log-info-card); - border-radius: 12px; +// 与连接页表格同一张卡:窄屏下退掉卡片边界,直接铺在页面底色上 +.card { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + border-radius: 16px; + background: var(--color-card); + border: 1px solid var(--color-card-border); box-shadow: var(--shadow-card); - border: 1px solid var(--color-separator); overflow: hidden; @media (max-width: 768px) { - margin: 10px 15px 15px; + background: transparent; + border: none; + box-shadow: none; + border-radius: 0; } } -.RuleProviderItemWrapper { - border-bottom: 1px solid var(--color-separator); +.listWrap { + flex: 1; + min-height: 0; + min-width: 0; } -.tabsContainer { - display: flex; - align-items: center; - background-color: var(--color-bg-sidebar); - border-radius: 12px; - padding: 4px; - - @media (max-width: 768px) { - order: 1; - flex: 1 1 100%; - width: 100%; - } +.row { + border-bottom: 1px solid var(--color-card-border); } -.tab { +/* ---------- 空态 ---------- */ + +.empty { display: flex; + flex-direction: column; align-items: center; - padding: 8px 16px; - border-radius: 8px; - cursor: pointer; - font-size: 1em; - font-weight: 500; - color: var(--color-text-secondary); - transition: all 0.2s ease; - user-select: none; - - @media (max-width: 768px) { - padding: 6px 10px; - font-size: 0.85em; - flex: 1; - justify-content: center; - min-width: 0; - white-space: nowrap; - } + justify-content: center; + gap: 6px; + height: 100%; +} - &:hover { - color: var(--color-focus-blue); - background: rgba(176, 206, 255, 0.221); - } +.emptyTitle { + font-size: 0.88rem; + color: var(--color-text); +} - &.active { - background-color: var(--color-focus-blue); - color: #fff; - } +.emptyHint { + font-size: 0.78rem; + color: var(--color-text-secondary); } -.tabCount { - font-family: var(--font-normal); - font-size: 0.7em; - margin-left: 6px; - padding: 2px 8px; - display: inline-flex; - justify-content: center; +/* ---------- 页脚 ---------- */ + +.footer { + display: flex; align-items: center; - background-color: rgba(255, 255, 255, 0.15); - border-radius: 10px; - font-weight: 600; - min-width: 20px; + gap: 14px; + height: 36px; flex-shrink: 0; + padding: 0 18px; + border-top: 1px solid var(--color-card-border); + background: var(--color-track); + font-size: 0.72rem; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; } diff --git a/src/components/rules/Rules.tsx b/src/components/rules/Rules.tsx index c0b02ae..5bf00a5 100644 --- a/src/components/rules/Rules.tsx +++ b/src/components/rules/Rules.tsx @@ -1,42 +1,29 @@ -import cx from 'clsx'; -import React from 'react'; +import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { List as VirtualList, RowComponentProps } from 'react-window'; +import { RowComponentProps, List as VirtualList } from 'react-window'; -import ContentHeader from '~/components/ContentHeader'; -import { TextFilter } from '~/components/shared/TextFitler'; -import useRemainingViewPortHeight from '~/hooks/useRemainingViewPortHeight'; -import { useRulesPage } from '~/modules/rules/hooks'; -import { formatQty, getItemSizeFactory, RulesListItemData } from '~/modules/rules/utils'; -import { ruleFilterText } from '~/store/rules'; +import { useRulesPage, useUpdateAllRuleProviderItems } from '~/modules/rules/hooks'; +import { PROVIDER_ROW_HEIGHT, RULE_ROW_HEIGHT, type RulesRowProps } from '~/modules/rules/utils'; import { ClashAPIConfig } from '~/types'; import Rule from './Rule'; import { RuleProviderItem } from './RuleProviderItem'; import s from './Rules.module.scss'; -import { RulesPageFab } from './RulesPageFab'; - -type RulesRowProps = { - data: RulesListItemData; -}; - -function Row({ index, style, data }: RowComponentProps<RulesRowProps>) { - const { rules, provider, apiConfig } = data; +import { RulesHeader } from './RulesHeader'; +function Row({ index, style, rules, provider, apiConfig }: RowComponentProps<RulesRowProps>) { if (!rules) { - const name = provider.names[index]; - const item = provider.byName[name]; + const item = provider.byName[provider.names[index]]; return ( - <div style={style} className={s.RuleProviderItemWrapper}> + <div style={style} className={s.row}> <RuleProviderItem apiConfig={apiConfig} {...item} /> </div> ); } - const r = rules[index]; return ( - <div style={style}> - <Rule {...r} /> + <div style={style} className={s.row}> + <Rule {...rules[index]} apiConfig={apiConfig} provider={provider} /> </div> ); } @@ -46,59 +33,69 @@ type RulesProps = { }; export default function Rules({ apiConfig }: RulesProps) { - const [refRulesContainer, containerHeight] = useRemainingViewPortHeight(); - const { rules, provider, activeTab, setActiveTab, isRulesTab, handleTabKeyDown } = + const { t } = useTranslation(); + const { rules, provider, providerCount, activeTab, setActiveTab, isRulesTab } = useRulesPage(apiConfig); - const getItemSize = getItemSizeFactory({ isRulesTab }); + const [updateAllProviders, isUpdatingProviders] = useUpdateAllRuleProviderItems(apiConfig); - const { t } = useTranslation(); + const rowCount = isRulesTab ? rules.length : provider.names.length; + + // rowProps 每次渲染新建对象会让所有行跟着重渲染,虚拟列表就白做了 + const rowProps = React.useMemo<RulesRowProps>( + () => ({ rules: isRulesTab ? rules : null, provider, apiConfig }), + [isRulesTab, rules, provider, apiConfig], + ); + + const rowHeight = React.useCallback( + () => (isRulesTab ? RULE_ROW_HEIGHT : PROVIDER_ROW_HEIGHT), + [isRulesTab], + ); + + const rowKey = React.useCallback( + (index: number, { rules, provider }: RulesRowProps) => + rules ? rules[index].id : provider.names[index], + [], + ); return ( - <div className={s.container}> - <ContentHeader> - <div className={s.tabsContainer}> - <div - className={cx(s.tab, { [s.active]: activeTab === 'rules' })} - onClick={() => setActiveTab('rules')} - onKeyDown={handleTabKeyDown('rules')} - role="button" - tabIndex={0} - > - {t('Rules')} - <span className={s.tabCount}>{formatQty(rules.length)}</span> + <div className={s.page}> + <RulesHeader + activeTab={activeTab} + setActiveTab={setActiveTab} + ruleCount={rules.length} + providerCount={providerCount} + visibleProviderCount={provider.names.length} + onUpdateAllProviders={updateAllProviders} + isUpdatingProviders={isUpdatingProviders} + /> + + <div className={s.listArea}> + <div className={s.card}> + <div className={s.listWrap}> + {rowCount === 0 ? ( + <div className={s.empty}> + <span className={s.emptyTitle}> + {t(isRulesTab ? 'rules_empty_title' : 'rule_providers_empty_title')} + </span> + <span className={s.emptyHint}>{t('rules_empty_hint')}</span> + </div> + ) : ( + <VirtualList + style={{ height: '100%', width: '100%' }} + rowCount={rowCount} + rowHeight={rowHeight} + rowComponent={Row} + rowKey={rowKey} + rowProps={rowProps} + /> + )} + </div> + + <div className={s.footer}> + <span>{t('rules_shown', { count: rowCount })}</span> </div> - {provider.names.length > 0 && ( - <div - className={cx(s.tab, { [s.active]: activeTab === 'providers' })} - onClick={() => setActiveTab('providers')} - onKeyDown={handleTabKeyDown('providers')} - role="button" - tabIndex={0} - > - {t('rule_provider')} - <span className={s.tabCount}>{formatQty(provider.names.length)}</span> - </div> - )} - </div> - <div style={{ flex: 1 }} /> - <div className={s.filterWrapper}> - <TextFilter textAtom={ruleFilterText} placeholder={t('Search')} /> </div> - </ContentHeader> - <div ref={refRulesContainer} className={s.listWrapper}> - <VirtualList - style={{ height: containerHeight, width: '100%' }} - rowCount={isRulesTab ? rules.length : provider.names.length} - rowHeight={getItemSize} - rowComponent={Row} - rowProps={{ - data: { rules: isRulesTab ? rules : null, provider, apiConfig } as RulesListItemData, - }} - /> </div> - {provider && provider.names && provider.names.length > 0 ? ( - <RulesPageFab apiConfig={apiConfig} /> - ) : null} </div> ); } diff --git a/src/components/rules/RulesHeader.tsx b/src/components/rules/RulesHeader.tsx new file mode 100644 index 0000000..f15a863 --- /dev/null +++ b/src/components/rules/RulesHeader.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from 'react-i18next'; + +import { + HeaderActions, + HeaderButton, + HeaderSearch, + HeaderTab, + HeaderTabs, + HeaderTitle, + PageHeader, +} from '~/components/shared/PageHeader'; +import { RotateIcon } from '~/components/shared/RotateIcon'; +import { TextFilter } from '~/components/shared/TextFilter'; +import type { RulesTabKey } from '~/modules/rules/utils'; +import { ruleFilterText } from '~/store/rules'; + +type Props = { + activeTab: RulesTabKey; + setActiveTab: (tab: RulesTabKey) => void; + /** 搜索后可见的规则数 */ + ruleCount: number; + /** 提供商总数,决定标签是否出现 */ + providerCount: number; + /** 搜索后可见的提供商数,只影响标签上的计数 */ + visibleProviderCount: number; + onUpdateAllProviders: () => void; + isUpdatingProviders: boolean; +}; + +export function RulesHeader({ + activeTab, + setActiveTab, + ruleCount, + providerCount, + visibleProviderCount, + onUpdateAllProviders, + isUpdatingProviders, +}: Props) { + const { t } = useTranslation(); + + return ( + <PageHeader> + <HeaderTitle>{t('Rules')}</HeaderTitle> + + <HeaderTabs label={t('Rules')}> + <HeaderTab + active={activeTab === 'rules'} + label={t('Rules')} + count={ruleCount} + onClick={() => setActiveTab('rules')} + /> + {providerCount > 0 ? ( + <HeaderTab + active={activeTab === 'providers'} + label={t('rule_provider')} + count={visibleProviderCount} + onClick={() => setActiveTab('providers')} + /> + ) : null} + </HeaderTabs> + + <HeaderSearch> + <TextFilter textAtom={ruleFilterText} placeholder={t('search_rules_placeholder')} /> + </HeaderSearch> + + <HeaderActions> + {/* 和代理页一致:更新全部只在提供商标签页出现 */} + {activeTab === 'providers' && providerCount > 0 ? ( + <HeaderButton + variant="primary" + icon={<RotateIcon isRotating={isUpdatingProviders} />} + label={t('update_all_rule_provider')} + busy={isUpdatingProviders} + onClick={onUpdateAllProviders} + /> + ) : null} + </HeaderActions> + </PageHeader> + ); +} diff --git a/src/components/rules/RulesPageFab.tsx b/src/components/rules/RulesPageFab.tsx deleted file mode 100644 index f9dee23..0000000 --- a/src/components/rules/RulesPageFab.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { Fab, position as fabPosition } from '~/components/shared/Fab'; -import { RotateIcon } from '~/components/shared/RotateIcon'; -import { useUpdateAllRuleProviderItems } from '~/modules/rules/hooks'; -import { ClashAPIConfig } from '~/types'; - -type RulesPageFabProps = { - apiConfig: ClashAPIConfig; -}; - -export function RulesPageFab({ apiConfig }: RulesPageFabProps) { - const [update, isLoading] = useUpdateAllRuleProviderItems(apiConfig); - const { t } = useTranslation(); - return ( - <Fab - icon={<RotateIcon isRotating={isLoading} />} - text={t('update_all_rule_provider')} - style={fabPosition} - onClick={update} - /> - ); -} |
