summaryrefslogtreecommitdiff
path: root/src/components/rules
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/rules')
-rw-r--r--src/components/rules/Rule.module.scss116
-rw-r--r--src/components/rules/Rule.tsx149
-rw-r--r--src/components/rules/RuleProviderItem.module.scss108
-rw-r--r--src/components/rules/RuleProviderItem.tsx69
-rw-r--r--src/components/rules/Rules.module.scss127
-rw-r--r--src/components/rules/Rules.tsx135
-rw-r--r--src/components/rules/RulesHeader.tsx80
-rw-r--r--src/components/rules/RulesPageFab.tsx24
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}
- />
- );
-}