diff options
Diffstat (limited to 'src/modules')
| -rw-r--r-- | src/modules/backend/hooks.ts | 100 | ||||
| -rw-r--r-- | src/modules/backend/utils.ts | 98 | ||||
| -rw-r--r-- | src/modules/config/hooks.ts | 59 | ||||
| -rw-r--r-- | src/modules/config/utils.ts | 4 | ||||
| -rw-r--r-- | src/modules/connections/hooks.ts | 208 | ||||
| -rw-r--r-- | src/modules/connections/utils.ts | 263 | ||||
| -rw-r--r-- | src/modules/home/hooks.ts | 175 | ||||
| -rw-r--r-- | src/modules/home/utils.ts | 80 | ||||
| -rw-r--r-- | src/modules/logs/hooks.ts | 28 | ||||
| -rw-r--r-- | src/modules/logs/utils.ts | 4 | ||||
| -rw-r--r-- | src/modules/proxies/hooks.ts | 606 | ||||
| -rw-r--r-- | src/modules/proxies/utils.ts | 235 | ||||
| -rw-r--r-- | src/modules/rules/hooks.ts | 100 | ||||
| -rw-r--r-- | src/modules/rules/utils.ts | 36 |
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; |
