summaryrefslogtreecommitdiff
path: root/src/modules
diff options
context:
space:
mode:
Diffstat (limited to 'src/modules')
-rw-r--r--src/modules/backend/hooks.ts100
-rw-r--r--src/modules/backend/utils.ts98
-rw-r--r--src/modules/config/hooks.ts59
-rw-r--r--src/modules/config/utils.ts4
-rw-r--r--src/modules/connections/hooks.ts208
-rw-r--r--src/modules/connections/utils.ts263
-rw-r--r--src/modules/home/hooks.ts175
-rw-r--r--src/modules/home/utils.ts80
-rw-r--r--src/modules/logs/hooks.ts28
-rw-r--r--src/modules/logs/utils.ts4
-rw-r--r--src/modules/proxies/hooks.ts606
-rw-r--r--src/modules/proxies/utils.ts235
-rw-r--r--src/modules/rules/hooks.ts100
-rw-r--r--src/modules/rules/utils.ts36
14 files changed, 1577 insertions, 419 deletions
diff --git a/src/modules/backend/hooks.ts b/src/modules/backend/hooks.ts
index 04e0c56..465d6da 100644
--- a/src/modules/backend/hooks.ts
+++ b/src/modules/backend/hooks.ts
@@ -5,27 +5,55 @@ import { closeModal } from '~/store/modals';
import type { DispatchFn } from '~/store/types';
import type { ClashAPIConfig } from '~/types';
-import { detectEmbeddedAPIBaseURL, normalizeAPIBaseURL, verifyAPIConfig } from './utils';
+import {
+ buildAPIBaseURL,
+ DEFAULT_BACKEND_FIELDS,
+ detectEmbeddedAPIBaseURL,
+ splitAPIBaseURL,
+ splitPastedHost,
+ verifyAPIConfig,
+ type BackendFields,
+ type Protocol,
+} from './utils';
-const { useCallback, useEffect, useState } = React;
+const { useCallback, useEffect, useMemo, useRef, useState } = React;
export function useBackendConfigForm({
onAddConfig,
+ onUpdateConfig,
}: {
onAddConfig: (config: ClashAPIConfig) => void;
+ onUpdateConfig: (prev: ClashAPIConfig, next: ClashAPIConfig) => void;
}) {
- const [baseURL, setBaseURL] = useState('');
+ const [fields, setFields] = useState<BackendFields>(DEFAULT_BACKEND_FIELDS);
const [secret, setSecret] = useState('');
const [errMsg, setErrMsg] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ // 非 null 时表单是在改这一条已保存的配置,而不是新增
+ const [editing, setEditing] = useState<ClashAPIConfig | null>(null);
+ // 用户已经动过表单后,自动探测的结果不能再覆盖回去
+ const isFormDirty = useRef(false);
+
+ const handleProtocolOnChange = useCallback((protocol: Protocol) => {
+ setErrMsg('');
+ isFormDirty.current = true;
+ setFields((prev) => ({ ...prev, protocol }));
+ }, []);
const handleInputOnChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setErrMsg('');
- const target = e.target;
- const { name, value } = target;
+ isFormDirty.current = true;
+ const { name, value } = e.target;
switch (name) {
- case 'baseURL':
- setBaseURL(value);
+ case 'host': {
+ // 整条地址粘进来时自动拆分,省得用户手动删协议和端口
+ const pasted = splitPastedHost(value);
+ setFields((prev) => (pasted ? { ...prev, ...pasted } : { ...prev, host: value }));
+ break;
+ }
+ case 'port':
+ setFields((prev) => ({ ...prev, port: value }));
break;
case 'secret':
setSecret(value);
@@ -35,23 +63,53 @@ export function useBackendConfigForm({
}
}, []);
+ const baseURLPreview = useMemo(() => {
+ const built = buildAPIBaseURL(fields);
+ return 'baseURL' in built ? built.baseURL : '';
+ }, [fields]);
+
+ const resetForm = useCallback(() => {
+ isFormDirty.current = true;
+ setEditing(null);
+ setFields(DEFAULT_BACKEND_FIELDS);
+ setSecret('');
+ setErrMsg('');
+ }, []);
+
+ const startEdit = useCallback((config: ClashAPIConfig) => {
+ const parsed = splitAPIBaseURL(config.baseURL);
+ if (!parsed) return;
+ isFormDirty.current = true;
+ setEditing(config);
+ setFields(parsed);
+ setSecret(config.secret ?? '');
+ setErrMsg('');
+ }, []);
+
const onConfirm = useCallback(() => {
- const normalizedResult = normalizeAPIBaseURL(baseURL, window.location.protocol);
- if ('error' in normalizedResult) {
- setErrMsg(normalizedResult.error);
+ const built = buildAPIBaseURL(fields);
+ if ('error' in built) {
+ setErrMsg(built.error);
return;
}
- const nextConfig = { baseURL: normalizedResult.baseURL, secret };
+ const nextConfig = { baseURL: built.baseURL, secret };
+ setIsSubmitting(true);
verifyAPIConfig(nextConfig).then(([status, message]) => {
+ setIsSubmitting(false);
if (status !== 0) {
setErrMsg(message ?? 'Failed to connect');
return;
}
- onAddConfig(nextConfig);
+ if (editing) {
+ onUpdateConfig(editing, nextConfig);
+ resetForm();
+ } else {
+ onAddConfig(nextConfig);
+ }
});
- }, [baseURL, onAddConfig, secret]);
+ }, [editing, fields, onAddConfig, onUpdateConfig, resetForm, secret]);
const handleContentOnKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -66,16 +124,16 @@ export function useBackendConfigForm({
onConfirm();
},
- [onConfirm]
+ [onConfirm],
);
useEffect(() => {
let isCancelled = false;
detectEmbeddedAPIBaseURL().then((detectedBaseURL) => {
- if (!isCancelled && detectedBaseURL) {
- setBaseURL(detectedBaseURL);
- }
+ if (isCancelled || isFormDirty.current || !detectedBaseURL) return;
+ const detected = splitAPIBaseURL(detectedBaseURL);
+ if (detected) setFields(detected);
});
return () => {
@@ -84,9 +142,15 @@ export function useBackendConfigForm({
}, []);
return {
- baseURL,
+ ...fields,
secret,
errMsg,
+ baseURLPreview,
+ isSubmitting,
+ editing,
+ startEdit,
+ cancelEdit: resetForm,
+ handleProtocolOnChange,
handleInputOnChange,
handleContentOnKeyDown,
onConfirm,
diff --git a/src/modules/backend/utils.ts b/src/modules/backend/utils.ts
index 47d2bd1..7d63251 100644
--- a/src/modules/backend/utils.ts
+++ b/src/modules/backend/utils.ts
@@ -1,41 +1,88 @@
import { fetchConfigs } from '~/api/configs';
import type { ClashAPIConfig } from '~/types';
-export const DEFAULT_API_BASE_URL = 'http://127.0.0.1:9090';
+export type Protocol = 'http' | 'https';
-const Ok = 0;
+/** 后端地址在表单里被拆成三段独立编辑 */
+export type BackendFields = {
+ protocol: Protocol;
+ host: string;
+ port: string;
+};
-export function normalizeAPIBaseURL(baseURL: string, currentProtocol: string) {
- let normalizedBaseURL = baseURL || DEFAULT_API_BASE_URL;
+export const DEFAULT_BACKEND_FIELDS: BackendFields = {
+ protocol: 'http',
+ host: '127.0.0.1',
+ port: '9090',
+};
- if (normalizedBaseURL) {
- const prefix = normalizedBaseURL.substring(0, 7);
- if (prefix.includes(':/')) {
- if (prefix !== 'http://' && prefix !== 'https:/') {
- return { error: 'Must starts with http:// or https://' };
- }
- } else if (currentProtocol) {
- normalizedBaseURL = `${currentProtocol}//${normalizedBaseURL}`;
- }
- }
+const Ok = 0;
- return { baseURL: normalizedBaseURL };
+/** IPv6 在 URL 里带方括号,表单里只展示裸地址 */
+function stripBrackets(host: string) {
+ return host.replace(/^\[/, '').replace(/\]$/, '');
}
-export async function verifyAPIConfig(apiConfig: ClashAPIConfig): Promise<[number, string?]> {
+function isIPv6(host: string) {
+ return host.includes(':');
+}
+
+/** 把完整 baseURL 拆成协议 / 主机 / 端口,解析失败返回 null */
+export function splitAPIBaseURL(baseURL: string): BackendFields | null {
+ let url: URL;
try {
- new URL(apiConfig.baseURL);
+ url = new URL(baseURL);
} catch (e) {
- if (apiConfig.baseURL) {
- const prefix = apiConfig.baseURL.substring(0, 7);
- if (prefix !== 'http://' && prefix !== 'https:/') {
- return [1, 'Must starts with http:// or https://'];
- }
- }
+ return null;
+ }
- return [1, 'Invalid URL'];
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
+ const protocol: Protocol = url.protocol === 'https:' ? 'https' : 'http';
+
+ return {
+ protocol,
+ host: stripBrackets(url.hostname),
+ port: url.port || (protocol === 'https' ? '443' : '80'),
+ };
+}
+
+/**
+ * 允许把一整条地址粘贴进 Host 输入框,自动拆到对应字段。
+ * 返回 null 表示这就是个普通主机名,按原样填入 Host 即可。
+ */
+export function splitPastedHost(value: string): Partial<BackendFields> | null {
+ const text = value.trim();
+ const hasProtocol = text.includes('://');
+ if (!hasProtocol && !/^[^\s/:]+:\d+$/.test(text)) return null;
+
+ const fields = splitAPIBaseURL(hasProtocol ? text : `http://${text}`);
+ if (!fields) return null;
+
+ // 没写协议时不要凭空替用户选一个
+ return hasProtocol ? fields : { host: fields.host, port: fields.port };
+}
+
+/** 三段拼回 baseURL,同时做基本校验 */
+export function buildAPIBaseURL({
+ protocol,
+ host,
+ port,
+}: BackendFields): { baseURL: string } | { error: string } {
+ const trimmedHost = stripBrackets(host.trim());
+ const trimmedPort = port.trim();
+
+ if (!trimmedHost) return { error: 'Host is required' };
+ if (/[\s/?#]/.test(trimmedHost)) return { error: 'Invalid host' };
+ if (!trimmedPort) return { error: 'Port is required' };
+ if (!/^\d{1,5}$/.test(trimmedPort) || Number(trimmedPort) < 1 || Number(trimmedPort) > 65535) {
+ return { error: 'Port must be a number between 1 and 65535' };
}
+ const hostname = isIPv6(trimmedHost) ? `[${trimmedHost}]` : trimmedHost;
+ return { baseURL: `${protocol}://${hostname}:${trimmedPort}` };
+}
+
+export async function verifyAPIConfig(apiConfig: ClashAPIConfig): Promise<[number, string?]> {
try {
const res = await fetchConfigs(apiConfig);
if (res.status > 399) {
@@ -50,6 +97,9 @@ export async function verifyAPIConfig(apiConfig: ClashAPIConfig): Promise<[numbe
export async function detectEmbeddedAPIBaseURL() {
try {
const res = await fetch('/');
+ // 内核设置了 secret 时根路径返回 401,这同样说明当前 origin 就是 API 地址
+ // (面板被内核自己托管在 /ui/ 下的常见情况)
+ if (res.status === 401) return window.location.origin;
if (res.headers.get('content-type')?.includes('application/json')) {
const data = await res.json();
if (data.hello === 'clash') {
diff --git a/src/modules/config/hooks.ts b/src/modules/config/hooks.ts
index e09a4dd..4efaaf5 100644
--- a/src/modules/config/hooks.ts
+++ b/src/modules/config/hooks.ts
@@ -1,6 +1,8 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
+import type { UpgradeChannel } from '~/api/configs';
import * as logsApi from '~/api/logs';
import { fetchVersion } from '~/api/version';
import {
@@ -14,11 +16,16 @@ import {
upgradeUI,
} from '~/store/configs';
import { openModal } from '~/store/modals';
-import { ClashGeneralConfig, DispatchFn } from '~/store/types';
+import { toast } from '~/store/toast';
+import { ClashGeneralConfig, ClashTunConfig, DispatchFn } from '~/store/types';
+import { unregisterAndReload } from '~/swRegistration';
import { ClashAPIConfig } from '~/types';
const { useCallback, useEffect, useRef, useState } = React;
+// 面板更新成功后到自动刷新之间的间隔,够看清通知即可
+const UI_RELOAD_DELAY_MS = 1500;
+
type UpdateAppConfigFn = (name: string, value: unknown) => void;
function useConfigVersionQuery(apiConfig: ClashAPIConfig) {
@@ -46,7 +53,7 @@ export function useConfigState(configs: ClashGeneralConfig) {
const setTunConfigState = useCallback((name: string, value: any) => {
setConfigStateInternal((prev) => ({
...prev,
- tun: { ...prev.tun, [name]: value },
+ tun: { ...prev.tun, [name]: value } as ClashTunConfig,
}));
}, []);
@@ -68,6 +75,8 @@ export function useConfigPage({
dispatch: DispatchFn;
updateAppConfig: UpdateAppConfigFn;
}) {
+ const { t } = useTranslation();
+
useEffect(() => {
dispatch(fetchConfigs(apiConfig));
}, [apiConfig, dispatch]);
@@ -112,14 +121,14 @@ export function useConfigPage({
return;
}
},
- [apiConfig, dispatch, setConfigState, setTunConfigState]
+ [apiConfig, dispatch, setConfigState, setTunConfigState],
);
const handleInputOnBlur = useCallback(
(
e:
| React.FocusEvent<HTMLSelectElement | HTMLInputElement>
- | React.ChangeEvent<HTMLSelectElement | HTMLInputElement>
+ | React.ChangeEvent<HTMLSelectElement | HTMLInputElement>,
) => {
const { name, value } = e.target;
@@ -144,7 +153,7 @@ export function useConfigPage({
throw new Error(`unknown input name ${name}`);
}
},
- [apiConfig, dispatch, updateAppConfig]
+ [apiConfig, dispatch, updateAppConfig],
);
const handleReloadConfigFile = useCallback(() => {
@@ -155,17 +164,43 @@ export function useConfigPage({
dispatch(restartCore(apiConfig));
}, [apiConfig, dispatch]);
- const handleUpgradeCore = useCallback(() => {
- dispatch(upgradeCore(apiConfig));
- }, [apiConfig, dispatch]);
+ // 正在更新的通道,null 表示空闲;同时用来给两个按钮做 loading / 互斥
+ const [upgradingChannel, setUpgradingChannel] = useState<UpgradeChannel | null>(null);
+
+ const handleUpgradeCore = useCallback(
+ async (channel: UpgradeChannel) => {
+ if (upgradingChannel !== null) return;
+ setUpgradingChannel(channel);
+ const result = await dispatch(upgradeCore(apiConfig, channel));
+ setUpgradingChannel(null);
+ if (result.ok) {
+ toast('success', t('upgrade_core_success'));
+ } else {
+ toast('error', t('upgrade_core_failed', { message: result.message }));
+ }
+ },
+ [apiConfig, dispatch, t, upgradingChannel],
+ );
const handleUpgradeGeo = useCallback(() => {
dispatch(upgradeGeo(apiConfig));
}, [apiConfig, dispatch]);
- const handleUpgradeUI = useCallback(() => {
- dispatch(upgradeUI(apiConfig));
- }, [apiConfig, dispatch]);
+ const [isUpgradingUI, setIsUpgradingUI] = useState(false);
+
+ const handleUpgradeUI = useCallback(async () => {
+ if (isUpgradingUI) return;
+ setIsUpgradingUI(true);
+ const result = await dispatch(upgradeUI(apiConfig));
+ setIsUpgradingUI(false);
+ if (result.ok) {
+ toast('success', t('upgrade_ui_success'));
+ // 留一点时间让通知露个面,再带着清缓存整页刷新
+ setTimeout(unregisterAndReload, UI_RELOAD_DELAY_MS);
+ } else {
+ toast('error', t('upgrade_ui_failed', { message: result.message }));
+ }
+ }, [apiConfig, dispatch, isUpgradingUI, t]);
const handleFlushFakeIPPool = useCallback(() => {
dispatch(flushFakeIPPool(apiConfig));
@@ -179,8 +214,10 @@ export function useConfigPage({
handleReloadConfigFile,
handleRestartCore,
handleUpgradeCore,
+ upgradingChannel,
handleUpgradeGeo,
handleUpgradeUI,
+ isUpgradingUI,
handleFlushFakeIPPool,
versionQuery,
};
diff --git a/src/modules/config/utils.ts b/src/modules/config/utils.ts
index 8300311..444622e 100644
--- a/src/modules/config/utils.ts
+++ b/src/modules/config/utils.ts
@@ -1,6 +1,8 @@
export type SelectOption = [string, string];
+/** 只有这几个端口字段会被渲染成输入框,故意收窄成字面量联合,好让 configState[key] 能推出 number */
+export type PortFieldKey = 'port' | 'socks-port' | 'mixed-port' | 'redir-port' | 'mitm-port';
export type PortField = {
- key: string;
+ key: PortFieldKey;
label: string;
};
diff --git a/src/modules/connections/hooks.ts b/src/modules/connections/hooks.ts
index d733734..cb40ca5 100644
--- a/src/modules/connections/hooks.ts
+++ b/src/modules/connections/hooks.ts
@@ -6,6 +6,7 @@ import * as connAPI from '~/api/connections';
import {
closedConnectionsState,
connectionsState,
+ connectionsTotalState,
FormattedConn,
isRefreshPausedState,
MAX_CLOSED_CONNECTIONS,
@@ -15,17 +16,19 @@ import { ClashAPIConfig } from '~/types';
import {
ALL_SOURCE_IP,
arrayToIdKv,
+ buildHideRegExp,
+ CONNECTION_COLUMN_MAP,
+ CONNECTION_COLUMNS,
CONNECTION_COLUMNS_DEFAULT,
- ConnectionColumn,
+ ConnectionSettings,
filterConns,
formatConnectionDataItem,
getInitialColumns,
- getInitialHiddenColumns,
+ getInitialSettings,
getInitialSourceMap,
getNameFromSource,
- HIDDEN_COLUMNS_DEFAULT,
saveColumns,
- saveHiddenColumns,
+ saveSettings,
saveSourceMap,
SourceMapItem,
} from './utils';
@@ -33,36 +36,24 @@ import {
const { useCallback, useEffect, useMemo, useRef, useState } = React;
export function useSourceMapState() {
- const [sourceMapModal, setSourceMapModal] = useState(false);
- const [sourceMap, setSourceMap] = useState<SourceMapItem[]>(() => getInitialSourceMap());
+ const [sourceMap, setSourceMapState] = useState<SourceMapItem[]>(() => getInitialSourceMap());
- const openModalSource = useCallback(() => {
- setSourceMap((prev) => (prev.length === 0 ? [{ reg: '', name: '' }] : prev));
- setSourceMapModal(true);
- }, []);
-
- const closeModalSource = useCallback(() => {
- setSourceMap((prev) => {
- const nextSourceMap = prev.filter((item) => item.reg || item.name);
- saveSourceMap(nextSourceMap);
- return nextSourceMap;
+ const setSourceMap = useCallback((updater: React.SetStateAction<SourceMapItem[]>) => {
+ setSourceMapState((prev) => {
+ const next = typeof updater === 'function' ? updater(prev) : updater;
+ saveSourceMap(next.filter((item) => item.reg || item.name));
+ return next;
});
- setSourceMapModal(false);
}, []);
- return {
- sourceMap,
- setSourceMap,
- sourceMapModal,
- openModalSource,
- closeModalSource,
- };
+ return { sourceMap, setSourceMap };
}
export function useConnectionsStream(apiConfig: ClashAPIConfig, sourceMap: SourceMapItem[]) {
const [conns, setConns] = useAtom(connectionsState);
const [closedConns, setClosedConns] = useAtom(closedConnectionsState);
const [isRefreshPaused, setIsRefreshPaused] = useAtom(isRefreshPausedState);
+ const [total, setTotal] = useAtom(connectionsTotalState);
const [reConnectCount, setReConnectCount] = useState(0);
const prevConnsRef = useRef<FormattedConn[]>(conns);
@@ -75,7 +66,15 @@ export function useConnectionsStream(apiConfig: ClashAPIConfig, sourceMap: Sourc
}, [apiConfig]);
const read = useCallback(
- ({ connections }: { connections: ConnectionItem[] }) => {
+ ({
+ connections,
+ downloadTotal,
+ uploadTotal,
+ }: {
+ connections: ConnectionItem[];
+ downloadTotal?: number;
+ uploadTotal?: number;
+ }) => {
// skip all processing while paused or in a background tab; prevConnsRef
// keeps the last committed snapshot as the baseline, so closed
// connections are still detected against it on the first message after
@@ -87,7 +86,7 @@ export function useConnectionsStream(apiConfig: ClashAPIConfig, sourceMap: Sourc
const now = Date.now();
const nextConnections =
connections?.map((item: ConnectionItem) =>
- formatConnectionDataItem(item, prevConnsKv, now, sourceMap)
+ formatConnectionDataItem(item, prevConnsKv, now, sourceMap),
) ?? [];
const nextIds = new Set<string>();
@@ -101,12 +100,18 @@ export function useConnectionsStream(apiConfig: ClashAPIConfig, sourceMap: Sourc
setClosedConns((prev) => [...closed, ...prev].slice(0, MAX_CLOSED_CONNECTIONS + 1));
}
+ setTotal((prev) =>
+ prev.download === downloadTotal && prev.upload === uploadTotal
+ ? prev
+ : { download: downloadTotal ?? 0, upload: uploadTotal ?? 0 },
+ );
+
if (nextConnections.length !== 0 || prevConnsRef.current.length !== 0) {
prevConnsRef.current = nextConnections;
setConns(nextConnections);
}
},
- [isRefreshPaused, setClosedConns, setConns, sourceMap]
+ [isRefreshPaused, setClosedConns, setConns, setTotal, sourceMap],
);
useEffect(() => {
@@ -120,53 +125,92 @@ export function useConnectionsStream(apiConfig: ClashAPIConfig, sourceMap: Sourc
return {
conns,
closedConns,
+ total,
isRefreshPaused,
toggleIsRefreshPaused,
closeAllConnections,
};
}
+/** 已启用列(有序)与可添加列的增删改查 */
export function useConnectionColumns() {
- const [hiddenColumns, setHiddenColumnsState] = useState<string[]>(() =>
- getInitialHiddenColumns()
- );
- const [columns, setColumnsState] = useState<ConnectionColumn[]>(() => getInitialColumns());
+ const [columns, setColumnsState] = useState<string[]>(() => getInitialColumns());
- const setHiddenColumns = useCallback((nextHiddenColumns: string[]) => {
- setHiddenColumnsState(nextHiddenColumns);
- saveHiddenColumns(nextHiddenColumns);
+ const setColumns = useCallback((next: string[]) => {
+ setColumnsState(next);
+ saveColumns(next);
}, []);
- const setColumns = useCallback((nextColumns: ConnectionColumn[]) => {
- setColumnsState(nextColumns);
- saveColumns(nextColumns);
- }, []);
+ const addColumn = useCallback(
+ (id: string) => setColumns([...columns, id]),
+ [columns, setColumns],
+ );
- const resetColumns = useCallback(() => {
- setHiddenColumnsState([...HIDDEN_COLUMNS_DEFAULT]);
- setColumnsState([...CONNECTION_COLUMNS_DEFAULT]);
- saveHiddenColumns([...HIDDEN_COLUMNS_DEFAULT]);
- saveColumns([...CONNECTION_COLUMNS_DEFAULT]);
- }, []);
+ const removeColumn = useCallback(
+ (id: string) => setColumns(columns.filter((each) => each !== id)),
+ [columns, setColumns],
+ );
+
+ const reorderColumns = useCallback(
+ (fromIndex: number, toIndex: number) => {
+ if (fromIndex === toIndex) return;
+ const next = [...columns];
+ const [moved] = next.splice(fromIndex, 1);
+ next.splice(toIndex, 0, moved);
+ setColumns(next);
+ },
+ [columns, setColumns],
+ );
+
+ const resetColumns = useCallback(() => setColumns([...CONNECTION_COLUMNS_DEFAULT]), [setColumns]);
+
+ const visibleColumns = useMemo(
+ () => columns.map((id) => CONNECTION_COLUMN_MAP[id]).filter(Boolean),
+ [columns],
+ );
+
+ const availableColumns = useMemo(
+ () => CONNECTION_COLUMNS.filter((column) => !columns.includes(column.id)),
+ [columns],
+ );
return {
- hiddenColumns,
columns,
- setHiddenColumns,
- setColumns,
+ visibleColumns,
+ availableColumns,
+ addColumn,
+ removeColumn,
+ reorderColumns,
resetColumns,
};
}
+/** 隐藏正则 / 完整代理链等页面级设置,写 localStorage */
+export function useConnectionSettings() {
+ const [settings, setSettingsState] = useState<ConnectionSettings>(() => getInitialSettings());
+
+ const updateSettings = useCallback((patch: Partial<ConnectionSettings>) => {
+ setSettingsState((prev) => {
+ const next = { ...prev, ...patch };
+ saveSettings(next);
+ return next;
+ });
+ }, []);
+
+ return { settings, updateSettings };
+}
+
export function useConnectionFilters({
conns,
closedConns,
sourceMap,
+ settings,
t,
}: {
conns: FormattedConn[];
closedConns: FormattedConn[];
sourceMap: SourceMapItem[];
+ settings: ConnectionSettings;
t: (key: string) => string;
}) {
const [filterKeyword, setFilterKeyword] = useState('');
@@ -186,13 +230,15 @@ export function useConnectionFilters({
return next;
}, [conns]);
+ const hideRegExp = useMemo(() => buildHideRegExp(settings), [settings]);
+
const filteredConns = useMemo(
- () => filterConns(conns, filterKeyword, filterSourceIpStr),
- [conns, filterKeyword, filterSourceIpStr]
+ () => filterConns(conns, filterKeyword, filterSourceIpStr, hideRegExp),
+ [conns, filterKeyword, filterSourceIpStr, hideRegExp],
);
const filteredClosedConns = useMemo(
- () => filterConns(closedConns, filterKeyword, filterSourceIpStr),
- [closedConns, filterKeyword, filterSourceIpStr]
+ () => filterConns(closedConns, filterKeyword, filterSourceIpStr, hideRegExp),
+ [closedConns, filterKeyword, filterSourceIpStr, hideRegExp],
);
const connIpSet = useMemo(() => {
@@ -205,6 +251,8 @@ export function useConnectionFilters({
];
}, [sourceIps, sourceMap, t]);
+ const isFiltering = filterKeyword !== '' || filterSourceIpStr !== ALL_SOURCE_IP;
+
return {
filterKeyword,
setFilterKeyword,
@@ -213,5 +261,63 @@ export function useConnectionFilters({
filteredConns,
filteredClosedConns,
connIpSet,
+ isFiltering,
};
}
+
+/** 顶部四张统计卡的数据来源 */
+export function useConnectionStats(
+ conns: FormattedConn[],
+ total: { download: number; upload: number },
+) {
+ return useMemo(() => {
+ let downloadSpeed = 0;
+ let uploadSpeed = 0;
+ for (const conn of conns) {
+ downloadSpeed += conn.downloadSpeedCurr ?? 0;
+ uploadSpeed += conn.uploadSpeedCurr ?? 0;
+ }
+ return {
+ activeCount: conns.length,
+ downloadSpeed,
+ uploadSpeed,
+ downloadTotal: total.download,
+ uploadTotal: total.upload,
+ };
+ }, [conns, total]);
+}
+
+/** 关闭连接:单条与按当前筛选批量关闭 */
+export function useCloseConnections(apiConfig: ClashAPIConfig) {
+ const closeConn = useCallback(
+ (id: string) => {
+ connAPI.closeConnById(apiConfig, id);
+ },
+ [apiConfig],
+ );
+
+ const closeConns = useCallback(
+ (conns: FormattedConn[]) =>
+ Promise.allSettled(conns.map((conn) => connAPI.closeConnById(apiConfig, conn.id))),
+ [apiConfig],
+ );
+
+ return { closeConn, closeConns };
+}
+
+/** 容器宽度:列宽按它分配剩余空间 */
+export function useElementWidth<T extends HTMLElement>() {
+ const ref = useRef<T>(null);
+ const [width, setWidth] = useState(0);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ setWidth(el.clientWidth);
+ const ro = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width));
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ return [ref, width] as const;
+}
diff --git a/src/modules/connections/utils.ts b/src/modules/connections/utils.ts
index 0968911..4797d54 100644
--- a/src/modules/connections/utils.ts
+++ b/src/modules/connections/utils.ts
@@ -9,84 +9,161 @@ export type SourceMapItem = {
name: string;
};
+/** 单元格的渲染形态,决定 ConnectionTable 走哪个分支 */
+export type ConnectionColumnKind = 'ctrl' | 'host' | 'chip' | 'chain' | 'text';
+
export type ConnectionColumn = {
- accessor: string;
- Header?: string;
- show?: boolean;
- sortDescFirst?: boolean;
+ id: string;
+ /** i18n key */
+ labelKey: string;
+ /** 最小宽度(px);grow 为空时即固定宽度 */
+ width: number;
+ /** >0 时参与剩余空间按比例分配 */
+ grow?: number;
+ /** grow 分配的上限(px)。到顶后余量让给别的列,都到顶就留白,避免宽屏下把几列撑得过分 */
+ max?: number;
+ align?: 'left' | 'right';
+ kind: ConnectionColumnKind;
+ sortable?: boolean;
+ /** 数值列按数字比较,其余按 localeCompare */
+ numeric?: boolean;
};
export const ALL_SOURCE_IP = 'ALL_SOURCE_IP';
export const SOURCE_MAP_STORAGE_KEY = 'sourceMap';
-export const CONNECTIONS_PADDING_BOTTOM = 30;
-export const HIDDEN_COLUMNS_STORAGE_KEY = 'hiddenColumns';
-export const COLUMNS_STORAGE_KEY = 'columns';
+export const COLUMNS_STORAGE_KEY = 'connColumns';
+export const SETTINGS_STORAGE_KEY = 'connSettings';
+export const SORT_STORAGE_KEY = 'connSort';
+
+/** 全部可用列,同时也是「可用列」面板里的展示顺序 */
+export const CONNECTION_COLUMNS: ConnectionColumn[] = [
+ { id: 'ctrl', labelKey: 'c_ctrl', width: 34, kind: 'ctrl', sortable: false },
+ { id: 'start', labelKey: 'c_time', width: 84, kind: 'text', numeric: true },
+ { id: 'type', labelKey: 'c_type', width: 120, kind: 'chip' },
+ { id: 'source', labelKey: 'c_source', width: 120, kind: 'text' },
+ { id: 'host', labelKey: 'c_host', width: 100, grow: 1.6, max: 380, kind: 'host' },
+ { id: 'rule', labelKey: 'c_rule', width: 70, grow: 1, max: 220, kind: 'chip' },
+ { id: 'chains', labelKey: 'c_chains', width: 140, grow: 1.15, max: 280, kind: 'chain' },
+ {
+ id: 'downloadSpeedCurr',
+ labelKey: 'c_dl_speed',
+ width: 80,
+ align: 'right',
+ kind: 'text',
+ numeric: true,
+ },
+ {
+ id: 'uploadSpeedCurr',
+ labelKey: 'c_ul_speed',
+ width: 80,
+ align: 'right',
+ kind: 'text',
+ numeric: true,
+ },
+ { id: 'download', labelKey: 'c_dl', width: 74, align: 'right', kind: 'text', numeric: true },
+ { id: 'upload', labelKey: 'c_ul', width: 74, align: 'right', kind: 'text', numeric: true },
+ { id: 'process', labelKey: 'c_process', width: 110, kind: 'text' },
+ { id: 'chainNode', labelKey: 'c_node', width: 110, kind: 'text' },
+ {
+ id: 'sourcePort',
+ labelKey: 'c_source_port',
+ width: 72,
+ align: 'right',
+ kind: 'text',
+ numeric: true,
+ },
+ { id: 'destinationIP', labelKey: 'c_destination_ip', width: 130, kind: 'text' },
+ { id: 'network', labelKey: 'c_network', width: 70, kind: 'text' },
+ { id: 'sniffHost', labelKey: 'c_sni', width: 130, kind: 'text' },
+ { id: 'outboundType', labelKey: 'c_outbound_type', width: 84, kind: 'text' },
+];
-const sortDescFirst = true;
+export const CONNECTION_COLUMN_MAP: Record<string, ConnectionColumn> = Object.fromEntries(
+ CONNECTION_COLUMNS.map((column) => [column.id, column]),
+);
-export const HIDDEN_COLUMNS_DEFAULT = ['id'];
-export const CONNECTION_COLUMNS_DEFAULT: ConnectionColumn[] = [
- { accessor: 'id', show: false },
- { Header: 'c_type', accessor: 'type' },
- { Header: 'c_process', accessor: 'process' },
- { Header: 'c_host', accessor: 'host' },
- { Header: 'c_rule', accessor: 'rule' },
- { Header: 'c_chains', accessor: 'chains' },
- { Header: 'c_time', accessor: 'start' },
- { Header: 'c_dl_speed', accessor: 'downloadSpeedCurr', sortDescFirst },
- { Header: 'c_ul_speed', accessor: 'uploadSpeedCurr', sortDescFirst },
- { Header: 'c_dl', accessor: 'download', sortDescFirst },
- { Header: 'c_ul', accessor: 'upload', sortDescFirst },
- { Header: 'c_source', accessor: 'source' },
- { Header: 'c_destination_ip', accessor: 'destinationIP' },
- { Header: 'c_sni', accessor: 'sniffHost' },
- { Header: 'c_ctrl', accessor: 'ctrl' },
+export const CONNECTION_COLUMNS_DEFAULT: string[] = [
+ 'ctrl',
+ 'start',
+ 'type',
+ 'source',
+ 'host',
+ 'rule',
+ 'chains',
+ 'downloadSpeedCurr',
+ 'uploadSpeedCurr',
+ 'download',
+ 'upload',
];
+export type SortDir = 'asc' | 'desc';
+export type SortState = { key: string; dir: SortDir };
+/** 默认按连接时长升序,也就是最新建立的连接排在最上面 */
+export const SORT_DEFAULT: SortState = { key: 'start', dir: 'asc' };
+
+export type ConnectionSettings = {
+ /** 匹配规则 / 代理链的正则,命中的连接不显示 */
+ hideRegex: string;
+ hideEnabled: boolean;
+ /** 代理链显示每一跳而非只显示末端节点 */
+ fullChain: boolean;
+};
+
+export const CONNECTION_SETTINGS_DEFAULT: ConnectionSettings = {
+ hideRegex: 'DIRECT|dns-out',
+ hideEnabled: false,
+ fullChain: false,
+};
+
+function readJSON<T>(key: string): T | null {
+ const raw = localStorage.getItem(key);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+}
+
export function getInitialSourceMap(): SourceMapItem[] {
- const sourceMap = localStorage.getItem(SOURCE_MAP_STORAGE_KEY);
- return sourceMap ? JSON.parse(sourceMap) : [];
+ return readJSON<SourceMapItem[]>(SOURCE_MAP_STORAGE_KEY) ?? [];
}
export function saveSourceMap(sourceMap: SourceMapItem[]) {
localStorage.setItem(SOURCE_MAP_STORAGE_KEY, JSON.stringify(sourceMap));
}
-export function getInitialHiddenColumns(): string[] {
- const hiddenColumns = localStorage.getItem(HIDDEN_COLUMNS_STORAGE_KEY);
- return hiddenColumns ? JSON.parse(hiddenColumns) : [...HIDDEN_COLUMNS_DEFAULT];
+export function getInitialColumns(): string[] {
+ const saved = readJSON<string[]>(COLUMNS_STORAGE_KEY);
+ if (!Array.isArray(saved)) return [...CONNECTION_COLUMNS_DEFAULT];
+ // 存量数据里可能有已经不存在的列 id,过滤掉;全空则退回默认
+ const valid = saved.filter((id) => CONNECTION_COLUMN_MAP[id]);
+ return valid.length > 0 ? valid : [...CONNECTION_COLUMNS_DEFAULT];
}
-export function saveHiddenColumns(hiddenColumns: string[]) {
- localStorage.setItem(HIDDEN_COLUMNS_STORAGE_KEY, JSON.stringify(hiddenColumns));
+export function saveColumns(columns: string[]) {
+ localStorage.setItem(COLUMNS_STORAGE_KEY, JSON.stringify(columns));
}
-export function getInitialColumns(): ConnectionColumn[] {
- const savedColumns = localStorage.getItem(COLUMNS_STORAGE_KEY);
- const columnOrder: ConnectionColumn[] | null = savedColumns ? JSON.parse(savedColumns) : null;
-
- if (!columnOrder) {
- return [...CONNECTION_COLUMNS_DEFAULT];
- }
-
- return [...CONNECTION_COLUMNS_DEFAULT].sort((prev, next) => {
- const prevIdx = columnOrder.findIndex((column) => column.accessor === prev.accessor);
- const nextIdx = columnOrder.findIndex((column) => column.accessor === next.accessor);
-
- if (prevIdx === -1) {
- return 1;
- }
+export function getInitialSettings(): ConnectionSettings {
+ return {
+ ...CONNECTION_SETTINGS_DEFAULT,
+ ...(readJSON<ConnectionSettings>(SETTINGS_STORAGE_KEY) ?? {}),
+ };
+}
- if (nextIdx === -1) {
- return -1;
- }
+export function saveSettings(settings: ConnectionSettings) {
+ localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
+}
- return prevIdx - nextIdx;
- });
+export function getInitialSort(): SortState {
+ const saved = readJSON<SortState>(SORT_STORAGE_KEY);
+ if (!saved || !CONNECTION_COLUMN_MAP[saved.key]) return { ...SORT_DEFAULT };
+ return { key: saved.key, dir: saved.dir === 'desc' ? 'desc' : 'asc' };
}
-export function saveColumns(columns: ConnectionColumn[]) {
- localStorage.setItem(COLUMNS_STORAGE_KEY, JSON.stringify(columns));
+export function saveSort(sort: SortState) {
+ localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(sort));
}
export function arrayToIdKv<T extends { id: string }>(items: T[]) {
@@ -98,18 +175,39 @@ export function arrayToIdKv<T extends { id: string }>(items: T[]) {
return result;
}
-function hasSubstring(value: string, pattern: string) {
- return value.toLowerCase().includes(pattern.toLowerCase());
+function hasSubstring(value: string | undefined, pattern: string) {
+ return (value ?? '').toLowerCase().includes(pattern.toLowerCase());
}
-function filterConnIps(conns: FormattedConn[], ipStr: string) {
- return conns.filter((each) => each.sourceIP === ipStr);
+/**
+ * 「隐藏连接」用的正则。用户输入随时可能是半截的非法正则,编译失败时返回 null
+ * 表示不过滤,而不是把整张表清空。
+ */
+export function buildHideRegExp(settings: ConnectionSettings): RegExp | null {
+ if (!settings.hideEnabled) return null;
+ const pattern = settings.hideRegex.trim();
+ if (!pattern) return null;
+ try {
+ return new RegExp(pattern, 'i');
+ } catch {
+ return null;
+ }
}
-export function filterConns(conns: FormattedConn[], keyword: string, sourceIp: string) {
+export function filterConns(
+ conns: FormattedConn[],
+ keyword: string,
+ sourceIp: string,
+ hideRegExp: RegExp | null,
+) {
let result = conns;
+
+ if (hideRegExp) {
+ result = result.filter((conn) => !(hideRegExp.test(conn.chains) || hideRegExp.test(conn.rule)));
+ }
+
if (keyword !== '') {
- result = conns.filter((conn) =>
+ result = result.filter((conn) =>
[
conn.host,
conn.sourceIP,
@@ -120,16 +218,30 @@ export function filterConns(conns: FormattedConn[], keyword: string, sourceIp: s
conn.type,
conn.network,
conn.process,
- ].some((field) => hasSubstring(field, keyword))
+ ].some((field) => hasSubstring(field, keyword)),
);
}
+
if (sourceIp !== ALL_SOURCE_IP) {
- result = filterConnIps(result, sourceIp);
+ result = result.filter((conn) => conn.sourceIP === sourceIp);
}
return result;
}
+export function sortConns(conns: FormattedConn[], sort: SortState): FormattedConn[] {
+ const column = CONNECTION_COLUMN_MAP[sort.key];
+ if (!column || column.sortable === false) return conns;
+
+ const factor = sort.dir === 'desc' ? -1 : 1;
+ return [...conns].sort((a, b) => {
+ const x = (a as any)[sort.key];
+ const y = (b as any)[sort.key];
+ if (column.numeric) return (Number(x) - Number(y)) * factor;
+ return String(x ?? '').localeCompare(String(y ?? '')) * factor;
+ });
+}
+
// getNameFromSource runs per connection per second; compile each pattern once.
// No `g` flag: a cached RegExp with `g` would make `.test` stateful (lastIndex).
const sourceRegExpCache = new Map<string, RegExp>();
@@ -145,7 +257,7 @@ function getSourceRegExp(reg: string): RegExp {
export function getNameFromSource(
source: string,
sourceMap: SourceMapItem[],
- defaultVal?: string
+ defaultVal?: string,
): string {
let sourceName = defaultVal ?? source;
@@ -197,11 +309,23 @@ export function modifyChains(chains: string[]): string {
return `${chains[chains.length - 1]} -> ${chains[0]}`;
}
+/** chains 数组是从末端节点往外层策略组排的,展示时反过来读更顺 */
+export function formatFullChain(chains: string[]): string {
+ if (!Array.isArray(chains) || chains.length === 0) return '';
+ return [...chains].reverse().join(' → ');
+}
+
+function getOutboundType(node: string): string {
+ if (node === 'DIRECT') return 'Direct';
+ if (node.startsWith('REJECT') || node === 'PASS') return 'Reject';
+ return 'Proxy';
+}
+
export function formatConnectionDataItem(
item: ConnectionItem,
prevKv: Record<string, FormattedConn>,
now: number,
- sourceMap: SourceMapItem[]
+ sourceMap: SourceMapItem[],
): FormattedConn {
const { id, upload, download, start, chains, rule, rulePayload, metadata } = item;
const prev = prevKv[id];
@@ -232,6 +356,9 @@ export function formatConnectionDataItem(
const host2 = host || destinationIP;
const source = `${sourceIP}:${sourcePort}`;
const startTime = new Date(start).valueOf();
+ const chainList = Array.isArray(chains) ? chains : [];
+ const chainNode = chainList[0] ?? '';
+ const chainGroup = chainList.length > 1 ? chainList[chainList.length - 1] : '';
return {
id,
@@ -239,7 +366,11 @@ export function formatConnectionDataItem(
download,
start: now - startTime,
startTime,
- chains: modifyChains(chains),
+ chains: modifyChains(chainList),
+ chainNode,
+ chainGroup,
+ chainsFull: formatFullChain(chainList),
+ outboundType: getOutboundType(chainNode),
rule: !rulePayload ? rule : `${rule} :: ${rulePayload}`,
...metadata,
host: `${host2}:${destinationPort}`,
@@ -251,4 +382,4 @@ export function formatConnectionDataItem(
process: process || '-',
destinationIP: remoteDestination || destinationIP || host,
};
-} \ No newline at end of file
+}
diff --git a/src/modules/home/hooks.ts b/src/modules/home/hooks.ts
index 5c22bc6..26c4133 100644
--- a/src/modules/home/hooks.ts
+++ b/src/modules/home/hooks.ts
@@ -1,30 +1,112 @@
+import { useQuery } from '@tanstack/react-query';
import * as React from 'react';
import * as connAPI from '~/api/connections';
-import prettyBytes from '~/misc/pretty-bytes';
+import type { ConnectionsData } from '~/api/connections';
+import { fetchData as fetchMemory } from '~/api/memory';
+import { fetchRules } from '~/api/rules';
+import { fetchData as fetchTraffic } from '~/api/traffic';
import { ClashAPIConfig } from '~/types';
-const { useCallback, useEffect, useState } = React;
+const { useCallback, useEffect, useRef, useState } = React;
-export function useConnectionSummary(apiConfig: ClashAPIConfig) {
- const [state, setState] = useState({
- upTotal: '0 B',
- dlTotal: '0 B',
- connNumber: 0,
- mUsage: '0 B',
- });
+/** 图表窗口长度(秒),后端每秒推送一帧 */
+export const CHART_WINDOW = 60;
- const read = useCallback(
- ({ downloadTotal, uploadTotal, connections, memory }) => {
- setState({
- upTotal: prettyBytes(uploadTotal),
- dlTotal: prettyBytes(downloadTotal),
- connNumber: connections ? connections.length : 0,
- mUsage: prettyBytes(memory),
- });
- },
- [setState]
- );
+/**
+ * 可视窗口比当前时刻滞后一个推送周期。最新的点始终落在右边缘之外,
+ * 曲线右端永远是满的,不会因为推送早到晚到而出现忽长忽短的缺口。
+ */
+const RENDER_DELAY = 1000;
+
+/** 纵轴量程每帧向目标值靠拢的比例,越小越跟手 */
+const Y_EASING = 0.08;
+
+type Range = { min: number; max: number };
+
+/**
+ * 让图表按「坐标轴窗口逐帧前进」的方式滚动。
+ *
+ * 滑动窗口里索引 i 的像素位置是固定的,所以让元素做补间只会让曲线原地起伏(果冻感)。
+ * 这里改成关掉元素动画、每帧把 x 轴推到「此刻往前 CHART_WINDOW 秒」,
+ * 数据点按各自的时间戳定位,曲线就是匀速左移。纵轴量程用指数缓动跟随
+ * getRange(),出现尖峰时是平滑缩放而不是整条曲线瞬移。
+ */
+export function useScrollingChart(chartRef: React.RefObject<any>, getRange: () => Range) {
+ const getRangeRef = useRef(getRange);
+ getRangeRef.current = getRange;
+
+ useEffect(() => {
+ let raf = 0;
+ let shown: Range | null = null;
+
+ const frame = () => {
+ raf = requestAnimationFrame(frame);
+ const chart = chartRef.current;
+ if (!chart) return;
+
+ const target = getRangeRef.current();
+ // 首帧直接落到目标量程,避免开局曲线从零“长”出来
+ shown = shown
+ ? {
+ min: shown.min + (target.min - shown.min) * Y_EASING,
+ max: shown.max + (target.max - shown.max) * Y_EASING,
+ }
+ : target;
+
+ const xMax = Date.now() - RENDER_DELAY;
+ const { x, y } = chart.options.scales;
+ x.min = xMax - CHART_WINDOW * 1000;
+ x.max = xMax;
+ y.min = shown.min;
+ y.max = shown.max;
+ chart.update('none');
+ };
+
+ raf = requestAnimationFrame(frame);
+ return () => cancelAnimationFrame(raf);
+ }, [chartRef]);
+}
+
+type ConnectionSummary = {
+ /** 自内核启动累计上传,单位 byte */
+ upTotal: number;
+ /** 自内核启动累计下载,单位 byte */
+ dlTotal: number;
+ connNumber: number;
+ tcpNumber: number;
+ udpNumber: number;
+};
+
+const initialSummary: ConnectionSummary = {
+ upTotal: 0,
+ dlTotal: 0,
+ connNumber: 0,
+ tcpNumber: 0,
+ udpNumber: 0,
+};
+
+export function useConnectionSummary(apiConfig: ClashAPIConfig): ConnectionSummary {
+ const [state, setState] = useState(initialSummary);
+
+ const read = useCallback(({ downloadTotal, uploadTotal, connections }: ConnectionsData) => {
+ let tcpNumber = 0;
+ let udpNumber = 0;
+ for (const conn of connections || []) {
+ if (conn.metadata?.network === 'udp') {
+ udpNumber += 1;
+ } else {
+ tcpNumber += 1;
+ }
+ }
+ setState({
+ upTotal: uploadTotal || 0,
+ dlTotal: downloadTotal || 0,
+ connNumber: connections ? connections.length : 0,
+ tcpNumber,
+ udpNumber,
+ });
+ }, []);
useEffect(() => {
return connAPI.fetchData(apiConfig, read, () => {
@@ -34,3 +116,56 @@ export function useConnectionSummary(apiConfig: ClashAPIConfig) {
return state;
}
+
+/** 规则条数,和规则页共用同一份缓存 */
+export function useRulesCount(apiConfig: ClashAPIConfig) {
+ const { data } = useQuery({
+ queryKey: ['/rules', apiConfig],
+ queryFn: () => fetchRules('/rules', apiConfig),
+ });
+ return data ? data.length : 0;
+}
+
+/** 流量曲线:订阅 /traffic 的共享数据源,每次推送把窗口内的数组复制出来触发渲染 */
+export function useTraffic(apiConfig: ClashAPIConfig) {
+ const traffic = fetchTraffic(apiConfig);
+ const [data, setData] = useState({
+ up: [...traffic.up],
+ down: [...traffic.down],
+ labels: [...traffic.labels],
+ });
+
+ useEffect(() => {
+ return traffic.subscribe(() => {
+ setData({
+ up: [...traffic.up],
+ down: [...traffic.down],
+ labels: [...traffic.labels],
+ });
+ });
+ }, [traffic]);
+
+ return data;
+}
+
+/** 内存曲线,同 useTraffic */
+export function useMemory(apiConfig: ClashAPIConfig) {
+ const memory = fetchMemory(apiConfig);
+ const [data, setData] = useState({
+ inuse: [...memory.inuse],
+ oslimit: [...memory.oslimit],
+ labels: [...memory.labels],
+ });
+
+ useEffect(() => {
+ return memory.subscribe(() => {
+ setData({
+ inuse: [...memory.inuse],
+ oslimit: [...memory.oslimit],
+ labels: [...memory.labels],
+ });
+ });
+ }, [memory]);
+
+ return data;
+}
diff --git a/src/modules/home/utils.ts b/src/modules/home/utils.ts
index 177cfc9..d457c24 100644
--- a/src/modules/home/utils.ts
+++ b/src/modules/home/utils.ts
@@ -3,3 +3,83 @@ import prettyBytes from '~/misc/pretty-bytes';
export function formatTrafficRate(value: number) {
return `${prettyBytes(value || 0)}/s`;
}
+
+type ValueWithUnit = { value: string; unit: string };
+
+/** 把 "17.6 GB" 拆成数值和单位,方便大号数字 + 小号单位的排版 */
+export function splitBytes(value: number): ValueWithUnit {
+ const [num, unit] = prettyBytes(value || 0).split(' ');
+ return { value: num, unit };
+}
+
+/** 同 splitBytes,但单位带 /s 后缀 */
+export function splitTrafficRate(value: number): ValueWithUnit {
+ const { value: num, unit } = splitBytes(value);
+ return { value: num, unit: `${unit}/s` };
+}
+
+/** 数量千分位,例如 1248 -> 1,248 */
+export function formatCount(value: number) {
+ return (value || 0).toLocaleString();
+}
+
+/** 把 chart.js 配色里的 rgb()/rgba() 颜色换成指定透明度 */
+export function withAlpha(color: string, alpha: number) {
+ const matched = color.match(/rgba?\(([^)]+)\)/);
+ if (!matched) return color;
+ const [r, g, b] = matched[1].split(',').map((x) => parseFloat(x));
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+}
+
+/** 自上而下渐隐的填充色,渐变对象按绘图区尺寸缓存,避免逐帧重建 */
+export function gradientFill(color: string) {
+ let cached: { top: number; bottom: number; value: CanvasGradient } | null = null;
+ return (context: any) => {
+ const { ctx, chartArea } = context.chart;
+ if (!chartArea) return withAlpha(color, 0.18);
+ const { top, bottom } = chartArea;
+ if (!cached || cached.top !== top || cached.bottom !== bottom) {
+ const value = ctx.createLinearGradient(0, top, 0, bottom);
+ value.addColorStop(0, withAlpha(color, 0.35));
+ value.addColorStop(1, withAlpha(color, 0.02));
+ cached = { top, bottom, value };
+ }
+ return cached.value;
+ };
+}
+
+/** 采样缓冲里未填充的位置是 null,内核首帧还会推一个 0,都当作没有数据 */
+const isSample = (v: number) => v > 0;
+
+/** 末尾 count 个采样点里的最大值 */
+export function peakOf(values: number[], count: number) {
+ let peak = 0;
+ for (let i = Math.max(0, values.length - count); i < values.length; i++) {
+ if (isSample(values[i]) && values[i] > peak) peak = values[i];
+ }
+ return peak;
+}
+
+/** 末尾 count 个采样点的取值区间,两端留出 padRatio 的余量 */
+export function rangeOf(values: number[], count: number, padRatio: number) {
+ let min = Infinity;
+ let max = 0;
+ for (let i = Math.max(0, values.length - count); i < values.length; i++) {
+ const v = values[i];
+ if (!isSample(v)) continue;
+ if (v < min) min = v;
+ if (v > max) max = v;
+ }
+ if (max === 0) return { min: 0, max: 1 };
+ // 数值本身很平稳时,余量取值的百分比,免得把细微噪声放大成剧烈起伏
+ const pad = Math.max((max - min) * 0.5, max * padRatio);
+ return { min: Math.max(0, min - pad), max: max + pad };
+}
+
+/** 末尾一个有效采样点 */
+export function latestOf(values: number[]) {
+ for (let i = values.length - 1; i >= 0; i--) {
+ if (isSample(values[i])) return values[i];
+ }
+ return 0;
+}
diff --git a/src/modules/logs/hooks.ts b/src/modules/logs/hooks.ts
index bbb3f7f..0620569 100644
--- a/src/modules/logs/hooks.ts
+++ b/src/modules/logs/hooks.ts
@@ -1,25 +1,33 @@
+import { useAtom, useSetAtom } from 'jotai';
import * as React from 'react';
import { fetchLogs, reconnect as reconnectLogs, stop as stopLogs } from '~/api/logs';
-import { appendLog } from '~/store/logs';
-import { DispatchFn, Log } from '~/store/types';
-import { ClashAPIConfig } from '~/types';
+import { appendLogAtom, logFilterText } from '~/store/logs';
+import { ClashAPIConfig, Log } from '~/types';
import { LOGS_SCROLL_BOTTOM_THRESHOLD } from './utils';
-const { useCallback, useEffect, useRef, useState } = React;
+const { useCallback, useEffect, useMemo, useRef, useState } = React;
type UpdateAppConfigFn = (name: string, value: unknown) => void;
+/** 搜索词是防抖过的,过滤结果缓存住,免得每条新日志进来都重扫一遍 */
+export function useFilteredLogs(logs: Log[]) {
+ const [filterText] = useAtom(logFilterText);
+ return useMemo(() => {
+ if (filterText === '') return logs;
+ const f = filterText.toLowerCase();
+ return logs.filter((log) => log.payload.toLowerCase().indexOf(f) >= 0);
+ }, [logs, filterText]);
+}
+
export function useLogsPage({
- dispatch,
logLevel,
apiConfig,
logs,
logStreamingPaused,
updateAppConfig,
}: {
- dispatch: DispatchFn;
logLevel: string;
apiConfig: ClashAPIConfig;
logs: Log[];
@@ -31,14 +39,15 @@ export function useLogsPage({
updateAppConfig('logStreamingPaused', !logStreamingPaused);
}, [apiConfig, logLevel, logStreamingPaused, updateAppConfig]);
- const appendLogInternal = useCallback((log) => dispatch(appendLog(log)), [dispatch]);
+ // useSetAtom 返回的 setter 引用稳定,可以直接当 effect 依赖
+ const appendLog = useSetAtom(appendLogAtom);
useEffect(() => {
- const unsubscribe = fetchLogs({ ...apiConfig, logLevel }, appendLogInternal);
+ const unsubscribe = fetchLogs({ ...apiConfig, logLevel }, appendLog);
return () => {
unsubscribe?.();
};
- }, [apiConfig, logLevel, appendLogInternal]);
+ }, [apiConfig, logLevel, appendLog]);
const scrollRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
@@ -49,6 +58,7 @@ export function useLogsPage({
}
}, []);
+ // 贴着底看的时候才自动跟随,用户往回翻了就别抢滚动位置
useEffect(() => {
if (isAtBottom) {
scrollToBottom();
diff --git a/src/modules/logs/utils.ts b/src/modules/logs/utils.ts
index cb0d7c6..34a716f 100644
--- a/src/modules/logs/utils.ts
+++ b/src/modules/logs/utils.ts
@@ -5,5 +5,5 @@ export const LOG_TYPES: Record<string, string> = {
error: 'error',
};
-export const LOGS_HEIGHT_RATIO = 0.8;
-export const LOGS_SCROLL_BOTTOM_THRESHOLD = 50; \ No newline at end of file
+/** 距底部多少像素以内算「贴着底」,自动跟随新日志 */
+export const LOGS_SCROLL_BOTTOM_THRESHOLD = 50;
diff --git a/src/modules/proxies/hooks.ts b/src/modules/proxies/hooks.ts
index 594855b..e9cf502 100644
--- a/src/modules/proxies/hooks.ts
+++ b/src/modules/proxies/hooks.ts
@@ -1,27 +1,357 @@
-import { useAtom } from 'jotai';
+import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
+import { useAtomValue } from 'jotai';
import * as React from 'react';
-import {
- fetchProxies,
- NonProxyTypes,
- proxyFilterText,
- requestDelayAll,
- updateProviderByName,
- updateProviders,
-} from '~/store/proxies';
-import {
- DelayMapping,
- DispatchFn,
- FormattedProxyProvider,
- ProxiesMapping,
- ProxyItem,
-} from '~/store/types';
+import * as connAPI from '~/api/connections';
+import * as proxiesAPI from '~/api/proxies';
+import i18n from '~/misc/i18n';
+import { readErrorMessage } from '~/misc/request-helper';
+import { delayPatchesAtom, proxyFilterText, setDelayPatch } from '~/store/proxies';
+import { useStoreActions } from '~/store/StateProvider';
+import { toast } from '~/store/toast';
+import { DelayMapping, FormattedProxyProvider, ProxiesMapping, ProxyItem } from '~/store/types';
import { ClashAPIConfig } from '~/types';
-import { splitItemsByLayout } from './utils';
+import {
+ formatProxyProviders,
+ matchesFilter,
+ mergeDelayMapping,
+ NonProxyTypes,
+ parseFilterSegments,
+ ProxiesAppConfig,
+ resolveChain,
+ resolveGroupTestUrl,
+ retrieveGroupNamesFrom,
+ splitItemsByLayout,
+ withTimeout,
+} from './utils';
const { useCallback, useEffect, useMemo, useRef, useState } = React;
+const PROXIES_QUERY_KEY = '/proxies';
+
+type SwitchTo = { groupName: string; itemName: string };
+
+export type ProxiesData = {
+ proxies: ProxiesMapping;
+ groupNames: string[];
+ proxyProviders: FormattedProxyProvider[];
+ /** 不属于任何提供商的节点,只有这批能单独调 /proxies/{name}/delay */
+ dangleProxyNames: string[];
+};
+
+async function fetchProxiesData(apiConfig: ClashAPIConfig): Promise<ProxiesData> {
+ const [proxiesData, providersData] = await Promise.all([
+ proxiesAPI.fetchProxies(apiConfig),
+ proxiesAPI.fetchProviderProxies(apiConfig),
+ ]);
+
+ const { providers: proxyProviders, proxies: providerProxies } = formatProxyProviders(
+ providersData.providers,
+ );
+ const proxies = { ...providerProxies, ...proxiesData.proxies };
+ // /proxies 的同名条目会盖掉 provider 那份,把 providerName 补回去
+ for (const name of Object.keys(providerProxies)) {
+ if (proxies[name]) {
+ proxies[name] = { ...proxies[name], providerName: providerProxies[name].providerName };
+ }
+ }
+
+ const [groupNames, proxyNames] = retrieveGroupNamesFrom(proxies);
+ const dangleProxyNames = proxyNames.filter((name) => !providerProxies[name]);
+
+ return { proxies, groupNames, proxyProviders, dangleProxyNames };
+}
+
+export function useProxiesQuery(apiConfig: ClashAPIConfig) {
+ return useSuspenseQuery({
+ queryKey: [PROXIES_QUERY_KEY, apiConfig],
+ queryFn: () => fetchProxiesData(apiConfig),
+ // 窗口重新聚焦时是否重拉由 staleTime 决定,30s 是旧实现手写的那个窗口
+ staleTime: 30_000,
+ });
+}
+
+/**
+ * 缓存的读写入口。这里刻意不订阅查询——ProxyGroup / ProxyProvider 只在触发动作时
+ * 需要读数据,订阅了会让它们跟着每次刷新重渲染,memo 就白做了。
+ */
+function useProxiesCache(apiConfig: ClashAPIConfig) {
+ const queryClient = useQueryClient();
+ const queryKey = useMemo(() => [PROXIES_QUERY_KEY, apiConfig], [apiConfig]);
+
+ const getData = useCallback(
+ () => queryClient.getQueryData<ProxiesData>(queryKey),
+ [queryClient, queryKey],
+ );
+ const invalidate = useCallback(
+ () => queryClient.invalidateQueries({ queryKey }),
+ [queryClient, queryKey],
+ );
+
+ return { queryClient, queryKey, getData, invalidate };
+}
+
+/**
+ * 展示用的延迟表。合并规则见 utils 的 mergeDelayMapping;这里把上一轮结果留在 ref 里,
+ * 逐项复用引用,否则批量测速时每次刷新都会让所有 Proxy 的 memo 失效。
+ */
+export function useDelayMapping(proxies: ProxiesMapping, dataUpdatedAt: number): DelayMapping {
+ const patches = useAtomValue(delayPatchesAtom);
+ const cache = useRef<{
+ proxies: ProxiesMapping;
+ patches: DelayMapping;
+ dataUpdatedAt: number;
+ result: DelayMapping;
+ } | null>(null);
+
+ const prev = cache.current;
+ if (
+ !prev ||
+ prev.proxies !== proxies ||
+ prev.patches !== patches ||
+ prev.dataUpdatedAt !== dataUpdatedAt
+ ) {
+ const result = mergeDelayMapping(prev ? prev.result : {}, proxies, patches, dataUpdatedAt);
+ const next = { proxies, patches, dataUpdatedAt, result };
+ cache.current = next;
+ return next.result;
+ }
+ return prev.result;
+}
+
+const noop = (): null => null;
+
+/** 关掉走 groupName、但没走 exceptionItemName 的那些连接 */
+async function closeGroupConns(
+ apiConfig: ClashAPIConfig,
+ groupName: string,
+ exceptionItemName: string,
+) {
+ const res = await connAPI.fetchConns(apiConfig);
+ if (!res.ok) {
+ console.log('unable to fetch all connections', res.statusText);
+ }
+ const json = await res.json();
+ const idsToClose = [];
+ for (const conn of json.connections) {
+ if (conn.chains.indexOf(groupName) > -1 && conn.chains.indexOf(exceptionItemName) < 0) {
+ idsToClose.push(conn.id);
+ }
+ }
+ await Promise.all(idsToClose.map((id) => connAPI.closeConnById(apiConfig, id).catch(noop)));
+}
+
+function closePrevConns(apiConfig: ClashAPIConfig, proxies: ProxiesMapping, switchTo: SwitchTo) {
+ const chain = resolveChain(proxies, switchTo.groupName, switchTo.itemName);
+ closeGroupConns(apiConfig, switchTo.groupName, chain[0]);
+}
+
+/** 切换节点:先乐观改缓存,失败回滚并弹出后端给的原因 */
+export function useSwitchProxy(apiConfig: ClashAPIConfig, autoCloseOldConns: boolean) {
+ const { queryClient, queryKey, getData, invalidate } = useProxiesCache(apiConfig);
+
+ const { mutate } = useMutation({
+ mutationFn: async ({ groupName, itemName }: SwitchTo) => {
+ const res = await proxiesAPI.requestToSwitchProxy(apiConfig, groupName, itemName);
+ if (!res.ok) throw new Error(await readErrorMessage(res));
+ },
+ onMutate: async ({ groupName, itemName }: SwitchTo) => {
+ await queryClient.cancelQueries({ queryKey });
+ const snapshot = getData();
+ queryClient.setQueryData<ProxiesData>(queryKey, (old) => {
+ const group = old?.proxies[groupName];
+ if (!old || !group?.now) return old;
+ return {
+ ...old,
+ proxies: { ...old.proxies, [groupName]: { ...group, now: itemName } },
+ };
+ });
+ return { snapshot };
+ },
+ onError: (err, { groupName }, ctx) => {
+ if (ctx?.snapshot) queryClient.setQueryData(queryKey, ctx.snapshot);
+ toast('error', i18n.t('switch_proxy_failed', { group: groupName, message: err.message }));
+ },
+ onSuccess: (_data, switchTo) => {
+ if (!autoCloseOldConns) return;
+ const data = getData();
+ if (data) closePrevConns(apiConfig, data.proxies, switchTo);
+ },
+ onSettled: () => invalidate(),
+ });
+
+ return useCallback(
+ (groupName: string, itemName: string) => mutate({ groupName, itemName }),
+ [mutate],
+ );
+}
+
+/**
+ * 单个节点测速。结果只落在 delay patch 里,不动查询缓存——后端的 history 会在
+ * 下一次刷新时带上同样的数字。
+ */
+export function useTestProxyLatency(apiConfig: ClashAPIConfig, appConfig: ProxiesAppConfig) {
+ const { latencyTestUrl, latencyTestTimeout, latencyTestExpectedStatus } = appConfig;
+
+ return useCallback(
+ async (name: string, providerName?: string) => {
+ setDelayPatch(name, { testing: true, error: '' });
+
+ let delayNumber: number | undefined;
+ let error = '';
+ try {
+ const res = providerName
+ ? await proxiesAPI.healthcheckProviderProxy(
+ apiConfig,
+ providerName,
+ name,
+ latencyTestUrl,
+ latencyTestTimeout,
+ latencyTestExpectedStatus,
+ )
+ : await proxiesAPI.requestDelayForProxy(
+ apiConfig,
+ name,
+ latencyTestUrl,
+ latencyTestTimeout,
+ latencyTestExpectedStatus,
+ );
+ if (!res.ok) error = res.statusText;
+ const body = await res.json().catch((): undefined => undefined);
+ delayNumber = body?.delay;
+ } catch (err) {
+ error = (err as Error).message || 'Request failed';
+ }
+
+ const number = typeof delayNumber === 'number' && delayNumber > 0 ? delayNumber : undefined;
+ setDelayPatch(name, {
+ number,
+ error: error || (number === undefined ? 'Timeout' : ''),
+ testing: false,
+ });
+ },
+ [apiConfig, latencyTestUrl, latencyTestTimeout, latencyTestExpectedStatus],
+ );
+}
+
+async function healthcheckProvider(apiConfig: ClashAPIConfig, name: string, timeout: number) {
+ await withTimeout(timeout, async (signal) => {
+ try {
+ await proxiesAPI.healthcheckProviderByName(apiConfig, name, signal);
+ } catch (err) {
+ // 客户端超时触发的 AbortError 也走这里,忽略即可
+ }
+ });
+}
+
+/**
+ * 整组测速。Meta 后端有 /group/{name}/delay,一次请求测完整组;老后端只能逐个节点测。
+ */
+export function useTestGroupLatency(
+ apiConfig: ClashAPIConfig,
+ appConfig: ProxiesAppConfig,
+): [(opts: { groupName: string; isMeta: boolean; memberNames: string[] }) => void, boolean] {
+ const { getData, invalidate } = useProxiesCache(apiConfig);
+ const testProxy = useTestProxyLatency(apiConfig, appConfig);
+ const { latencyTestTimeout, latencyTestExpectedStatus } = appConfig;
+
+ const { mutate, isPending } = useMutation({
+ mutationFn: async ({
+ groupName,
+ isMeta,
+ memberNames,
+ }: {
+ groupName: string;
+ isMeta: boolean;
+ memberNames: string[];
+ }) => {
+ if (isMeta) {
+ const group = getData()?.proxies[groupName];
+ await proxiesAPI.requestDelayForProxyGroup(
+ apiConfig,
+ groupName,
+ resolveGroupTestUrl(group, appConfig),
+ latencyTestTimeout,
+ latencyTestExpectedStatus,
+ );
+ return;
+ }
+ const dangle = getData()?.dangleProxyNames ?? [];
+ await Promise.all(
+ memberNames.filter((name) => dangle.indexOf(name) > -1).map((name) => testProxy(name)),
+ );
+ },
+ onSettled: () => invalidate(),
+ });
+
+ return [mutate, isPending];
+}
+
+/** 「全部测速」:先并发测所有独立节点,再逐个跑提供商的健康检查 */
+export function useTestAllLatency(
+ apiConfig: ClashAPIConfig,
+ appConfig: ProxiesAppConfig,
+): [() => void, boolean] {
+ const { getData, invalidate } = useProxiesCache(apiConfig);
+ const testProxy = useTestProxyLatency(apiConfig, appConfig);
+ const { providerHealthcheckTimeout } = appConfig;
+
+ const { mutate, isPending } = useMutation({
+ mutationFn: async () => {
+ const data = getData();
+ if (!data) return;
+ await Promise.all(data.dangleProxyNames.map((name) => testProxy(name)));
+ // 一个一个来,每个都设上限,免得某个慢提供商拖住整轮
+ for (const provider of data.proxyProviders) {
+ await healthcheckProvider(apiConfig, provider.name, providerHealthcheckTimeout);
+ }
+ },
+ onSettled: () => invalidate(),
+ });
+
+ return [mutate, isPending];
+}
+
+export function useHealthcheckProvider(
+ apiConfig: ClashAPIConfig,
+ timeout: number,
+): [(name: string) => void, boolean] {
+ const { invalidate } = useProxiesCache(apiConfig);
+ const { mutate, isPending } = useMutation({
+ mutationFn: (name: string) => healthcheckProvider(apiConfig, name, timeout),
+ onSettled: () => invalidate(),
+ });
+ return [mutate, isPending];
+}
+
+export function useUpdateProviderItem(
+ apiConfig: ClashAPIConfig,
+): [(name: string) => void, boolean] {
+ const { invalidate } = useProxiesCache(apiConfig);
+ const { mutate, isPending } = useMutation({
+ mutationFn: (name: string) => proxiesAPI.updateProviderByName(apiConfig, name),
+ onSettled: () => invalidate(),
+ });
+ return [mutate, isPending];
+}
+
+export function useUpdateProviderItems(
+ apiConfig: ClashAPIConfig,
+ names: string[],
+): [() => void, boolean] {
+ const { invalidate } = useProxiesCache(apiConfig);
+ const { mutate, isPending } = useMutation({
+ mutationFn: async () => {
+ for (const name of names) {
+ await proxiesAPI.updateProviderByName(apiConfig, name).catch(noop);
+ }
+ },
+ onSettled: () => invalidate(),
+ });
+ return [mutate, isPending];
+}
+
function filterAvailableProxies(list: string[], delay: DelayMapping) {
return list.filter((name) => {
const d = delay[name];
@@ -41,7 +371,7 @@ const getSortDelay = (
| {
number?: number;
},
- proxyInfo: ProxyItem
+ proxyInfo?: ProxyItem,
) => {
if (d && typeof d.number === 'number' && d.number > 0) {
return d.number;
@@ -81,42 +411,35 @@ const ProxySortingFns = {
},
};
-function filterStrArr(all: string[], searchText: string) {
- const segments = searchText
- .toLowerCase()
- .split(' ')
- .map((x) => x.trim())
- .filter((x) => !!x);
-
- if (segments.length === 0) return all;
-
- return all.filter((name) => {
- let i = 0;
- for (; i < segments.length; i++) {
- const seg = segments[i];
- if (name.toLowerCase().indexOf(seg) > -1) return true;
- }
- return false;
- });
-}
+type ProxySortBy = keyof typeof ProxySortingFns;
function filterAvailableProxiesAndSort(
all: string[],
delay: DelayMapping,
hideUnavailableProxies: boolean,
- filterText: string,
+ segments: string[],
proxySortBy: string,
- proxies?: ProxiesMapping
+ proxies?: ProxiesMapping,
) {
let filtered = [...all];
if (hideUnavailableProxies) {
filtered = filterAvailableProxies(all, delay);
}
- if (typeof filterText === 'string' && filterText !== '') {
- filtered = filterStrArr(filtered, filterText);
+ if (segments.length > 0) {
+ filtered = filtered.filter((name) => matchesFilter(name, segments));
}
- return ProxySortingFns[proxySortBy](filtered, delay, proxies);
+ // proxySortBy 来自 localStorage,可能是旧版本留下的、已经不存在的排序名
+ const sortFn = ProxySortingFns[proxySortBy as ProxySortBy] ?? ProxySortingFns.Natural;
+ return sortFn(filtered, delay, proxies);
+}
+
+const EMPTY_SEGMENTS: string[] = [];
+
+/** 搜索框当前的分词,空数组表示没有搜索 */
+export function useFilterSegments(): string[] {
+ const filterText = useAtomValue(proxyFilterText);
+ return useMemo(() => parseFilterSegments(filterText), [filterText]);
}
export function useFilteredAndSorted(
@@ -124,140 +447,103 @@ export function useFilteredAndSorted(
delay: DelayMapping,
hideUnavailableProxies: boolean,
proxySortBy: string,
- proxies?: ProxiesMapping
+ proxies?: ProxiesMapping,
+ /** 组名本身命中搜索时,组内节点就不再过滤,整组原样展示 */
+ skipTextFilter = false,
) {
- const [filterText] = useAtom(proxyFilterText);
+ const segments = useFilterSegments();
+ const effectiveSegments = skipTextFilter ? EMPTY_SEGMENTS : segments;
return useMemo(
() =>
filterAvailableProxiesAndSort(
all,
delay,
hideUnavailableProxies,
- filterText,
+ effectiveSegments,
proxySortBy,
- proxies
+ proxies,
),
- [all, delay, hideUnavailableProxies, filterText, proxySortBy, proxies]
+ [all, delay, hideUnavailableProxies, effectiveSegments, proxySortBy, proxies],
);
}
-export function useUpdateProviderItem({
- dispatch,
- apiConfig,
- name,
-}: {
- dispatch: DispatchFn;
- apiConfig: ClashAPIConfig;
- name: string;
-}) {
- return useCallback(
- () => dispatch(updateProviderByName(apiConfig, name)),
- [apiConfig, dispatch, name]
- );
+/** 代理组:组名命中,或组内有节点命中,才留在列表里 */
+export function useVisibleGroupNames(groupNames: string[], proxies: ProxiesMapping): string[] {
+ const segments = useFilterSegments();
+ return useMemo(() => {
+ if (segments.length === 0) return groupNames;
+ return groupNames.filter((name) => {
+ if (matchesFilter(name, segments)) return true;
+ const group = proxies[name] as (ProxyItem & { all?: string[] }) | undefined;
+ return (group?.all ?? []).some((n) => matchesFilter(n, segments));
+ });
+ }, [groupNames, proxies, segments]);
}
-export function useUpdateProviderItems({
- dispatch,
- apiConfig,
- names,
-}: {
- dispatch: DispatchFn;
- apiConfig: ClashAPIConfig;
- names: string[];
-}): [() => unknown, boolean] {
- const [isLoading, setIsLoading] = useState(false);
+/** 提供商:同上,名称命中或旗下有节点命中 */
+export function useVisibleProviders(providers: FormattedProxyProvider[]): FormattedProxyProvider[] {
+ const segments = useFilterSegments();
+ return useMemo(() => {
+ if (segments.length === 0) return providers;
+ return providers.filter(
+ (p) => matchesFilter(p.name, segments) || p.proxies.some((n) => matchesFilter(n, segments)),
+ );
+ }, [providers, segments]);
+}
- const action = useCallback(async () => {
- if (isLoading) {
- return;
- }
+/**
+ * 搜索时,仅因为「组内节点命中」才留下来的卡片会自动展开——否则只能看到一排
+ * 圆点,还得手动点开才能看见搜到的节点。用户手动收起时用本地 override 记下来,
+ * 不写进持久化的折叠状态;搜索词一变就重置。
+ */
+export function useFilterAwareCollapse({
+ isOpen,
+ nameMatched,
+ onToggle,
+}: {
+ isOpen: boolean;
+ nameMatched: boolean;
+ onToggle: (next: boolean) => void;
+}): [boolean, () => void] {
+ const segments = useFilterSegments();
+ const [collapseOverride, setCollapseOverride] = useState(false);
- setIsLoading(true);
- try {
- await dispatch(updateProviders(apiConfig, names));
- } catch (e) {
- // ignore
- }
- setIsLoading(false);
- }, [apiConfig, dispatch, names, isLoading]);
+ useEffect(() => {
+ setCollapseOverride(false);
+ }, [segments]);
- return [action, isLoading];
-}
+ const forceOpen = segments.length > 0 && !nameMatched && !collapseOverride;
+ const effectiveIsOpen = isOpen || forceOpen;
-export function useTestLatencyAction({
- dispatch,
- apiConfig,
-}: {
- dispatch: DispatchFn;
- apiConfig: ClashAPIConfig;
-}): [() => unknown, boolean] {
- const [isTestingLatency, setIsTestingLatency] = useState(false);
- const requestDelayAllFn = useCallback(() => {
- if (isTestingLatency) return;
+ const toggle = useCallback(() => {
+ if (forceOpen) {
+ setCollapseOverride(true);
+ return;
+ }
+ onToggle(!effectiveIsOpen);
+ }, [forceOpen, effectiveIsOpen, onToggle]);
- setIsTestingLatency(true);
- dispatch(requestDelayAll(apiConfig)).then(
- () => setIsTestingLatency(false),
- () => setIsTestingLatency(false)
- );
- }, [apiConfig, dispatch, isTestingLatency]);
- return [requestDelayAllFn, isTestingLatency];
+ return [effectiveIsOpen, toggle];
}
export function useProxiesPage({
- dispatch,
- apiConfig,
groupNames,
proxyProviders,
proxiesLayout,
}: {
- dispatch: DispatchFn;
- apiConfig: ClashAPIConfig;
groupNames: string[];
proxyProviders: FormattedProxyProvider[];
proxiesLayout: string;
}) {
- const refFetchedTimestamp = useRef<{ startAt?: number; completeAt?: number }>({});
-
- const fetchProxiesHooked = useCallback(() => {
- refFetchedTimestamp.current.startAt = Date.now();
- dispatch(fetchProxies(apiConfig)).then(() => {
- refFetchedTimestamp.current.completeAt = Date.now();
- });
- }, [apiConfig, dispatch]);
-
- useEffect(() => {
- fetchProxiesHooked();
-
- const fn = () => {
- if (
- refFetchedTimestamp.current.startAt &&
- Date.now() - refFetchedTimestamp.current.startAt > 3e4
- ) {
- fetchProxiesHooked();
- }
- };
- window.addEventListener('focus', fn, false);
- return () => window.removeEventListener('focus', fn, false);
- }, [fetchProxiesHooked]);
-
- const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
- const closeSettingsModal = useCallback(() => {
- setIsSettingsModalOpen(false);
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false);
+ const closeSettings = useCallback(() => {
+ setIsSettingsOpen(false);
}, []);
- const openSettingsModal = useCallback(() => {
- setIsSettingsModalOpen(true);
+ const toggleSettings = useCallback(() => {
+ setIsSettingsOpen((v) => !v);
}, []);
- const [activeTab, setActiveTab] = useState('proxies');
- const handleTabKeyDown = useCallback(
- (tab: string) => (e: React.KeyboardEvent) => {
- if (e.key === 'Enter' || e.key === ' ') {
- setActiveTab(tab);
- }
- },
- []
- );
+ const [activeTab, setActiveTab] = useState<'proxies' | 'providers'>('proxies');
const proxyGroups = useMemo(() => {
const formatted = groupNames.map((name, i) => ({ name, i }));
@@ -270,13 +556,41 @@ export function useProxiesPage({
}, [proxyProviders, proxiesLayout]);
return {
- isSettingsModalOpen,
- openSettingsModal,
- closeSettingsModal,
+ isSettingsOpen,
+ toggleSettings,
+ closeSettings,
activeTab,
setActiveTab,
- handleTabKeyDown,
proxyGroups,
providers,
};
}
+
+/**
+ * 「全部收起 / 全部展开」:只要当前标签页下还有展开的分组就收起全部,
+ * 全部已收起时再点则展开全部。
+ */
+export function useCollapseAll({
+ prefix,
+ names,
+ collapsibleIsOpen,
+}: {
+ prefix: string;
+ names: string[];
+ collapsibleIsOpen: Record<string, boolean>;
+}): [() => void, boolean] {
+ const {
+ app: { updateCollapsibleIsOpenBulk },
+ } = useStoreActions();
+
+ const allCollapsed = useMemo(
+ () => !names.some((name) => collapsibleIsOpen[`${prefix}:${name}`]),
+ [names, collapsibleIsOpen, prefix],
+ );
+
+ const toggleAll = useCallback(() => {
+ updateCollapsibleIsOpenBulk(prefix, names, allCollapsed);
+ }, [updateCollapsibleIsOpenBulk, prefix, names, allCollapsed]);
+
+ return [toggleAll, allCollapsed];
+}
diff --git a/src/modules/proxies/utils.ts b/src/modules/proxies/utils.ts
index 59e24a0..8c93ece 100644
--- a/src/modules/proxies/utils.ts
+++ b/src/modules/proxies/utils.ts
@@ -1,4 +1,10 @@
-import { DelayMapping, ProxiesMapping } from '~/store/types';
+import {
+ DelayMapping,
+ FormattedProxyProvider,
+ ProxiesMapping,
+ ProxyItem,
+ ProxyProvider,
+} from '~/store/types';
export const PROXY_SORT_OPTIONS = [
['Natural', 'order_natural'],
@@ -8,8 +14,59 @@ export const PROXY_SORT_OPTIONS = [
['NameDesc', 'order_name_desc'],
] as const;
-export function formatQty(qty: number) {
- return qty < 100 ? String(qty) : '99+';
+/** 搜索框分词:空格分隔,任一词命中即算命中 */
+export function parseFilterSegments(filterText: string): string[] {
+ if (typeof filterText !== 'string') return [];
+ return filterText
+ .toLowerCase()
+ .split(' ')
+ .map((x) => x.trim())
+ .filter((x) => !!x);
+}
+
+/** 无搜索词时一律视为命中 */
+export function matchesFilter(name: string, segments: string[]): boolean {
+ if (segments.length === 0) return true;
+ const lower = name.toLowerCase();
+ return segments.some((seg) => lower.indexOf(seg) > -1);
+}
+
+/**
+ * 排序设置在 UI 上拆成「维度 + 方向」两层:分段控件选维度,
+ * 再次点击同一维度切换升/降序。store 里仍然只存原来那 5 个值。
+ */
+export type ProxySortKey = 'Natural' | 'Latency' | 'Name';
+
+export function getProxySortKey(proxySortBy: string): ProxySortKey {
+ if (proxySortBy.startsWith('Latency')) return 'Latency';
+ if (proxySortBy.startsWith('Name')) return 'Name';
+ return 'Natural';
+}
+
+/** 「原始顺序」没有方向,返回 null */
+export function getProxySortDirection(proxySortBy: string): 'Asc' | 'Desc' | null {
+ if (proxySortBy.endsWith('Desc')) return 'Desc';
+ if (proxySortBy.endsWith('Asc')) return 'Asc';
+ return null;
+}
+
+/** 点击某个维度后的下一个 proxySortBy:切维度时用升序,点当前维度则反向 */
+export function nextProxySortBy(proxySortBy: string, key: ProxySortKey): string {
+ if (key === 'Natural') return 'Natural';
+ if (getProxySortKey(proxySortBy) !== key) return `${key}Asc`;
+ return getProxySortDirection(proxySortBy) === 'Asc' ? `${key}Desc` : `${key}Asc`;
+}
+
+/** 测速超时的常用档位(毫秒) */
+export const LATENCY_TIMEOUT_PRESETS = [2000, 5000, 10000];
+
+/** 订阅健康检查超时的常用档位(毫秒) */
+export const HEALTHCHECK_TIMEOUT_PRESETS = [5000, 10000, 20000];
+
+/** 把当前值并进档位里,避免旧配置里的自定义值在分段控件上「没有选中项」 */
+export function withCurrentTimeout(presets: number[], current: number): number[] {
+ if (!current || presets.includes(current)) return presets;
+ return [...presets, current].sort((a, b) => a - b);
}
export function splitItemsByLayout<T>(items: T[], layout: string) {
@@ -34,7 +91,7 @@ export function getProxyLatency(
proxies: ProxiesMapping,
delay: DelayMapping,
name: string,
- visited = new Set<string>()
+ visited = new Set<string>(),
) {
if (visited.has(name)) return undefined;
visited.add(name);
@@ -56,3 +113,173 @@ export function getProxyLatency(
return latency;
}
+
+// see all types:
+// https://github.com/Dreamacro/clash/blob/master/constant/adapters.go
+export const NonProxyTypes = [
+ 'Direct',
+ 'Fallback',
+ 'Reject',
+ 'Pass',
+ 'Selector',
+ 'URLTest',
+ 'LoadBalance',
+ 'Unknown',
+];
+
+/**
+ * 组名按它们在 GLOBAL 组里的顺序排;同时挑出「真正的节点」(非组、非内置类型),
+ * 后者是可以单独测速的那批。
+ */
+export function retrieveGroupNamesFrom(proxies: ProxiesMapping): [string[], string[]] {
+ let groupNames: string[] = [];
+ let globalAll: string[] | undefined;
+ const proxyNames: string[] = [];
+ for (const prop in proxies) {
+ const p = proxies[prop];
+ if (p.all && Array.isArray(p.all)) {
+ if (!p.hidden) {
+ groupNames.push(prop);
+ }
+ if (prop === 'GLOBAL') {
+ globalAll = Array.from(p.all);
+ }
+ } else if (NonProxyTypes.indexOf(p.type) < 0) {
+ proxyNames.push(prop);
+ }
+ }
+ if (globalAll) {
+ globalAll.push('GLOBAL');
+ groupNames = groupNames
+ .map((name): [number, string] => [globalAll.indexOf(name), name])
+ .sort((a, b) => a[0] - b[0])
+ .map(([, name]) => name);
+ }
+ return [groupNames, proxyNames];
+}
+
+/** provider 的 proxies 从对象数组摊成名字数组,节点本身并进全局 mapping 并记下出处 */
+export function formatProxyProviders(providersInput: Record<string, ProxyProvider>): {
+ providers: FormattedProxyProvider[];
+ proxies: ProxiesMapping;
+} {
+ const providers: FormattedProxyProvider[] = [];
+ const proxies: ProxiesMapping = {};
+ for (const key of Object.keys(providersInput)) {
+ const provider = providersInput[key];
+ if (provider.name === 'default' || provider.vehicleType === 'Compatible') {
+ continue;
+ }
+ const names: string[] = [];
+ for (const proxy of provider.proxies) {
+ proxies[proxy.name] = { ...proxy, providerName: provider.name };
+ names.push(proxy.name);
+ }
+ providers.push({ ...provider, proxies: names });
+ }
+ return { providers, proxies };
+}
+
+type DelayEntry = DelayMapping[string];
+
+function sameDelayEntry(a: DelayEntry, b: DelayEntry) {
+ return (
+ a.number === b.number &&
+ a.error === b.error &&
+ a.testing === b.testing &&
+ a.updatedAt === b.updatedAt
+ );
+}
+
+/**
+ * 组和内置节点(DIRECT/REJECT…)不进延迟表:延迟排序按类型把它们排在最前,
+ * 「隐藏不可用节点」也不该因为一个组自己测出 0 就把整组藏掉。判据同
+ * retrieveGroupNamesFrom 挑 proxyNames 的那条。
+ */
+function historyDelayOf(proxy: ProxyItem | undefined): number | undefined {
+ if (!proxy || proxy.all || NonProxyTypes.indexOf(proxy.type) > -1) return undefined;
+ const history = proxy.history;
+ return history?.[history.length - 1]?.delay;
+}
+
+/**
+ * 展示用的延迟表 = 查询数据里 history 的末条,叠上比这批数据更新的测速结果。
+ * 「后端数据一到就盖掉本地测速结果」是旧实现每次 fetch 都做的事,这里用时间戳表达。
+ * 逐项复用上一轮的对象,否则批量测速每 100ms 换一次表,几百个 Proxy 的 memo 全部失效。
+ */
+export function mergeDelayMapping(
+ prev: DelayMapping,
+ proxies: ProxiesMapping,
+ patches: DelayMapping,
+ dataUpdatedAt: number,
+): DelayMapping {
+ const next: DelayMapping = {};
+ const names = new Set([...Object.keys(proxies), ...Object.keys(patches)]);
+ for (const name of names) {
+ const patch = patches[name];
+ let entry: DelayEntry | undefined;
+ if (patch && (patch.updatedAt ?? 0) > dataUpdatedAt) {
+ entry = patch;
+ } else {
+ const delay = historyDelayOf(proxies[name]);
+ entry = typeof delay === 'number' ? { number: delay } : patch;
+ }
+ if (!entry) continue;
+ const prevEntry = prev[name];
+ next[name] = prevEntry && sameDelayEntry(prevEntry, entry) ? prevEntry : entry;
+ }
+ return next;
+}
+
+/** 代理页用到的那部分 app 偏好,来自 pages/ProxiesPage 的 createSelector */
+export type ProxiesAppConfig = {
+ proxySortBy: string;
+ hideUnavailableProxies: boolean;
+ autoCloseOldConns: boolean;
+ proxiesLayout: string;
+ proxyGroupByProvider: boolean;
+ latencyTestUrl: string;
+ latencyTestTimeout: number;
+ latencyTestExpectedStatus: string;
+ preferBackendLatencyTestUrl: boolean;
+ providerHealthcheckTimeout: number;
+};
+
+/** 带超时跑一段异步逻辑,signal 交给调用方传给 fetch */
+export async function withTimeout(ms: number, fn: (signal: AbortSignal) => Promise<void>) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), ms);
+ try {
+ await fn(controller.signal);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/**
+ * 后端给这个组配的测速地址:优先 testUrl,退回 extra 的第一个键(extra 按测速地址分组)。
+ * 面板设置里勾了「优先用后端的测速地址」才走这条,否则用面板自己的地址。
+ */
+export function resolveGroupTestUrl(
+ group: ProxyItem | undefined,
+ appConfig: Pick<ProxiesAppConfig, 'latencyTestUrl' | 'preferBackendLatencyTestUrl'>,
+): string {
+ if (appConfig.preferBackendLatencyTestUrl && group) {
+ if (group.testUrl) return group.testUrl;
+ const keys = group.extra ? Object.keys(group.extra) : [];
+ if (keys.length > 0) return keys[0];
+ }
+ return appConfig.latencyTestUrl;
+}
+
+/** 从 groupName 选中的节点一路往下走,得到完整的代理链 */
+export function resolveChain(proxies: ProxiesMapping, groupName: string, itemName: string) {
+ const chain = [itemName, groupName];
+ let child: ProxyItem;
+ let childKey = itemName;
+ while ((child = proxies[childKey]) && child.now) {
+ chain.unshift(child.now);
+ childKey = child.now;
+ }
+ return chain;
+}
diff --git a/src/modules/rules/hooks.ts b/src/modules/rules/hooks.ts
index e13ea65..90a95f2 100644
--- a/src/modules/rules/hooks.ts
+++ b/src/modules/rules/hooks.ts
@@ -7,16 +7,18 @@ 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';
-const { useCallback, useState } = React;
+import type { RulesTabKey } from './utils';
+
+const { useCallback, useMemo, useState } = React;
export function useUpdateRuleProviderItem(
name: string,
- apiConfig: ClashAPIConfig
-): [(ev: React.MouseEvent<HTMLButtonElement>) => unknown, boolean] {
+ apiConfig: ClashAPIConfig,
+): [() => void, boolean] {
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: refreshRuleProviderByName,
@@ -24,16 +26,11 @@ export function useUpdateRuleProviderItem(
queryClient.invalidateQueries({ queryKey: ['/providers/rules'] });
},
});
- const onClickRefreshButton = (ev: React.MouseEvent<HTMLButtonElement>) => {
- ev.preventDefault();
- mutate({ name, apiConfig });
- };
- return [onClickRefreshButton, isPending];
+ const refresh = useCallback(() => mutate({ name, apiConfig }), [mutate, name, apiConfig]);
+ return [refresh, isPending];
}
-export function useUpdateAllRuleProviderItems(
- apiConfig: ClashAPIConfig
-): [(ev: React.MouseEvent<HTMLButtonElement>) => unknown, boolean] {
+export function useUpdateAllRuleProviderItems(apiConfig: ClashAPIConfig): [() => void, boolean] {
const queryClient = useQueryClient();
const { data: provider } = useRuleProviderQuery(apiConfig);
const { mutate, isPending } = useMutation({
@@ -42,19 +39,27 @@ export function useUpdateAllRuleProviderItems(
queryClient.invalidateQueries({ queryKey: ['/providers/rules'] });
},
});
- const onClickRefreshButton = (ev: React.MouseEvent<HTMLButtonElement>) => {
- ev.preventDefault();
- mutate({ names: provider.names, apiConfig });
- };
- return [onClickRefreshButton, isPending];
+ const refreshAll = useCallback(
+ () => mutate({ names: provider.names, apiConfig }),
+ [mutate, provider.names, apiConfig],
+ );
+ return [refreshAll, isPending];
}
-export function useInvalidateQueries() {
+export function useToggleRuleDisabled(apiConfig: ClashAPIConfig) {
const queryClient = useQueryClient();
- return useCallback(() => {
- queryClient.invalidateQueries({ queryKey: ['/rules'] });
- queryClient.invalidateQueries({ queryKey: ['/providers/rules'] });
- }, [queryClient]);
+ 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 useRuleProviderQuery(apiConfig: ClashAPIConfig) {
@@ -72,41 +77,40 @@ export function useRuleAndProvider(apiConfig: ClashAPIConfig) {
const { data: provider } = useRuleProviderQuery(apiConfig);
const [filterText] = useAtom(ruleFilterText);
- if (filterText === '') {
- return { rules, provider, isFetching };
- }
- const f = filterText.toLowerCase();
- return {
- rules: rules.filter((r) => r.payload.toLowerCase().indexOf(f) >= 0),
- isFetching,
- provider: {
- byName: provider.byName,
- names: provider.names.filter((t) => t.toLowerCase().indexOf(f) >= 0),
- },
- };
+ // 规则表动辄上千条,过滤结果必须缓存住:不然每次渲染都重算一遍,
+ // 而且新数组会让下游的 memo 全部失效
+ return useMemo(() => {
+ if (filterText === '') {
+ return { rules, provider, isFetching };
+ }
+ const f = filterText.toLowerCase();
+ return {
+ rules: rules.filter((r) => r.payload.toLowerCase().indexOf(f) >= 0),
+ isFetching,
+ provider: {
+ byName: provider.byName,
+ names: provider.names.filter((t) => t.toLowerCase().indexOf(f) >= 0),
+ },
+ };
+ }, [rules, provider, filterText, isFetching]);
}
export function useRulesPage(apiConfig: ClashAPIConfig) {
const { rules, provider } = useRuleAndProvider(apiConfig);
- const [activeTab, setActiveTab] = useState('rules');
- const isRulesTab = activeTab === 'rules';
+ // 标签出不出现看提供商总数,不看过滤结果——搜索不该把整个标签搞消失
+ const { data: allProviders } = useRuleProviderQuery(apiConfig);
+ const providerCount = allProviders.names.length;
- const handleTabKeyDown = useCallback(
- (tab: string) => (e: React.KeyboardEvent) => {
- if (e.key === 'Enter' || e.key === ' ') {
- setActiveTab(tab);
- }
- },
- []
- );
+ const [activeTab, setActiveTab] = useState<RulesTabKey>('rules');
+ const effectiveTab: RulesTabKey = providerCount > 0 ? activeTab : 'rules';
return {
rules,
provider,
- activeTab,
+ providerCount,
+ activeTab: effectiveTab,
setActiveTab,
- isRulesTab,
- handleTabKeyDown,
+ isRulesTab: effectiveTab === 'rules',
};
-} \ No newline at end of file
+}
diff --git a/src/modules/rules/utils.ts b/src/modules/rules/utils.ts
index c1d1464..772a1cc 100644
--- a/src/modules/rules/utils.ts
+++ b/src/modules/rules/utils.ts
@@ -1,24 +1,22 @@
-import { ClashAPIConfig } from '~/types';
+import type { RuleProvider } from '~/api/rule-provider';
+import type { RuleItem } from '~/api/rules';
+import type { ClashAPIConfig } from '~/types';
-export type RulesListItemData = {
- rules: any[] | null;
- provider: any;
- apiConfig: ClashAPIConfig;
+export type RuleProviderIndex = {
+ byName: Record<string, RuleProvider>;
+ names: string[];
};
-export function itemKey(index: number, { rules, provider }: RulesListItemData) {
- if (!rules) {
- return provider.names[index];
- }
- return rules[index].id;
-}
+export type RulesTabKey = 'rules' | 'providers';
-export function getItemSizeFactory({ isRulesTab }: { isRulesTab: boolean }) {
- return function getItemSize() {
- return isRulesTab ? 70 : 100;
- };
-}
+export type RulesRowProps = {
+ /** 规则标签页的数据;提供商标签页下为 null */
+ rules: RuleItem[] | null;
+ provider: RuleProviderIndex;
+ apiConfig: ClashAPIConfig;
+};
-export function formatQty(qty: number) {
- return qty < 100 ? String(qty) : '99+';
-}
+// 虚拟列表要求定高。两个数字都按「内容最多的那一行」定:
+// 规则行是 序号 + 两行内容(payload 长了会折行占两行),提供商行多一行元信息
+export const RULE_ROW_HEIGHT = 88;
+export const PROVIDER_ROW_HEIGHT = 100;