summaryrefslogtreecommitdiff
path: root/src/components/rules
diff options
context:
space:
mode:
authorLarvan2 <[email protected]>2026-07-05 17:36:35 +0800
committerLarvan2 <[email protected]>2026-07-05 17:36:35 +0800
commit30dfb51a61a6d6083640d411da8c63cb076318ab (patch)
tree457f95f9dafc6cf1196d35fdec5eb0d677667b23 /src/components/rules
parenta7a842ee25a5b7fc5aa0cd20e9a3086d747332bc (diff)
feat(rules): support enabling/disabling rules and show hit/entry countsHEADmaster
Wire up the mihomo rules API's extra fields (disabled, hitCount, hitAt) and the /rules/disable endpoint so each rule can be toggled on/off and shows its hit count. Also surface the rule provider's ruleCount for RuleSet-type rules, since their own size field is always -1.
Diffstat (limited to 'src/components/rules')
-rw-r--r--src/components/rules/Rule.module.scss28
-rw-r--r--src/components/rules/Rule.tsx77
-rw-r--r--src/components/rules/Rules.tsx2
3 files changed, 102 insertions, 5 deletions
diff --git a/src/components/rules/Rule.module.scss b/src/components/rules/Rule.module.scss
index 48a22e7..f13cab1 100644
--- a/src/components/rules/Rule.module.scss
+++ b/src/components/rules/Rule.module.scss
@@ -10,6 +10,10 @@
&:hover {
background-color: var(--bg-near-transparent);
}
+
+ &.disabled {
+ opacity: 0.45;
+ }
}
.left {
@@ -79,3 +83,27 @@
font-weight: 600;
letter-spacing: 0.02em;
}
+
+.hitInfo {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--color-text-secondary);
+ cursor: default;
+}
+
+.spacer {
+ flex: 1;
+}
+
+.wrapSwitch {
+ display: flex;
+ align-items: center;
+ transform: scale(0.7);
+ transform-origin: right center;
+
+ &.pending {
+ opacity: 0.6;
+ pointer-events: none;
+ }
+}
diff --git a/src/components/rules/Rule.tsx b/src/components/rules/Rule.tsx
index e1fd047..e53ff23 100644
--- a/src/components/rules/Rule.tsx
+++ b/src/components/rules/Rule.tsx
@@ -1,6 +1,13 @@
+import cx from 'clsx';
+import { formatDistanceToNow } from 'date-fns';
import React from 'react';
+import { useTranslation } from 'react-i18next';
-import { FileText, Globe, Hash, Link, Shield, Zap } from '~/components/shared/FeatherIcons';
+import { RuleExtra } from '~/api/rules';
+import { Activity, FileText, Globe, Hash, Link, Shield, Zap } from '~/components/shared/FeatherIcons';
+import SwitchThemed from '~/components/SwitchThemed';
+import { useToggleRuleDisabled } from '~/modules/rules/hooks';
+import { ClashAPIConfig } from '~/types';
import s0 from './Rule.module.scss';
@@ -39,23 +46,66 @@ function getIconFor(type: string) {
}
}
+type RuleProviderLookup = {
+ byName: Record<string, { ruleCount?: number }>;
+};
+
type Props = {
id?: number;
type?: string;
payload?: string;
proxy?: string;
size?: number;
+ extra?: RuleExtra;
+ apiConfig?: ClashAPIConfig;
+ provider?: RuleProviderLookup;
};
-function Rule({ type, payload, proxy, id, size }: Props) {
+function getEntryCount({
+ type,
+ payload,
+ size,
+ provider,
+}: {
+ type: string;
+ payload: string;
+ size: number;
+ provider?: RuleProviderLookup;
+}): 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 styleProxy = getStyleFor({ proxy });
+ 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={cx(s0.rule, { [s0.disabled]: disabled })}>
<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>}
+ {typeof entryCount === 'number' && (
+ <div className={s0.size}>{t('rule_entry_count', { count: entryCount })}</div>
+ )}
</div>
<div className={s0.metaRow}>
<div className={s0.typeTag}>
@@ -65,6 +115,25 @@ function Rule({ type, payload, proxy, id, size }: Props) {
<div className={s0.proxyTag} style={styleProxy}>
{proxy}
</div>
+ {extra && (
+ <div className={s0.hitInfo} title={hitTitle}>
+ <Activity size={12} />
+ <span>{extra.hitCount}</span>
+ </div>
+ )}
+ <div className={s0.spacer} />
+ {extra && (
+ <div
+ className={cx(s0.wrapSwitch, { [s0.pending]: isPending })}
+ title={disabled ? t('rule_enable') : t('rule_disable')}
+ >
+ <SwitchThemed
+ name={`rule-${id}`}
+ checked={!disabled}
+ onChange={(checked: boolean) => toggleRule(id, !checked)}
+ />
+ </div>
+ )}
</div>
</div>
</div>
diff --git a/src/components/rules/Rules.tsx b/src/components/rules/Rules.tsx
index c0b02ae..45c55fc 100644
--- a/src/components/rules/Rules.tsx
+++ b/src/components/rules/Rules.tsx
@@ -36,7 +36,7 @@ function Row({ index, style, data }: RowComponentProps<RulesRowProps>) {
const r = rules[index];
return (
<div style={style}>
- <Rule {...r} />
+ <Rule {...r} apiConfig={apiConfig} provider={provider} />
</div>
);
}