diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/rules.ts | 43 | ||||
| -rw-r--r-- | src/components/rules/Rule.module.scss | 28 | ||||
| -rw-r--r-- | src/components/rules/Rule.tsx | 77 | ||||
| -rw-r--r-- | src/components/rules/Rules.tsx | 2 | ||||
| -rw-r--r-- | src/i18n/en.ts | 5 | ||||
| -rw-r--r-- | src/i18n/zh-cn.ts | 5 | ||||
| -rw-r--r-- | src/modules/rules/hooks.ts | 18 | ||||
| -rw-r--r-- | src/modules/rules/utils.ts | 2 |
8 files changed, 168 insertions, 12 deletions
diff --git a/src/api/rules.ts b/src/api/rules.ts index 5537c31..4e65e9d 100644 --- a/src/api/rules.ts +++ b/src/api/rules.ts @@ -5,13 +5,23 @@ import { ClashAPIConfig } from '~/types'; // const endpoint = '/rules'; -type RuleItem = RuleAPIItem & { id: number }; +export type RuleExtra = { + disabled: boolean; + hitCount: number; + hitAt: string; + missCount: number; + missAt: string; +}; + +export type RuleItem = RuleAPIItem & { id: number }; -type RuleAPIItem = { +export type RuleAPIItem = { + index?: number; type: string; payload: string; proxy: string; size: number; + extra?: RuleExtra; }; function normalizeAPIResponse(json: { rules: Array<RuleAPIItem> }): Array<RuleItem> { @@ -20,8 +30,11 @@ function normalizeAPIResponse(json: { rules: Array<RuleAPIItem> }): Array<RuleIt 'there is no valid rules list in the rules API response' ); - // attach an id - return json.rules.map((r: RuleAPIItem, i: number) => ({ ...r, id: i })); + // attach an id, preferring the backend-provided index over array position + return json.rules.map((r: RuleAPIItem, i: number) => ({ + ...r, + id: typeof r.index === 'number' ? r.index : i, + })); } export async function fetchRules(endpoint: string, apiConfig: ClashAPIConfig) { @@ -34,8 +47,28 @@ export async function fetchRules(endpoint: string, apiConfig: ClashAPIConfig) { } } catch (err) { // log and ignore - + console.log('failed to fetch rules', err); } return normalizeAPIResponse(json); } + +export async function updateRuleDisabledStatus( + apiConfig: ClashAPIConfig, + updates: Record<number, boolean> +) { + const { url, init } = getURLAndInit(apiConfig); + try { + const res = await fetch(url + '/rules/disable', { + method: 'PATCH', + ...init, + body: JSON.stringify(updates), + }); + return res.ok; + } catch (err) { + // log and ignore + + console.log('failed to PATCH /rules/disable', err); + return false; + } +} 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> ); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 534869c..1474d5a 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -93,4 +93,9 @@ export const data = { internel: 'Internal Connection', Clear: 'Clear', group_fixed_tip: 'This group has a manually fixed selection; run a latency test to release it', + rule_entry_count: '{{count}} entries', + rule_hit_tip: 'Hit {{count}} times, last {{time}}', + rule_never_hit: 'Never hit', + rule_enable: 'Enable rule', + rule_disable: 'Disable rule', }; diff --git a/src/i18n/zh-cn.ts b/src/i18n/zh-cn.ts index f394279..664becb 100644 --- a/src/i18n/zh-cn.ts +++ b/src/i18n/zh-cn.ts @@ -94,4 +94,9 @@ export const data = { internel: '内部链接', Clear: '清空', group_fixed_tip: '该组已手动固定选择,点击测速可解除固定', + rule_entry_count: '{{count}} 条规则', + rule_hit_tip: '已命中 {{count}} 次,最近一次{{time}}', + rule_never_hit: '从未命中', + rule_enable: '启用规则', + rule_disable: '禁用规则', }; diff --git a/src/modules/rules/hooks.ts b/src/modules/rules/hooks.ts index e13ea65..20c675e 100644 --- a/src/modules/rules/hooks.ts +++ b/src/modules/rules/hooks.ts @@ -7,7 +7,7 @@ import { refreshRuleProviderByName, updateRuleProviders, } from '~/api/rule-provider'; -import { fetchRules } from '~/api/rules'; +import { fetchRules, updateRuleDisabledStatus } from '~/api/rules'; import { ruleFilterText } from '~/store/rules'; import type { ClashAPIConfig } from '~/types'; @@ -49,6 +49,22 @@ export function useUpdateAllRuleProviderItems( return [onClickRefreshButton, isPending]; } +export function useToggleRuleDisabled(apiConfig: ClashAPIConfig) { + const queryClient = useQueryClient(); + const { mutate, isPending } = useMutation({ + mutationFn: ({ index, disabled }: { index: number; disabled: boolean }) => + updateRuleDisabledStatus(apiConfig, { [index]: disabled }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/rules'] }); + }, + }); + const toggleRule = useCallback( + (index: number, disabled: boolean) => mutate({ index, disabled }), + [mutate] + ); + return { toggleRule, isPending }; +} + export function useInvalidateQueries() { const queryClient = useQueryClient(); return useCallback(() => { diff --git a/src/modules/rules/utils.ts b/src/modules/rules/utils.ts index c1d1464..8ce0fc1 100644 --- a/src/modules/rules/utils.ts +++ b/src/modules/rules/utils.ts @@ -15,7 +15,7 @@ export function itemKey(index: number, { rules, provider }: RulesListItemData) { export function getItemSizeFactory({ isRulesTab }: { isRulesTab: boolean }) { return function getItemSize() { - return isRulesTab ? 70 : 100; + return isRulesTab ? 88 : 100; }; } |
