diff options
Diffstat (limited to 'src')
214 files changed, 9642 insertions, 6519 deletions
diff --git a/src/api/configs.ts b/src/api/configs.ts index 63a88c7..73d9683 100644 --- a/src/api/configs.ts +++ b/src/api/configs.ts @@ -19,17 +19,10 @@ export async function fetchConfigs(apiConfig: ClashAPIConfig, signal?: AbortSign // { Path: string } type ClashConfigPartial = TunPartial<ClashGeneralConfig>; -function configsPatchWorkaround(o: ClashConfigPartial) { - // backward compatibility for older clash using `socket-port` - if ('socks-port' in o) { - o['socket-port'] = o['socks-port']; - } - return o; -} export async function updateConfigs(apiConfig: ClashAPIConfig, o: ClashConfigPartial) { const { url, init } = getURLAndInit(apiConfig); - const body = JSON.stringify(configsPatchWorkaround(o)); + const body = JSON.stringify(o); return await fetch(url + endpoint, { ...init, body, method: 'PATCH' }); } @@ -45,10 +38,15 @@ export async function restartCore(apiConfig: ClashAPIConfig) { return await fetch(url + restartCoreEndpoint, { ...init, body, method: 'POST' }); } -export async function upgradeCore(apiConfig: ClashAPIConfig) { +// 内核更新通道,对应 mihomo `POST /upgrade?channel=` 的取值; +// 不传则由内核按当前版本自动选择 +export type UpgradeChannel = 'release' | 'alpha'; + +export async function upgradeCore(apiConfig: ClashAPIConfig, channel?: UpgradeChannel) { const { url, init } = getURLAndInit(apiConfig); const body = '{"path": "", "payload": ""}'; - return await fetch(url + upgradeCoreEndpoint, { ...init, body, method: 'POST' }); + const query = channel ? `?channel=${channel}` : ''; + return await fetch(url + upgradeCoreEndpoint + query, { ...init, body, method: 'POST' }); } export async function upgradeGeo(apiConfig: ClashAPIConfig) { diff --git a/src/api/connections.ts b/src/api/connections.ts index 8e5c1fd..0fcf2da 100644 --- a/src/api/connections.ts +++ b/src/api/connections.ts @@ -7,11 +7,11 @@ const endpoint = '/connections'; const fetched = false; interface Subscriber { - listner: unknown; // on data received, listener will be called with data + listner: ConnectionsListener; // on data received, listener will be called with data onClose: () => void; // on stream closed, onClose will be called } -const subscribers = []; +const subscribers: Subscriber[] = []; // see also https://github.com/Dreamacro/clash/blob/dev/constant/metadata.go#L41 type UUID = string; @@ -41,11 +41,12 @@ export type ConnectionItem = { rule: string; rulePayload?: string; }; -type ConnectionsData = { +export type ConnectionsData = { downloadTotal: number; uploadTotal: number; connections: Array<ConnectionItem>; }; +type ConnectionsListener = (data: ConnectionsData) => void; function appendData(s: string) { let o: ConnectionsData; @@ -60,7 +61,6 @@ function appendData(s: string) { } }); } catch (err) { - console.log('JSON.parse error', JSON.parse(s)); } subscribers.forEach((s) => s.listner(o)); @@ -71,8 +71,8 @@ type UnsubscribeFn = () => void; let wsState: number; export function fetchData( apiConfig: ClashAPIConfig, - listener: unknown, - onClose: () => void + listener: ConnectionsListener, + onClose: () => void, ): UnsubscribeFn | void { if (fetched || wsState === 1) { if (listener) diff --git a/src/api/logs.ts b/src/api/logs.ts index 53bb8ad..66989ce 100644 --- a/src/api/logs.ts +++ b/src/api/logs.ts @@ -1,6 +1,5 @@ import { pad0 } from '~/misc/utils'; -import { Log } from '~/store/types'; -import { LogsAPIConfig } from '~/types'; +import { Log, LogsAPIConfig } from '~/types'; import { buildLogsWebSocketURL, getURLAndInit } from '../misc/request-helper'; @@ -21,8 +20,8 @@ const getRandomStr = () => { let even = false; let decoded = ''; -let ws: WebSocket; -let controller: AbortController; +let ws: WebSocket | undefined; +let controller: AbortController | undefined; let usingFetchFallback = false; let currentConnStr: string; @@ -41,7 +40,6 @@ function appendData(s: string) { try { o = JSON.parse(s); } catch (err) { - console.log('JSON.parse error', s); return; } @@ -65,7 +63,7 @@ function formatDate(d: Date) { return `${YY}-${MM}-${dd} ${HH}:${mm}:${ss}`; } -function pump(reader: ReadableStreamDefaultReader) { +function pump(reader: ReadableStreamDefaultReader): Promise<void> { return reader.read().then(({ done, value }) => { const str = textDecoder.decode(value, { stream: !done }); decoded += str; @@ -82,7 +80,6 @@ function pump(reader: ReadableStreamDefaultReader) { appendData(lastSplit); decoded = ''; - console.log('GET /logs streaming done'); usingFetchFallback = false; return; @@ -97,7 +94,7 @@ function pump(reader: ReadableStreamDefaultReader) { function makeConnStr(c: LogsAPIConfig) { const keys = Object.keys(c); keys.sort(); - return keys.map((k) => c[k]).join('|'); + return keys.map((k) => c[k as keyof LogsAPIConfig]).join('|'); } function isConnectionLive() { @@ -139,7 +136,7 @@ function openConnection(apiConfig: LogsAPIConfig) { export function fetchLogs( apiConfig: LogsAPIConfig, - appendLog: AppendLogFn + appendLog: AppendLogFn, ): UnsubscribeFn | undefined { if (apiConfig.logLevel === 'uninit') return undefined; @@ -179,14 +176,16 @@ function fetchLogsWithFetch(apiConfig: LogsAPIConfig) { signal, }).then( (response) => { - const reader = response.body.getReader(); - pump(reader); + if (!response.body) { + usingFetchFallback = false; + return; + } + pump(response.body.getReader()); }, (err) => { usingFetchFallback = false; if (signal.aborted) return; - console.log('GET /logs error:', err.message); }, ); diff --git a/src/api/memory.ts b/src/api/memory.ts index 8b0c738..6f4008a 100644 --- a/src/api/memory.ts +++ b/src/api/memory.ts @@ -15,7 +15,7 @@ const memory = { oslimit: Array(Size).fill(null), size: Size, - subscribers: [], + subscribers: [] as Array<(o: { inuse: number; oslimit: number }) => void>, appendData(o: { inuse: number; oslimit: number }) { this.inuse.shift(); this.oslimit.shift(); @@ -45,7 +45,7 @@ function parseAndAppend(x: string) { memory.appendData(JSON.parse(x)); } -function pump(reader: ReadableStreamDefaultReader) { +function pump(reader: ReadableStreamDefaultReader): Promise<void> { return reader.read().then(({ done, value }) => { const str = textDecoder.decode(value, { stream: !done }); decoded += str; @@ -62,7 +62,6 @@ function pump(reader: ReadableStreamDefaultReader) { parseAndAppend(lastSplit); decoded = ''; - console.log('GET /memory streaming done'); fetched = false; return; @@ -102,7 +101,7 @@ function fetchDataWithFetch(apiConfig: ClashAPIConfig) { const { url, init } = getURLAndInit(apiConfig); fetch(url + endpoint, init).then( (response) => { - if (response.ok) { + if (response.ok && response.body) { const reader = response.body.getReader(); pump(reader); } else { @@ -110,10 +109,9 @@ function fetchDataWithFetch(apiConfig: ClashAPIConfig) { } }, (err) => { - console.log('fetch /memory error', err); fetched = false; - } + }, ); return memory; } diff --git a/src/api/proxies.ts b/src/api/proxies.ts index ca4bfd4..01a7c96 100644 --- a/src/api/proxies.ts +++ b/src/api/proxies.ts @@ -24,16 +24,20 @@ $ curl "http://127.0.0.1:8080/proxies/GLOBAL" -XPUT -d '{ "name": "Proxy" }' -i HTTP/1.1 204 No Content */ -export async function fetchProxies(config) { +export async function fetchProxies(config: ClashAPIConfig) { const { url, init } = getURLAndInit(config); const res = await fetch(url + endpoint, init); return await res.json(); } -export async function requestToSwitchProxy(apiConfig, name1, name2) { +export async function requestToSwitchProxy( + apiConfig: ClashAPIConfig, + name1: string, + name2: string, +) { const body = { name: name2 }; const { url, init } = getURLAndInit(apiConfig); - const fullURL = `${url}${endpoint}/${name1}`; + const fullURL = `${url}${endpoint}/${encodeURIComponent(name1)}`; return await fetch(fullURL, { ...init, method: 'PUT', @@ -42,11 +46,11 @@ export async function requestToSwitchProxy(apiConfig, name1, name2) { } export async function requestDelayForProxy( - apiConfig, - name, + apiConfig: ClashAPIConfig, + name: string, latencyTestUrl = DEFAULT_LATENCY_TEST_URL, timeout = 5000, - expected?: string + expected?: string, ) { const { url, init } = getURLAndInit(apiConfig); const qs = buildDelayQuery(latencyTestUrl, timeout, expected); @@ -55,11 +59,11 @@ export async function requestDelayForProxy( } export async function requestDelayForProxyGroup( - apiConfig, - name, + apiConfig: ClashAPIConfig, + name: string, latencyTestUrl = DEFAULT_LATENCY_TEST_URL, timeout = 5000, - expected?: string + expected?: string, ) { const { url, init } = getURLAndInit(apiConfig); const qs = buildDelayQuery(latencyTestUrl, timeout, expected); @@ -67,7 +71,7 @@ export async function requestDelayForProxyGroup( return await fetch(fullUrl, init); } -export async function fetchProviderProxies(config) { +export async function fetchProviderProxies(config: ClashAPIConfig) { const { url, init } = getURLAndInit(config); const res = await fetch(url + '/providers/proxies', init); if (res.status === 404) { @@ -76,18 +80,22 @@ export async function fetchProviderProxies(config) { return await res.json(); } -export async function updateProviderByName(config, name) { +export async function updateProviderByName(config: ClashAPIConfig, name: string) { const { url, init } = getURLAndInit(config); const options = { ...init, method: 'PUT' }; return await fetch(url + '/providers/proxies/' + encodeURIComponent(name), options); } -export async function healthcheckProviderByName(config, name, signal?: AbortSignal) { +export async function healthcheckProviderByName( + config: ClashAPIConfig, + name: string, + signal?: AbortSignal, +) { const { url, init } = getURLAndInit(config); const options = { ...init, method: 'GET', signal }; return await fetch( url + '/providers/proxies/' + encodeURIComponent(name) + '/healthcheck', - options + options, ); } @@ -97,15 +105,15 @@ export async function healthcheckProviderProxy( proxyName: string, latencyTestUrl = DEFAULT_LATENCY_TEST_URL, timeout = 5000, - expected?: string + expected?: string, ) { const { url, init } = getURLAndInit(config); const qs = buildDelayQuery(latencyTestUrl, timeout, expected); const options = { ...init, method: 'GET' }; return await fetch( `${url}/providers/proxies/${encodeURIComponent(providerName)}/${encodeURIComponent( - proxyName + proxyName, )}/healthcheck?${qs}`, - options + options, ); } diff --git a/src/api/rule-provider.ts b/src/api/rule-provider.ts index 43497c8..6ca45a6 100644 --- a/src/api/rule-provider.ts +++ b/src/api/rule-provider.ts @@ -42,7 +42,7 @@ export async function fetchRuleProviders(endpoint: string, apiConfig: ClashAPICo } } catch (err) { // log and ignore - + console.log('failed to GET /providers/rules', err); } return normalizeAPIResponse(data); @@ -57,14 +57,14 @@ export async function refreshRuleProviderByName({ }) { const { url, init } = getURLAndInit(apiConfig); try { - const res = await fetch(url + `/providers/rules/${name}`, { + const res = await fetch(url + `/providers/rules/${encodeURIComponent(name)}`, { method: 'PUT', ...init, }); return res.ok; } catch (err) { // log and ignore - + console.log('failed to PUT /providers/rules/:name', err); return false; } diff --git a/src/api/rules.ts b/src/api/rules.ts index 5537c31..9007041 100644 --- a/src/api/rules.ts +++ b/src/api/rules.ts @@ -5,27 +5,40 @@ import { ClashAPIConfig } from '~/types'; // const endpoint = '/rules'; -type RuleItem = RuleAPIItem & { id: number }; +export type RuleExtra = { + disabled: boolean; + hitCount: number; + hitAt: string; + missCount: number; + missAt: string; +}; + +export type RuleItem = RuleAPIItem & { id: number }; -type RuleAPIItem = { +export type RuleAPIItem = { + index?: number; type: string; payload: string; proxy: string; size: number; + extra?: RuleExtra; }; function normalizeAPIResponse(json: { rules: Array<RuleAPIItem> }): Array<RuleItem> { invariant( json.rules && json.rules.length >= 0, - 'there is no valid rules list in the rules API response' + 'there is no valid rules list in the rules API response', ); - // attach an id - return json.rules.map((r: RuleAPIItem, i: number) => ({ ...r, id: i })); + // attach an id, preferring the backend-provided index over array position + return json.rules.map((r: RuleAPIItem, i: number) => ({ + ...r, + id: typeof r.index === 'number' ? r.index : i, + })); } export async function fetchRules(endpoint: string, apiConfig: ClashAPIConfig) { - let json = { rules: [] }; + let json: { rules: Array<RuleAPIItem> } = { rules: [] }; try { const { url, init } = getURLAndInit(apiConfig); const res = await fetch(url + endpoint, init); @@ -34,8 +47,28 @@ export async function fetchRules(endpoint: string, apiConfig: ClashAPIConfig) { } } catch (err) { // log and ignore - + console.log('failed to fetch rules', err); } return normalizeAPIResponse(json); } + +export async function updateRuleDisabledStatus( + apiConfig: ClashAPIConfig, + updates: Record<number, boolean>, +) { + const { url, init } = getURLAndInit(apiConfig); + try { + const res = await fetch(url + '/rules/disable', { + method: 'PATCH', + ...init, + body: JSON.stringify(updates), + }); + return res.ok; + } catch (err) { + // log and ignore + + console.log('failed to PATCH /rules/disable', err); + return false; + } +} diff --git a/src/api/traffic.ts b/src/api/traffic.ts index a01940d..0a6bbd6 100644 --- a/src/api/traffic.ts +++ b/src/api/traffic.ts @@ -15,7 +15,7 @@ const traffic = { down: Array(Size).fill(null), size: Size, - subscribers: [], + subscribers: [] as Array<(o: { up: number; down: number }) => void>, appendData(o: { up: number; down: number }) { this.up.shift(); this.down.shift(); @@ -45,7 +45,7 @@ function parseAndAppend(x: string) { traffic.appendData(JSON.parse(x)); } -function pump(reader: ReadableStreamDefaultReader) { +function pump(reader: ReadableStreamDefaultReader): Promise<void> { return reader.read().then(({ done, value }) => { const str = textDecoder.decode(value, { stream: !done }); decoded += str; @@ -62,7 +62,6 @@ function pump(reader: ReadableStreamDefaultReader) { parseAndAppend(lastSplit); decoded = ''; - console.log('GET /traffic streaming done'); fetched = false; return; @@ -102,7 +101,7 @@ function fetchDataWithFetch(apiConfig: ClashAPIConfig) { const { url, init } = getURLAndInit(apiConfig); fetch(url + endpoint, init).then( (response) => { - if (response.ok) { + if (response.ok && response.body) { const reader = response.body.getReader(); pump(reader); } else { @@ -110,10 +109,9 @@ function fetchDataWithFetch(apiConfig: ClashAPIConfig) { } }, (err) => { - console.log('fetch /traffic error', err); fetched = false; - } + }, ); return traffic; } diff --git a/src/api/version.ts b/src/api/version.ts index aab2d22..c20aedd 100644 --- a/src/api/version.ts +++ b/src/api/version.ts @@ -9,7 +9,7 @@ type VersionData = { export async function fetchVersion( endpoint: string, - apiConfig: ClashAPIConfig + apiConfig: ClashAPIConfig, ): Promise<VersionData> { let json = {}; try { @@ -20,7 +20,7 @@ export async function fetchVersion( } } catch (err) { // log and ignore - + console.log(`failed to fetch ${endpoint}`, err); } return json; diff --git a/src/app/APIDiscovery.tsx b/src/app/APIDiscoveryContainer.tsx index e1d3bce..a8af75a 100644 --- a/src/app/APIDiscovery.tsx +++ b/src/app/APIDiscoveryContainer.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; import APIDiscovery from '~/components/backend/APIDiscovery'; -import { connect } from '~/components/StateProvider'; import { useBackendDiscovery } from '~/modules/backend/hooks'; import BackendPage from '~/pages/BackendPage'; import { getClashAPIConfig } from '~/store/app'; +import { connect } from '~/store/StateProvider'; import type { DispatchFn, State } from '~/store/types'; import type { ClashAPIConfig } from '~/types'; diff --git a/src/components/ErrorBoundary.tsx b/src/app/ErrorBoundary.tsx index ed395d1..c9a547d 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/app/ErrorBoundary.tsx @@ -14,7 +14,7 @@ type State = { }; class ErrorBoundary extends React.Component<Props, State> { - state = { error: null }; + state: State = { error: undefined }; static getDerivedStateFromError(error: Err) { return { error }; diff --git a/src/components/ErrorBoundaryFallback.module.scss b/src/app/ErrorBoundaryFallback.module.scss index d2adadb..d2adadb 100644 --- a/src/components/ErrorBoundaryFallback.module.scss +++ b/src/app/ErrorBoundaryFallback.module.scss diff --git a/src/components/ErrorBoundaryFallback.tsx b/src/app/ErrorBoundaryFallback.tsx index 9f41d91..ee6ca0c 100644 --- a/src/components/ErrorBoundaryFallback.tsx +++ b/src/app/ErrorBoundaryFallback.tsx @@ -1,8 +1,10 @@ import React from 'react'; +import SvgGithub from '~/components/shared/SvgGithub'; +import SvgYacd from '~/components/shared/SvgYacd'; + import s0 from './ErrorBoundaryFallback.module.scss'; -import SvgGithub from './SvgGithub'; -import SvgYacd from './SvgYacd'; + const yacdRepoIssueUrl = 'https://github.com/metacubex/yacd'; type Props = { diff --git a/src/components/SideBar.module.scss b/src/app/SideBar.module.scss index 74f7087..e3eeb41 100644 --- a/src/components/SideBar.module.scss +++ b/src/app/SideBar.module.scss @@ -1,7 +1,11 @@ @use '~/styles/utils/custom-media' as *; .root { - background: var(--color-bg-sidebar); + // 半透明外壳色而不是纯白卡片色:纯白会和卡片撞成同一层,看不出主次 + background: var(--color-chrome); + backdrop-filter: saturate(180%) blur(24px); + // 与内容区之间的竖向分隔线,用低透明度黑而不是实色,叠在底色上更收 + border-right: 1px solid var(--color-chrome-border); width: 180px; min-width: 180px; flex-shrink: 0; @@ -11,6 +15,9 @@ width: 100%; min-width: 0; background: var(--color-background); + // 移动端是顶部悬浮胶囊布局,底色不透明,竖线和毛玻璃都没有意义 + backdrop-filter: none; + border-right: none; position: fixed; top: 0; left: 0; @@ -53,7 +60,9 @@ background: var(--color-bg-sidebar); border-radius: 20px; padding: 6px 8px; - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.08); + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.12), + 0 1px 4px rgba(0, 0, 0, 0.08); border: 1px solid var(--color-separator); } } @@ -69,7 +78,10 @@ margin: 4px 8px; border-radius: 8px; box-shadow: inset 0 0 0 1px transparent; - transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease; + transition: + background-color 0.18s ease, + color 0.18s ease, + transform 0.18s ease; &:hover { background-color: rgba(176, 206, 255, 0.221); diff --git a/src/components/SideBar.tsx b/src/app/SideBar.tsx index 15db264..c9f1d7c 100644 --- a/src/components/SideBar.tsx +++ b/src/app/SideBar.tsx @@ -1,16 +1,16 @@ -import { useSuspenseQuery } from '@tanstack/react-query'; import cx from 'clsx'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { FcAreaChart, FcDocument, FcGlobe, FcLink, FcRuler, FcSettings } from 'react-icons/fc'; import { Link, useLocation } from 'react-router-dom'; -import { fetchVersion } from '~/api/version'; import { Info } from '~/components/shared/FeatherIcons'; import { ThemeSwitcher } from '~/components/shared/ThemeSwitcher'; import { Tooltip } from '~/components/shared/Tooltip'; -import { connect } from '~/components/StateProvider'; +import { useVersion } from '~/hooks/useVersion'; import { getClashAPIConfig } from '~/store/app'; +import { connect } from '~/store/StateProvider'; +import type { State } from '~/store/types'; import { ClashAPIConfig } from '~/types'; import s from './SideBar.module.scss'; @@ -45,11 +45,11 @@ const SideBarRow = React.memo(function SideBarRow({ interface SideBarRowProps { isActive: boolean; to: string; - iconId?: string; - labelText?: string; + iconId: keyof typeof icons; + labelText: string; } -const pages = [ +const pages: Array<Omit<SideBarRowProps, 'isActive'>> = [ { to: '/home', iconId: 'activity', @@ -82,7 +82,7 @@ const pages = [ }, ]; -const mapState = (s) => ({ +const mapState = (s: State) => ({ apiConfig: getClashAPIConfig(s), }); @@ -92,10 +92,7 @@ function SideBar(props: Props) { const { t } = useTranslation(); const location = useLocation(); - const { data: version } = useSuspenseQuery({ - queryKey: ['/version', props.apiConfig], - queryFn: () => fetchVersion('/version', props.apiConfig), - }); + const version = useVersion(props.apiConfig); return ( <div className={s.root}> <div className={version.meta && version.premium ? s.logo_singbox : s.logo_meta} /> diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index ecb73bd..f3eca93 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -18,9 +18,8 @@ export function registerAppBootstrap(rootEl: HTMLElement | null) { rootEl.addEventListener('touchmove', onTouchMove, false); rootEl.addEventListener('touchend', onTouchEnd, false); - console.log('Checkout the repo: https://github.com/MetaCubeX/yacd'); - + console.log('Version:', __VERSION__); } @@ -59,7 +58,6 @@ function handleTouch(trace: TouchPoint[]) { const tag = window.location.hash.slice(1); const index = tags.indexOf(tag); - console.log(index, tag, tags.length); if (index === 3) return; diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 2ad83e2..d8e394d 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -2,10 +2,11 @@ import * as RadixTooltip from '@radix-ui/react-tooltip'; import { QueryClientProvider } from '@tanstack/react-query'; import * as React from 'react'; -import ErrorBoundary from '~/components/ErrorBoundary'; -import StateProvider from '~/components/StateProvider'; +import ErrorBoundary from '~/app/ErrorBoundary'; +import { Toaster } from '~/components/shared/Toast'; import { queryClient } from '~/misc/query'; import { actions, initialState } from '~/store'; +import StateProvider from '~/store/StateProvider'; type Props = { children: React.ReactNode; @@ -16,7 +17,10 @@ export function AppProviders({ children }: Props) { <ErrorBoundary> <StateProvider initialState={initialState} actions={actions}> <QueryClientProvider client={queryClient}> - <RadixTooltip.Provider delayDuration={0}>{children}</RadixTooltip.Provider> + <RadixTooltip.Provider delayDuration={0}> + {children} + <Toaster /> + </RadixTooltip.Provider> </QueryClientProvider> </StateProvider> </ErrorBoundary> diff --git a/src/app/router.tsx b/src/app/router.tsx index e7822f9..ae558d0 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,9 +1,9 @@ import { Suspense } from 'react'; import { HashRouter, Navigate, Route, RouteObject, Routes, useRoutes } from 'react-router-dom'; -import Loading from '~/components/Loading'; +import SideBar from '~/app/SideBar'; import { Head } from '~/components/shared/Head'; -import SideBar from '~/components/SideBar'; +import Loading from '~/components/shared/Loading'; import styles from '../App.module.scss'; import AboutPage from '../pages/AboutPage'; @@ -16,7 +16,7 @@ import ProxiesPage from '../pages/ProxiesPage'; import RulesPage from '../pages/RulesPage'; import StyleGuidePage from '../pages/StyleGuidePage'; -import APIDiscovery from './APIDiscovery'; +import APIDiscoveryContainer from './APIDiscoveryContainer'; const routes = [ { path: '/', element: <Navigate to="/proxies" replace /> }, @@ -33,7 +33,7 @@ const routes = [ function DashboardRouter() { return ( <> - <APIDiscovery /> + <APIDiscoveryContainer /> <SideBar /> <div className={styles.content}>{useRoutes(routes)}</div> </> diff --git a/src/components/CollapsibleSectionHeader.module.scss b/src/components/CollapsibleSectionHeader.module.scss deleted file mode 100644 index 3fd8bd3..0000000 --- a/src/components/CollapsibleSectionHeader.module.scss +++ /dev/null @@ -1,49 +0,0 @@ -.header { - display: flex; - align-items: center; - padding: 5px; - user-select: none; - - &:focus { - outline: none; - } - - .arrow { - display: inline-flex; - transform: rotate(0deg); - transition: transform 0.3s; - - &.isOpen { - transform: rotate(180deg); - } - - &:focus { - outline: var(--color-focus-blue) solid 1px; - } - } -} - -.btn { - margin-left: 5px; -} - -.lock { - display: inline-flex; - align-items: center; - margin-left: 6px; - color: var(--color-latency-medium, #d4b75c); - cursor: help; -} - -/* TODO duplicate with connQty in Connections.module.css */ -.qty { - font-family: var(--font-normal); - font-size: 0.75em; - margin-left: 3px; - padding: 2px 7px; - display: inline-flex; - justify-content: center; - align-items: center; - background-color: var(--bg-near-transparent); - border-radius: 30px; -} diff --git a/src/components/CollapsibleSectionHeader.tsx b/src/components/CollapsibleSectionHeader.tsx deleted file mode 100644 index 729f072..0000000 --- a/src/components/CollapsibleSectionHeader.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from 'react'; -import { useTranslation } from 'react-i18next'; - -import s from './CollapsibleSectionHeader.module.scss'; -import { SectionNameType } from './shared/Basic'; -import { Lock } from './shared/FeatherIcons'; - -type Props = { - name: string; - type: string; - qty?: number | string; - toggle?: () => void; - isOpen?: boolean; - // URLTest/Fallback group has a manually-fixed selection - fixed?: boolean; -}; - -export default function Header({ name, type, toggle, qty, fixed }: Props) { - const { t } = useTranslation(); - const handleKeyDown = React.useCallback( - (e: React.KeyboardEvent) => { - e.preventDefault(); - if (e.key === 'Enter' || e.key === ' ') { - toggle(); - } - }, - [toggle] - ); - return ( - <div - className={s.header} - onClick={toggle} - style={{ cursor: 'pointer' }} - tabIndex={0} - onKeyDown={handleKeyDown} - role="button" - > - <div> - <SectionNameType name={name} type={type} /> - </div> - - {fixed ? ( - <span className={s.lock} title={t('group_fixed_tip')}> - <Lock size={13} /> - </span> - ) : null} - - {typeof qty === 'number' ? <span className={s.qty}>{qty}</span> : null} - </div> - ); -} diff --git a/src/components/ContentHeader.module.scss b/src/components/ContentHeader.module.scss deleted file mode 100644 index be1697b..0000000 --- a/src/components/ContentHeader.module.scss +++ /dev/null @@ -1,34 +0,0 @@ -@use '~/styles/utils/custom-media' as *; - -.root { - height: 60px; - display: flex; - align-items: center; - padding: 0 15px; - - @media (max-width: 768px) { - min-height: 48px; - height: auto; - padding: 5px 15px; - flex-wrap: wrap; - } - - @media (--breakpoint-not-small) { - padding: 0 40px; - } -} - -.h1 { - white-space: nowrap; - font-size: 1.7em; - - @media (max-width: 768px) { - font-size: 1.4em; - } - - @media (--breakpoint-not-small) { - font-size: 2em; - } - text-align: left; - margin: 0; -} diff --git a/src/components/ContentHeader.tsx b/src/components/ContentHeader.tsx deleted file mode 100644 index 4709037..0000000 --- a/src/components/ContentHeader.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -import s0 from './ContentHeader.module.scss'; - -type Props = { - children?: React.ReactNode; -}; - -function ContentHeader({ children }: Props) { - return <div className={s0.root}>{children}</div>; -} - -export default React.memo(ContentHeader); diff --git a/src/components/Field.module.scss b/src/components/Field.module.scss deleted file mode 100644 index 72a5149..0000000 --- a/src/components/Field.module.scss +++ /dev/null @@ -1,42 +0,0 @@ -.root { - position: relative; - padding: 10px 0; - input { - -webkit-appearance: none; - background-color: transparent; - background-image: none; - border: none; - border-radius: 0; - border-bottom: 1px solid var(--color-input-border); - box-sizing: border-box; - color: inherit; - display: inline-block; - font-size: inherit; - height: 40px; - outline: none; - padding: 0 4px; - width: 100%; - &:focus { - border-color: var(--color-focus-blue); - } - } - - label { - position: absolute; - left: 5px; - bottom: 22px; - transition: transform 150ms ease-in-out; - transform-origin: 0 0; - font-size: 0.9em; - &.floatAbove { - transform: scale(0.75) translateY(-25px); - } - } - - input { - &:focus + label { - color: var(--color-focus-blue); - transform: scale(0.75) translateY(-25px); - } - } -} diff --git a/src/components/Field.tsx b/src/components/Field.tsx deleted file mode 100644 index a0d43cf..0000000 --- a/src/components/Field.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from 'react'; - -import s from './Field.module.scss'; - -const { useCallback } = React; - -type Props = { - name: string; - value?: string | number; - type?: 'text' | 'number'; - onChange?: (...args: any[]) => any; - id?: string; - label?: string; - placeholder?: string; -}; - -export default function Field({ id, label, value, onChange, ...props }: Props) { - const valueOnChange = useCallback((e) => onChange(e), [onChange]); - return ( - <div className={s.root}> - <input id={id} value={value} onChange={valueOnChange} {...props} /> - <label htmlFor={id} className={s.floatAbove}> - {label} - </label> - </div> - ); -} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx deleted file mode 100644 index 560fb6d..0000000 --- a/src/components/Icon.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import cx from 'clsx'; -import React from 'react'; - -type Props = { - id: string; - width?: number; - height?: number; - className?: string; -}; - -const Icon = ({ id, width = 20, height = 20, className, ...props }: Props) => { - const c = cx('icon', id, className); - const href = '#' + id; - return ( - <svg className={c} width={width} height={height} {...props}> - <use xlinkHref={href} /> - </svg> - ); -}; -export default React.memo(Icon); diff --git a/src/components/Input.module.scss b/src/components/Input.module.scss deleted file mode 100644 index 44a686c..0000000 --- a/src/components/Input.module.scss +++ /dev/null @@ -1,28 +0,0 @@ -.input { - -webkit-appearance: none; - background-color: var(--color-input-bg); - background-image: none; - border-radius: 8px; - border: 1px solid var(--color-input-border); - box-sizing: border-box; - color: inherit; - display: inline-block; - font-size: inherit; - height: 35px; - outline: none; - padding: 0 15px; - width: 100%; - font-size: small; - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} - -.input:focus { - border-color: var(--color-focus-blue); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); -} - -input::-webkit-outer-spin-button, -input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} diff --git a/src/components/Loading2.module.scss b/src/components/Loading2.module.scss deleted file mode 100644 index 067281e..0000000 --- a/src/components/Loading2.module.scss +++ /dev/null @@ -1,8 +0,0 @@ -.lo { - opacity: 0.5; - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; -} diff --git a/src/components/Loading2.tsx b/src/components/Loading2.tsx deleted file mode 100644 index b847eb4..0000000 --- a/src/components/Loading2.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; - -import s0 from './Loading2.module.scss'; -import SvgYacd from './SvgYacd'; - -function Loading() { - return ( - <div className={s0.lo}> - <SvgYacd width={280} height={280} animate c0="transparent" c1="#646464" /> - </div> - ); -} - -export default Loading; diff --git a/src/components/Modal.module.scss b/src/components/Modal.module.scss deleted file mode 100644 index 59b0630..0000000 --- a/src/components/Modal.module.scss +++ /dev/null @@ -1,21 +0,0 @@ -.overlay { - position: fixed; - top: 0; - right: 0; - left: 0; - bottom: 0; - background: #444; - z-index: 1024; - display: flex; - align-items: center; - justify-content: center; -} - -.content { - outline: none; - color: var(--color-text); - background: var(--bg-modal); - padding: 20px; - border-radius: var(--border-radius); - box-shadow: var(--shadow-card); -} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx deleted file mode 100644 index dfb9683..0000000 --- a/src/components/Modal.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import cx from 'clsx'; -import * as React from 'react'; -import ReactModalBase, { Props as ReactModalProps } from 'react-modal'; - -import s0 from './Modal.module.scss'; - -type Props = ReactModalProps & { - isOpen: boolean; - onRequestClose: (...args: any[]) => any; - children: React.ReactNode; -}; - -const ReactModal = ReactModalBase as unknown as React.ComponentType<ReactModalProps>; - -function withBaseClass( - className: ReactModalProps['className'], - baseClassName: string -): ReactModalProps['className'] { - if (!className) { - return baseClassName; - } - - if (typeof className === 'string') { - return cx(className, baseClassName); - } - - return { - ...className, - base: cx(className.base, baseClassName), - }; -} - -function ModalAPIConfig({ - isOpen, - onRequestClose, - className, - overlayClassName, - children, - ...otherProps -}: Props) { - const contentCls = withBaseClass(className, s0.content); - const overlayCls = withBaseClass(overlayClassName, s0.overlay); - return ( - <ReactModal - isOpen={isOpen} - onRequestClose={onRequestClose} - className={contentCls} - overlayClassName={overlayCls} - {...otherProps} - > - {children} - </ReactModal> - ); -} - -export default React.memo(ModalAPIConfig); diff --git a/src/components/Search.module.scss b/src/components/Search.module.scss deleted file mode 100644 index f9b829e..0000000 --- a/src/components/Search.module.scss +++ /dev/null @@ -1,32 +0,0 @@ -.RuleSearch { - width: 100%; -} - -.input { - -webkit-appearance: none; - appearance: none; - background-color: var(--color-input-bg); - background-image: none; - border-radius: 8px; - border: 1px solid transparent; - box-sizing: border-box; - color: var(--color-text); - display: inline-block; - font-size: 0.95em; - height: 40px; - outline: none; - padding: 0 16px; - transition: all 0.2s ease; - width: 100%; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); - - &::placeholder { - color: var(--color-text-secondary); - opacity: 0.6; - } - - &:focus { - border-color: var(--color-focus-blue); - box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.15); - } -} diff --git a/src/components/Search.tsx b/src/components/Search.tsx deleted file mode 100644 index c1eee58..0000000 --- a/src/components/Search.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import debounce from 'lodash-es/debounce'; -import React, { useCallback, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import s0 from './Search.module.scss'; - -function RuleSearch({ dispatch, searchText, updateSearchText, className }) { - const { t } = useTranslation(); - const [text, setText] = useState(searchText); - const updateSearchTextInternal = useCallback( - (v) => { - dispatch(updateSearchText(v)); - }, - [dispatch, updateSearchText] - ); - const updateSearchTextDebounced = useMemo( - () => debounce(updateSearchTextInternal, 300), - [updateSearchTextInternal] - ); - const onChange = (e) => { - setText(e.target.value); - updateSearchTextDebounced(e.target.value); - }; - - return ( - <div className={className || s0.RuleSearch}> - <input - type="text" - value={text} - onChange={onChange} - className={s0.input} - placeholder={t('Search')} - /> - </div> - ); -} - -export default RuleSearch; diff --git a/src/components/SwitchThemed.tsx b/src/components/SwitchThemed.tsx deleted file mode 100644 index 923eff1..0000000 --- a/src/components/SwitchThemed.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from 'react'; -import ReactSwitch from 'react-switch'; - -import { State } from '~/store/types'; - -import { getTheme } from '../store/app'; - -import { connect } from './StateProvider'; - -// workaround https://github.com/vitejs/vite/issues/2139#issuecomment-802981228 -// @ts-ignore -const Switch = ReactSwitch.default ? ReactSwitch.default : ReactSwitch; - -function SwitchThemed({ checked = false, onChange, theme, name }) { - const offColor = theme === 'dark' ? '#393939' : '#e9e9e9'; - const onColor = theme === 'dark' ? '#306081' : '#005caf'; - return ( - <Switch - onChange={onChange} - checked={checked} - uncheckedIcon={false} - checkedIcon={false} - offColor={offColor} - onColor={onColor} - offHandleColor="#fff" - onHandleColor="#fff" - handleDiameter={24} - height={28} - width={44} - className="rs" - name={name} - /> - ); -} - -export default connect((s: State) => ({ theme: getTheme(s) }))(SwitchThemed); diff --git a/src/components/about/About.tsx b/src/components/about/About.tsx index 10e8f6e..8d122fe 100644 --- a/src/components/about/About.tsx +++ b/src/components/about/About.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; -import ContentHeader from '~/components/ContentHeader'; import { GitHub } from '~/components/shared/FeatherIcons'; import { useAboutVersionQuery } from '~/modules/about/hooks'; import { getCoreVersionMeta } from '~/modules/about/utils'; @@ -34,7 +33,6 @@ export function About({ apiConfig }: Props) { return ( <> - <ContentHeader>About</ContentHeader> {coreVersionMeta && version?.version ? ( <Version name={coreVersionMeta.name} diff --git a/src/components/backend/APIConfig.module.scss b/src/components/backend/APIConfig.module.scss index 6581d46..cfc740f 100644 --- a/src/components/backend/APIConfig.module.scss +++ b/src/components/backend/APIConfig.module.scss @@ -1,52 +1,268 @@ .root { + // 弹层里这块是 flex item,写死宽度才不会被内容撑成 shrink-to-fit + width: 520px; + max-width: 100%; + margin: 0 auto; + padding: 40px 0 48px; + display: flex; + flex-direction: column; + gap: 24px; + &:focus { outline: none; } } -.header { +/* 顶部品牌区 */ +.hero { display: flex; - justify-content: center; + flex-direction: column; align-items: center; + text-align: center; + gap: 8px; +} - .icon { - --stroke: #f3f3f3; - color: #20497e; - opacity: 0.7; - transition: opacity 400ms; - &:hover { - opacity: 1; - } +.logo { + --stroke: var(--color-background); + color: #20497e; + opacity: 0.85; + transition: opacity 400ms; + &:hover { + opacity: 1; } } -.body { - padding: 15px 0 0; +.title { + margin: 4px 0 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-highlight); +} + +.desc { + margin: 0; + font-size: 0.85rem; + line-height: 1.5; + color: var(--color-text-secondary); +} + +/* 表单卡片 */ +.card { + padding: 20px; + border-radius: var(--border-radius); + background: var(--color-card); + border: 1px solid var(--color-card-border); + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + gap: 16px; +} + +/* 协议 / 主机 / 端口一行,secret 独占一行 */ +.grid { + display: grid; + gap: 14px 12px; + grid-template-columns: minmax(140px, auto) minmax(0, 1fr) 88px; + grid-template-areas: + 'protocol host port' + 'secret secret secret'; + + @media screen and (max-width: 30em) { + grid-template-columns: minmax(0, 1fr) 88px; + grid-template-areas: + 'protocol protocol' + 'host port' + 'secret secret'; + } +} + +.protocol { + grid-area: protocol; +} +.host { + grid-area: host; +} +.port { + grid-area: port; +} +.secret { + grid-area: secret; +} + +.field { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; } -.hostnamePort { +.label { display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text-secondary); +} + +.optional { + padding: 1px 6px; + border-radius: 100px; + background: var(--color-badge-bg); + color: var(--color-badge-fg); + font-size: 0.65rem; + font-weight: 400; + text-transform: lowercase; +} + +.input { + -webkit-appearance: none; + width: 100%; + min-width: 0; + height: 38px; + box-sizing: border-box; + padding: 0 10px; + font-family: inherit; + font-size: 0.875rem; + color: var(--color-text); + background: var(--color-input-bg); + border: 1px solid var(--color-input-border); + border-radius: 10px; + outline: none; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; - div { - flex: 1 1 auto; + &::placeholder { + color: var(--color-text-secondary); + opacity: 0.6; } - div:nth-child(2) { - flex-grow: 0; - flex-basis: 120px; - margin-left: 10px; + &:focus { + border-color: var(--color-focus-blue); + box-shadow: 0 0 0 3px var(--color-accent-soft-bg); } } +/* 协议分段器与输入框对齐高度 */ +.protocolControl { + height: 38px; +} + +.inputWithAction { + position: relative; + display: flex; + align-items: center; + + .input { + padding-right: 40px; + } +} + +.iconBtn { + position: absolute; + right: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + appearance: none; + border: none; + border-radius: 8px; + background: transparent; + color: var(--color-icon); + cursor: pointer; + + &:hover { + color: var(--color-text); + background: var(--color-hover-soft); + } + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: -2px; + } +} + +.actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.status { + min-width: 0; + font-size: 0.75rem; + line-height: 1.4; +} + +.buttons { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.preview { + display: block; + color: var(--color-text-secondary); + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .error { - height: 20px; - font-size: 0.8em; - color: #ff8b8b; + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--color-danger); + + svg { + flex-shrink: 0; + } +} + +/* 已保存后端 */ +.saved { + display: flex; + flex-direction: column; + gap: 10px; } -.footer { - padding: 5px 0 10px; +.savedHeader { display: flex; - justify-content: flex-end; align-items: center; + gap: 8px; + padding: 0 4px; +} + +.savedTitle { + margin: 0; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--color-text-secondary); +} + +.count { + padding: 1px 8px; + border-radius: 100px; + background: var(--color-badge-bg); + color: var(--color-badge-fg); + font-size: 0.7rem; +} + +.empty { + margin: 0; + padding: 20px; + text-align: center; + font-size: 0.8rem; + color: var(--color-text-secondary); + border: 1px dashed var(--color-card-border); + border-radius: var(--border-radius); } diff --git a/src/components/backend/APIConfig.tsx b/src/components/backend/APIConfig.tsx index 72c72ca..3f26690 100644 --- a/src/components/backend/APIConfig.tsx +++ b/src/components/backend/APIConfig.tsx @@ -1,13 +1,18 @@ +import cx from 'clsx'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; -import Button from '~/components/Button'; -import Field from '~/components/Field'; -import SvgYacd from '~/components/SvgYacd'; +import Button from '~/components/shared/Button'; +import { AlertCircle, Eye, EyeOff } from '~/components/shared/FeatherIcons'; +import { SegmentedControl } from '~/components/shared/SegmentedControl'; +import SvgYacd from '~/components/shared/SvgYacd'; +import { useToggle } from '~/hooks/basic'; import { useBackendConfigForm } from '~/modules/backend/hooks'; +import type { Protocol } from '~/modules/backend/utils'; import type { ClashAPIConfigWithAddedAt } from '~/store/types'; import type { ClashAPIConfig } from '~/types'; -import s0 from './APIConfig.module.scss'; +import s from './APIConfig.module.scss'; import { BackendList } from './BackendList'; type Props = { @@ -16,9 +21,13 @@ type Props = { onAddConfig: (config: ClashAPIConfig) => void; onRemoveConfig: (config: ClashAPIConfig) => void; onSelectConfig: (config: ClashAPIConfig) => void; + onUpdateConfig: (prev: ClashAPIConfig, next: ClashAPIConfig) => void; }; -const { useRef } = React; +const protocolOptions: { value: Protocol; label: string }[] = [ + { value: 'http', label: 'HTTP' }, + { value: 'https', label: 'HTTPS' }, +]; export default function APIConfig({ apiConfigs, @@ -26,51 +35,162 @@ export default function APIConfig({ onAddConfig, onRemoveConfig, onSelectConfig, + onUpdateConfig, }: Props) { - const contentEl = useRef(null); - const { baseURL, secret, errMsg, handleInputOnChange, handleContentOnKeyDown, onConfirm } = - useBackendConfigForm({ onAddConfig }); + const { t } = useTranslation(); + const [showSecret, toggleSecret] = useToggle(); + const { + protocol, + host, + port, + secret, + errMsg, + baseURLPreview, + isSubmitting, + editing, + startEdit, + cancelEdit, + handleProtocolOnChange, + handleInputOnChange, + handleContentOnKeyDown, + onConfirm, + } = useBackendConfigForm({ onAddConfig, onUpdateConfig }); + + const SecretIcon = showSecret ? EyeOff : Eye; return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions - <div className={s0.root} ref={contentEl} onKeyDown={handleContentOnKeyDown}> - <div className={s0.header}> - <div className={s0.icon}> - <SvgYacd width={160} height={160} stroke="var(--stroke)" /> + // oxlint-disable-next-line jsx-a11y/no-static-element-interactions + <div className={s.root} onKeyDown={handleContentOnKeyDown}> + <header className={s.hero}> + <div className={s.logo}> + <SvgYacd width={96} height={96} stroke="var(--stroke)" /> </div> - </div> - <div className={s0.body}> - <div className={s0.hostnamePort}> - <Field - id="baseURL" - name="baseURL" - label="API Base URL" - type="text" - placeholder="http://127.0.0.1:9090" - value={baseURL} - onChange={handleInputOnChange} - /> - <Field - id="secret" - name="secret" - label="Secret(optional)" - value={secret} - type="text" - onChange={handleInputOnChange} - /> + <h1 className={s.title}> + {editing ? t('backend_form_title_edit') : t('backend_form_title')} + </h1> + <p className={s.desc}>{editing ? editing.baseURL : t('backend_form_desc')}</p> + </header> + + <section className={s.card}> + <div className={s.grid}> + <div className={cx(s.field, s.protocol)}> + <span className={s.label} id="backend-protocol-label"> + {t('protocol')} + </span> + <SegmentedControl + options={protocolOptions} + value={protocol} + onChange={handleProtocolOnChange} + label={t('protocol')} + className={s.protocolControl} + /> + </div> + + <div className={cx(s.field, s.host)}> + <label className={s.label} htmlFor="backend-host"> + {t('host')} + </label> + <input + className={s.input} + id="backend-host" + name="host" + type="text" + autoComplete="off" + spellCheck={false} + placeholder="127.0.0.1" + value={host} + onChange={handleInputOnChange} + /> + </div> + + <div className={cx(s.field, s.port)}> + <label className={s.label} htmlFor="backend-port"> + {t('port')} + </label> + <input + className={s.input} + id="backend-port" + name="port" + type="text" + inputMode="numeric" + autoComplete="off" + placeholder="9090" + value={port} + onChange={handleInputOnChange} + /> + </div> + + <div className={cx(s.field, s.secret)}> + <label className={s.label} htmlFor="backend-secret"> + {t('secret')} + <span className={s.optional}>{t('optional')}</span> + </label> + <div className={s.inputWithAction}> + <input + className={s.input} + id="backend-secret" + name="secret" + type={showSecret ? 'text' : 'password'} + autoComplete="off" + spellCheck={false} + value={secret} + onChange={handleInputOnChange} + /> + <button + type="button" + className={s.iconBtn} + onClick={toggleSecret} + title={showSecret ? t('hide_secret') : t('show_secret')} + aria-label={showSecret ? t('hide_secret') : t('show_secret')} + > + <SecretIcon size={16} /> + </button> + </div> + </div> + </div> + + <div className={s.actions}> + <div className={s.status}> + {errMsg ? ( + <span className={s.error}> + <AlertCircle size={14} /> + {errMsg} + </span> + ) : baseURLPreview ? ( + <span className={s.preview}>{baseURLPreview}</span> + ) : null} + </div> + <div className={s.buttons}> + {editing ? ( + <Button kind="minimal" label={t('cancel_edit')} onClick={cancelEdit} /> + ) : null} + <Button + label={editing ? t('save_backend') : t('add_backend')} + onClick={onConfirm} + isLoading={isSubmitting} + /> + </div> </div> - </div> - <div className={s0.error}>{errMsg ? errMsg : null}</div> - <div className={s0.footer}> - <Button label="Add" onClick={onConfirm} /> - </div> - <div style={{ height: 20 }} /> - <BackendList - apiConfigs={apiConfigs} - selectedClashAPIConfigIndex={selectedClashAPIConfigIndex} - onRemove={onRemoveConfig} - onSelect={onSelectConfig} - /> + </section> + + <section className={s.saved}> + <div className={s.savedHeader}> + <h2 className={s.savedTitle}>{t('saved_backends')}</h2> + {apiConfigs.length > 0 ? <span className={s.count}>{apiConfigs.length}</span> : null} + </div> + {apiConfigs.length > 0 ? ( + <BackendList + apiConfigs={apiConfigs} + selectedClashAPIConfigIndex={selectedClashAPIConfigIndex} + editing={editing} + onRemove={onRemoveConfig} + onSelect={onSelectConfig} + onEdit={startEdit} + /> + ) : ( + <p className={s.empty}>{t('no_saved_backends')}</p> + )} + </section> </div> ); } diff --git a/src/components/backend/APIDiscovery.module.scss b/src/components/backend/APIDiscovery.module.scss index e2161f8..630ec36 100644 --- a/src/components/backend/APIDiscovery.module.scss +++ b/src/components/backend/APIDiscovery.module.scss @@ -6,7 +6,10 @@ left: 0; right: 0; - transform: none; + // Modal 用独立的 translate 属性做居中,这里必须同样用 translate 抵消。 + // 注意别在同一条规则里再写 transform:postcss-preset-env 会把两者合并成 + // transform: translate(0,0),translate 声明被吞掉,弹层就会偏移半屏。 + translate: none; padding: 0; border-radius: 0; diff --git a/src/components/backend/APIDiscovery.tsx b/src/components/backend/APIDiscovery.tsx index f848623..aace980 100644 --- a/src/components/backend/APIDiscovery.tsx +++ b/src/components/backend/APIDiscovery.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import Modal from '~/components/Modal'; +import Modal from '~/components/shared/Modal'; import { ThemeSwitcher } from '~/components/shared/ThemeSwitcher'; import { DOES_NOT_SUPPORT_FETCH, errors } from '~/misc/errors'; diff --git a/src/components/backend/BackendList.module.scss b/src/components/backend/BackendList.module.scss index f3be8da..94d27c7 100644 --- a/src/components/backend/BackendList.module.scss +++ b/src/components/backend/BackendList.module.scss @@ -1,102 +1,156 @@ .ul { - position: relative; margin: 0; padding: 0; list-style: none; - line-height: 1.8; - - --width-max-content: 230px; + display: flex; + flex-direction: column; + gap: 8px; } .li { - position: relative; - margin: 5px 0; - padding: 10px 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px 8px 4px; border-radius: var(--border-radius); - display: grid; - place-content: center; - grid-template-columns: 40px 1fr 40px; - grid-template-rows: 30px; - grid-template-areas: 'close url .'; - column-gap: 10px; - border: 1px solid var(--bg-near-transparent); + background: var(--color-card); + border: 1px solid var(--color-card-border); + transition: + border-color 0.2s ease, + background-color 0.2s ease; + + &:hover { + border-color: var(--color-focus-blue); + } } -.li:hover { - background-color: var(--bg-near-transparent); +.selected { + border-color: var(--color-focus-blue); + background: var(--color-node-active-bg); } -.close { - opacity: 0; - grid-area: close; - place-self: center; - cursor: pointer; +/* 上方表单正绑着这一条 */ +.editing { + border-color: var(--color-focus-blue); + box-shadow: 0 0 0 3px var(--color-accent-soft-bg); } -.li:hover .close, -.li:hover .eye { - opacity: 1; +/* 整行左侧是切换后端的点击区 */ +.main { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + appearance: none; + border: none; + border-radius: 8px; + background: transparent; + color: inherit; + font-family: inherit; + text-align: left; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: -2px; + } } -.close:focus, -.eye:focus { - opacity: 1; + +.dot { + flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-text-secondary); + opacity: 0.4; + + .selected & { + background: var(--color-success); + opacity: 1; + } } -.hasSecret { - grid-template-rows: repeat(2, 30px); - grid-template-areas: - 'close url .' - 'close secret eye'; +.body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; } .url { - grid-area: url; -} -.secret { - grid-area: secret; -} -.eye { - grid-area: eye; - opacity: 0; - place-self: center; - cursor: pointer; + font-size: 0.875rem; + color: var(--color-text-highlight); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.url, .secret { + font-size: 0.72rem; + color: var(--color-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.btn { - outline: none; - appearance: none; - border: 1px solid transparent; - background-color: transparent; - color: inherit; +.tools { + flex-shrink: 0; display: flex; align-items: center; - padding: 5px; - border-radius: 100px; -} -.btn:focus { - border-color: var(--color-focus-blue); -} -.btn:hover:enabled { - background-color: var(--color-focus-blue); - color: white; -} -.btn:active:enabled { - transform: scale(0.97); + gap: 4px; } -.btn:disabled { - color: var(--color-text-secondary); + +.badge { + padding: 2px 8px; + border-radius: 100px; + background: var(--color-accent-soft-bg); + color: var(--color-accent-soft-fg); + font-size: 0.68rem; + font-weight: 500; + white-space: nowrap; } -.url { +.iconBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + appearance: none; + border: none; + border-radius: 8px; + background: transparent; + color: var(--color-icon); cursor: pointer; + transition: + color 0.2s ease, + background-color 0.2s ease; + + &:hover:enabled { + color: var(--color-text); + background: var(--color-hover-soft); + } + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: -2px; + } + + &:disabled { + opacity: 0.35; + cursor: default; + } } -.url:hover { - color: var(--color-text-highlight); + +.active { + color: var(--color-accent-soft-fg); + background: var(--color-accent-soft-bg); +} + +.remove:hover:enabled { + color: var(--color-danger); + background: var(--color-danger-soft-bg); } diff --git a/src/components/backend/BackendList.tsx b/src/components/backend/BackendList.tsx index 66fc319..1cbbe05 100644 --- a/src/components/backend/BackendList.tsx +++ b/src/components/backend/BackendList.tsx @@ -1,7 +1,8 @@ import cx from 'clsx'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; -import { Eye, EyeOff, X as Close } from '~/components/shared/FeatherIcons'; +import { Edit3, Eye, EyeOff, Trash2 } from '~/components/shared/FeatherIcons'; import { useToggle } from '~/hooks/basic'; import type { ClashAPIConfigWithAddedAt } from '~/store/types'; import type { ClashAPIConfig } from '~/types'; @@ -11,110 +12,111 @@ import s from './BackendList.module.scss'; type Props = { apiConfigs: ClashAPIConfigWithAddedAt[]; selectedClashAPIConfigIndex: number; + editing: ClashAPIConfig | null; onRemove: (x: ClashAPIConfig) => void; onSelect: (x: ClashAPIConfig) => void; + onEdit: (x: ClashAPIConfig) => void; }; export function BackendList({ apiConfigs, selectedClashAPIConfigIndex, + editing, onRemove, onSelect, + onEdit, }: Props) { return ( - <> - <ul className={s.ul}> - {apiConfigs.map((item, idx) => { - return ( - <li - className={cx(s.li, { - [s.hasSecret]: item.secret, - [s.isSelected]: idx === selectedClashAPIConfigIndex, - })} - key={item.baseURL + item.secret} - > - <Item - disableRemove={idx === selectedClashAPIConfigIndex} - baseURL={item.baseURL} - secret={item.secret} - onRemove={onRemove} - onSelect={onSelect} - /> - </li> - ); - })} - </ul> - </> + <ul className={s.ul}> + {apiConfigs.map((item, idx) => ( + <Item + key={item.baseURL + item.secret} + baseURL={item.baseURL} + secret={item.secret} + isSelected={idx === selectedClashAPIConfigIndex} + isEditing={ + editing != null && editing.baseURL === item.baseURL && editing.secret === item.secret + } + onRemove={onRemove} + onSelect={onSelect} + onEdit={onEdit} + /> + ))} + </ul> ); } function Item({ baseURL, secret, - disableRemove, + isSelected, + isEditing, onRemove, onSelect, + onEdit, }: { baseURL: string; secret?: string; - disableRemove: boolean; + isSelected: boolean; + isEditing: boolean; onRemove: (x: ClashAPIConfig) => void; onSelect: (x: ClashAPIConfig) => void; + onEdit: (x: ClashAPIConfig) => void; }) { + const { t } = useTranslation(); const [show, toggle] = useToggle(); - const Icon = show ? EyeOff : Eye; - - const handleTap = React.useCallback((e: React.KeyboardEvent) => { - e.stopPropagation(); - }, []); + const SecretIcon = show ? EyeOff : Eye; return ( - <> - <Button - disabled={disableRemove} - onClick={() => onRemove({ baseURL, secret })} - className={s.close} - > - <Close size={20} /> - </Button> - <span - className={s.url} - tabIndex={0} - role="button" + <li className={cx(s.li, { [s.selected]: isSelected, [s.editing]: isEditing })}> + <button + type="button" + className={s.main} + title={isSelected ? undefined : t('use_this_backend')} onClick={() => onSelect({ baseURL, secret })} - onKeyUp={handleTap} > - {baseURL} - </span> - <span /> - {secret ? ( - <> - <span className={s.secret}>{show ? secret : '***'}</span> - - <Button onClick={toggle} className={s.eye}> - <Icon size={20} /> - </Button> - </> - ) : null} - </> - ); -} + <span className={s.dot} /> + <span className={s.body}> + <span className={s.url}>{baseURL}</span> + <span className={s.secret}> + {secret ? (show ? secret : '••••••••') : t('backend_no_secret')} + </span> + </span> + </button> -function Button({ - children, - onClick, - className, - disabled, -}: { - children: React.ReactNode; - - onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown; - className: string; - disabled?: boolean; -}) { - return ( - <button disabled={disabled} className={cx(className, s.btn)} onClick={onClick}> - {children} - </button> + <div className={s.tools}> + {isSelected ? <span className={s.badge}>{t('backend_in_use')}</span> : null} + {secret ? ( + <button + type="button" + className={s.iconBtn} + onClick={toggle} + title={show ? t('hide_secret') : t('show_secret')} + aria-label={show ? t('hide_secret') : t('show_secret')} + > + <SecretIcon size={16} /> + </button> + ) : null} + <button + type="button" + className={cx(s.iconBtn, { [s.active]: isEditing })} + onClick={() => onEdit({ baseURL, secret })} + title={t('edit_backend')} + aria-label={t('edit_backend')} + > + <Edit3 size={16} /> + </button> + <button + type="button" + className={cx(s.iconBtn, s.remove)} + disabled={isSelected} + onClick={() => onRemove({ baseURL, secret })} + title={t('remove_backend')} + aria-label={t('remove_backend')} + > + <Trash2 size={16} /> + </button> + </div> + </li> ); } diff --git a/src/components/config/Config.module.scss b/src/components/config/Config.module.scss index 1c178b4..1e72168 100644 --- a/src/components/config/Config.module.scss +++ b/src/components/config/Config.module.scss @@ -1,5 +1,3 @@ -@use '~/styles/utils/custom-media' as *; - .root { max-width: 1000px; padding: 30px 20px; @@ -24,27 +22,23 @@ } } +// 同一项下并排的多个按钮(如内核更新的稳定版 / Alpha 版) +.buttonGroup { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + .wrapSwitch { height: 40px; display: flex; align-items: center; } -.sep { - max-width: 900px; - padding: 0 15px; - @media (--breakpoint-not-small) { - padding: 0 40px; - } - > div { - border-top: 1px dashed var(--color-separator); - } -} - .label { padding: 10px 0 8px; font-size: 0.85rem; - color: var(--color-text-secondary, #999); + color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.5px; } @@ -60,17 +54,13 @@ color: var(--color-text-highlight); } +// 与代理组 / 连接表格 / 规则列表同一张卡:--color-card 而不是更灰一档的 +// --bg-log-info-card,否则设置页的卡片在浅色下比别处暗一档 .card { - background: var(--bg-log-info-card); - border: 1px solid var(--color-separator); + background: var(--color-card); + border: 1px solid var(--color-card-border); border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: var(--shadow-card); - transition: border-color 0.2s ease, box-shadow 0.2s ease; - - &:hover { - border-color: var(--color-focus-blue); - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); - } } diff --git a/src/components/config/Config.tsx b/src/components/config/Config.tsx index 252cf04..bdbd27a 100644 --- a/src/components/config/Config.tsx +++ b/src/components/config/Config.tsx @@ -1,9 +1,7 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import Button from '~/components/Button'; -import Input from '~/components/Input'; -import { Selection2 } from '~/components/Selection'; +import Button from '~/components/shared/Button'; import { Cpu, DownloadCloud, @@ -14,10 +12,11 @@ import { Tool, Trash2, } from '~/components/shared/FeatherIcons'; +import Input from '~/components/shared/Input'; import Select from '~/components/shared/Select'; +import { Selection2 } from '~/components/shared/Selection'; +import Switch from '~/components/shared/SwitchThemed'; import TrafficChartSample from '~/components/shared/TrafficChartSample'; -import { useStoreActions } from '~/components/StateProvider'; -import Switch from '~/components/SwitchThemed'; import { useConfigPage } from '~/modules/config/hooks'; import { CONFIG_CHART_STYLE_PROPS, @@ -28,6 +27,7 @@ import { PORT_FIELDS, TUN_STACK_OPTIONS, } from '~/modules/config/utils'; +import { useStoreActions } from '~/store/StateProvider'; import { ClashGeneralConfig, DispatchFn } from '~/store/types'; import { ClashAPIConfig } from '~/types'; @@ -40,12 +40,7 @@ type Props = { apiConfig: ClashAPIConfig; }; -export default function Config({ - dispatch, - configs, - selectedChartStyleIndex, - apiConfig, -}: Props) { +export default function Config({ dispatch, configs, selectedChartStyleIndex, apiConfig }: Props) { const { t, i18n } = useTranslation(); const { selectChartStyleIndex, updateAppConfig } = useStoreActions(); @@ -57,8 +52,10 @@ export default function Config({ handleReloadConfigFile, handleRestartCore, handleUpgradeCore, + upgradingChannel, handleUpgradeGeo, handleUpgradeUI, + isUpgradingUI, handleFlushFakeIPPool, versionQuery: { data: version }, } = useConfigPage({ @@ -91,7 +88,7 @@ export default function Config({ onBlur={handleInputOnBlur} /> </div> - ) : null + ) : null, )} <div> @@ -230,6 +227,8 @@ export default function Config({ <Button start={<DownloadCloud size={16} />} label={t('upgrade_ui')} + isLoading={isUpgradingUI} + disabled={isUpgradingUI} onClick={handleUpgradeUI} /> </div> @@ -254,12 +253,23 @@ export default function Config({ )} {version.meta && !version.premium && ( <div> - <div className={s0.label}>⚠️ Upgrade ⚠️</div> - <Button - start={<RotateCw size={16} />} - label={t('upgrade_core')} - onClick={handleUpgradeCore} - /> + <div className={s0.label}> {t('upgrade_core')} </div> + <div className={s0.buttonGroup}> + <Button + start={<DownloadCloud size={16} />} + label={t('upgrade_core_release')} + isLoading={upgradingChannel === 'release'} + disabled={upgradingChannel !== null} + onClick={() => handleUpgradeCore('release')} + /> + <Button + start={<DownloadCloud size={16} />} + label={t('upgrade_core_alpha')} + isLoading={upgradingChannel === 'alpha'} + disabled={upgradingChannel !== null} + onClick={() => handleUpgradeCore('alpha')} + /> + </div> </div> )} </div> diff --git a/src/components/connections/ConnectionCard.module.scss b/src/components/connections/ConnectionCard.module.scss index e0c42c3..3c4691a 100644 --- a/src/components/connections/ConnectionCard.module.scss +++ b/src/components/connections/ConnectionCard.module.scss @@ -1,133 +1,156 @@ .card { - background: var(--color-bg-card); - border-radius: 12px; - padding: 10px 14px; - margin-bottom: 8px; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); display: flex; flex-direction: column; gap: 4px; + height: 112px; + margin-bottom: 8px; + padding: 10px 14px; + border-radius: 14px; + background: var(--color-card); + border: 1px solid var(--color-card-border); + box-shadow: var(--shadow-card); cursor: pointer; - transition: transform 0.1s ease; + overflow: hidden; + transition: + border-color 0.15s ease, + background-color 0.15s ease; + + &:hover { + border-color: var(--color-focus-blue); + } &:active { - transform: scale(0.98); + transform: scale(0.99); } } .row { display: flex; - justify-content: space-between; align-items: center; + gap: 8px; + height: 28px; min-width: 0; } +.dot { + flex: 0 0 7px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--color-text-secondary); + opacity: 0.4; +} + +.dotBusy { + background: var(--color-success); + opacity: 1; +} + .host { - color: #40c4aa; // Similar to the image + flex: 1; + min-width: 0; + font-size: 0.9rem; font-weight: 600; - font-size: 0.95rem; + color: var(--color-text-highlight); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - flex: 1; - margin-right: 8px; } .time { - color: var(--color-text-secondary); - font-size: 0.8rem; flex-shrink: 0; + font-size: 0.72rem; + color: var(--color-text-secondary); +} + +.chip { + padding: 3px 8px; + border-radius: 7px; + font-size: 0.68rem; + white-space: nowrap; +} + +.chipTcp { + background: var(--color-accent-soft-bg); + color: var(--color-accent-soft-fg); } -.typeProtocol { - color: var(--color-text); - font-size: 0.85rem; - opacity: 0.8; +.chipUdp { + background: var(--color-udp-bg); + color: var(--color-udp-fg); } .totals { display: flex; - gap: 10px; - font-size: 0.8rem; + gap: 12px; + margin-left: auto; + font-size: 0.72rem; + font-variant-numeric: tabular-nums; color: var(--color-text-secondary); span { display: flex; align-items: center; - gap: 4px; - } - - svg { - opacity: 0.5; + gap: 3px; } } -.ruleChain { +.chain { display: flex; align-items: center; gap: 6px; - color: var(--color-text-secondary); - font-size: 0.8rem; - overflow: hidden; flex: 1; - margin-right: 8px; + min-width: 0; + font-size: 0.74rem; + color: var(--color-text-secondary); +} - .rule { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } +.rule { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} - .arrow { - opacity: 0.5; - font-size: 0.7rem; - } +.arrow { + flex-shrink: 0; + opacity: 0.55; +} - .chains { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 500; - color: var(--color-text); - opacity: 0.9; - } +.node { + font-weight: 500; + color: var(--color-focus-blue); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .speedAndAction { display: flex; align-items: center; gap: 8px; + flex-shrink: 0; } .speed { - display: flex; - align-items: center; - gap: 4px; - font-size: 0.8rem; - color: var(--color-text-secondary); - font-family: 'Roboto Mono', monospace; - - .speedIcon { - color: #40c4aa; - opacity: 0.8; - } + font-size: 0.74rem; + font-variant-numeric: tabular-nums; + color: var(--color-success); } .closeBtn { - background: var(--color-bg-secondary, rgba(0, 0, 0, 0.05)); - border: none; - border-radius: 50%; - width: 28px; - height: 28px; display: flex; align-items: center; justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: 50%; + background: var(--color-badge-bg); color: var(--color-text-secondary); cursor: pointer; - transition: all 0.2s ease; &:hover { - background: rgba(231, 76, 60, 0.1); - color: #e74c3c; + background: var(--color-danger-soft-bg); + color: var(--color-danger); } } diff --git a/src/components/connections/ConnectionCard.tsx b/src/components/connections/ConnectionCard.tsx index b7a8ee6..1e681f9 100644 --- a/src/components/connections/ConnectionCard.tsx +++ b/src/components/connections/ConnectionCard.tsx @@ -1,12 +1,12 @@ +import cx from 'clsx'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ArrowDown, ArrowDownCircle, ArrowUp, X } from '~/components/shared/FeatherIcons'; +import { ArrowDown, ArrowUp, X } from '~/components/shared/FeatherIcons'; import prettyBytes from '~/misc/pretty-bytes'; import { formatElapsed, getDateFnsLocale } from '~/modules/connections/utils'; import { FormattedConn } from '~/store/connections'; - import s from './ConnectionCard.module.scss'; interface Props { @@ -19,6 +19,7 @@ const ConnectionCard = React.memo(function ConnectionCard({ conn, onDisconnect, const { i18n } = useTranslation(); const timeAgo = formatElapsed(conn.start, getDateFnsLocale(i18n.language)); + const busy = (conn.downloadSpeedCurr ?? 0) + (conn.uploadSpeedCurr ?? 0) > 0; return ( <div @@ -34,39 +35,46 @@ const ConnectionCard = React.memo(function ConnectionCard({ conn, onDisconnect, }} > <div className={s.row}> + <span className={cx(s.dot, { [s.dotBusy]: busy })} aria-hidden /> <div className={s.host}>{conn.host}</div> <div className={s.time}>{timeAgo}</div> </div> + <div className={s.row}> - <div className={s.typeProtocol}>{conn.type.replace(/\((.*)\)/, ' | $1')}</div> + <span className={cx(s.chip, conn.network === 'udp' ? s.chipUdp : s.chipTcp)}> + {conn.type} + </span> <div className={s.totals}> <span> - {prettyBytes(conn.download)} <ArrowDown size={12} /> + <ArrowDown size={11} /> + {prettyBytes(conn.download)} </span> <span> - {prettyBytes(conn.upload)} <ArrowUp size={12} /> + <ArrowUp size={11} /> + {prettyBytes(conn.upload)} </span> </div> </div> + <div className={s.row}> - <div className={s.ruleChain}> + <div className={s.chain}> <span className={s.rule}>{conn.rule}</span> - <span className={s.arrow}>→</span> - <span className={s.chains}>{conn.chains}</span> + <span className={s.arrow} aria-hidden> + › + </span> + <span className={s.node}>{conn.chainNode}</span> </div> <div className={s.speedAndAction}> - <div className={s.speed}> - {prettyBytes(conn.downloadSpeedCurr)}/s - <ArrowDownCircle size={16} className={s.speedIcon} /> - </div> + <span className={s.speed}>{prettyBytes(conn.downloadSpeedCurr)}/s</span> <button + type="button" className={s.closeBtn} onClick={(e) => { e.stopPropagation(); onDisconnect(conn.id, e); }} > - <X size={16} /> + <X size={14} /> </button> </div> </div> diff --git a/src/components/connections/ConnectionDetailModal.module.scss b/src/components/connections/ConnectionDetailModal.module.scss new file mode 100644 index 0000000..a164ca7 --- /dev/null +++ b/src/components/connections/ConnectionDetailModal.module.scss @@ -0,0 +1,142 @@ +// 覆盖层与面板都挂在连接表格容器内(absolute 定位), +// 模糊只作用于表格区域,不影响页面其它部分 +.overlay { + position: absolute; + inset: 0; + z-index: 20; + background: rgba(15, 23, 42, 0.32); + backdrop-filter: blur(3px) saturate(0.9); + animation: overlayFade 0.18s ease; +} + +.panel { + position: absolute; + top: 50%; + left: 50%; + translate: -50% -50%; + z-index: 21; + display: flex; + flex-direction: column; + width: min(560px, calc(100% - 32px)); + max-height: calc(100% - 32px); + border-radius: 18px; + background: var(--bg-modal); + border: 1px solid var(--color-card-border); + box-shadow: var(--shadow-popover); + overflow: hidden; + animation: panelIn 0.18s ease; +} + +@keyframes overlayFade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes panelIn { + from { + opacity: 0; + scale: 0.96; + } + to { + opacity: 1; + scale: 1; + } +} + +.header { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 12px; + padding: 16px 22px; + border-bottom: 1px solid var(--color-separator); +} + +.headerTitle { + min-width: 0; + font-size: 1rem; + font-weight: 600; + color: var(--color-text-highlight); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.headerClose { + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; + flex-shrink: 0; + width: 26px; + height: 26px; + border: none; + border-radius: 50%; + background: var(--color-badge-bg); + color: var(--color-text-secondary); + cursor: pointer; + + &:hover { + background: var(--color-hover-soft); + color: var(--color-text-highlight); + } +} + +.body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 18px 22px 22px; +} + +.grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + gap: 0; + border-radius: 12px; + border: 1px solid var(--color-card-border); + overflow: hidden; +} + +.item { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + padding: 10px 14px; + background: var(--color-card); + + &:nth-child(4n + 1), + &:nth-child(4n + 2) { + background: var(--color-track); + } + + &:nth-child(odd) { + border-right: 1px solid var(--color-card-border); + } + + &:nth-last-child(-n + 2) { + border-bottom: none; + } +} + +.label { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.value { + font-size: 0.82rem; + color: var(--color-text); + overflow-wrap: anywhere; + font-variant-numeric: tabular-nums; +} diff --git a/src/components/connections/ConnectionDetailModal.tsx b/src/components/connections/ConnectionDetailModal.tsx new file mode 100644 index 0000000..c56c600 --- /dev/null +++ b/src/components/connections/ConnectionDetailModal.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { X } from '~/components/shared/FeatherIcons'; +import prettyBytes from '~/misc/pretty-bytes'; +import { formatElapsed, getDateFnsLocale } from '~/modules/connections/utils'; +import { FormattedConn } from '~/store/connections'; + +import s from './ConnectionDetailModal.module.scss'; + +type Props = { + conn: FormattedConn | null; + onRequestClose: () => void; +}; + +type DetailItem = { label: string; value: string }; + +function DetailGrid({ items }: { items: DetailItem[] }) { + return ( + <div className={s.grid}> + {items.map((item) => ( + <div key={item.label} className={s.item}> + <span className={s.label}>{item.label}</span> + <span className={s.value} title={item.value}> + {item.value || '-'} + </span> + </div> + ))} + </div> + ); +} + +export default function ConnectionDetailModal({ conn, onRequestClose }: Props) { + const { t, i18n } = useTranslation(); + + const items = React.useMemo<DetailItem[]>(() => { + if (!conn) return []; + const locale = getDateFnsLocale(i18n.language); + // 顺序按「连接怎么走 → 流量 → 基础信息 → 地址与元数据」组织 + return [ + // 路由 + { label: t('c_host'), value: conn.host }, + { label: t('c_rule'), value: conn.rule }, + { label: t('c_node'), value: conn.chainNode }, + { label: t('c_full_chain'), value: conn.chainsFull }, + // 流量 + { label: t('c_dl'), value: prettyBytes(conn.download) }, + { label: t('c_ul'), value: prettyBytes(conn.upload) }, + { + label: t('c_dl_speed'), + value: `${prettyBytes(conn.downloadSpeedCurr ?? 0)}/s`, + }, + { + label: t('c_ul_speed'), + value: `${prettyBytes(conn.uploadSpeedCurr ?? 0)}/s`, + }, + // 基础信息 + { label: t('c_source'), value: conn.source }, + { label: t('c_type'), value: conn.type }, + { label: t('c_network'), value: conn.network.toUpperCase() }, + { + label: t('c_time'), + value: formatElapsed(conn.start, locale), + }, + // 地址与元数据 + { label: t('c_destination'), value: `${conn.destinationIP}:${conn.destinationPort}` }, + { label: t('c_destination_ip'), value: conn.destinationIP }, + { label: t('c_sni'), value: conn.sniffHost }, + { label: t('c_process'), value: conn.process ?? '' }, + { label: t('c_source_port'), value: conn.sourcePort }, + { label: t('c_conn_id'), value: conn.id }, + ]; + }, [conn, t, i18n.language]); + + return ( + <> + <div className={s.overlay} onClick={onRequestClose} aria-hidden /> + <div className={s.panel} role="dialog" aria-modal="true" aria-label={t('conn_details')}> + <div className={s.header}> + <span className={s.headerTitle} title={conn?.host}> + {conn?.host || t('conn_details')} + </span> + <button + type="button" + className={s.headerClose} + onClick={onRequestClose} + aria-label={t('close')} + > + <X size={15} /> + </button> + </div> + <div className={s.body}> + <DetailGrid items={items} /> + </div> + </div> + </> + ); +} diff --git a/src/components/connections/ConnectionSettingsModal.module.scss b/src/components/connections/ConnectionSettingsModal.module.scss new file mode 100644 index 0000000..cfb46b2 --- /dev/null +++ b/src/components/connections/ConnectionSettingsModal.module.scss @@ -0,0 +1,365 @@ +// 选择器写两遍是为了压过 Modal.module.scss 的 .content(同为单类选择器, +// 打包顺序不确定),确保这里的 padding / max-height / overflow 生效 +.modal.modal { + display: flex; + flex-direction: column; + width: 640px; + max-width: calc(100vw - 48px); + max-height: calc(100vh - 64px); + padding: 0; + border-radius: 18px; + overflow: hidden; + + // Modal 默认用 translate(-50%,-50%) 居中,而带 transform/translate 的祖先会成为 + // position: fixed 的包含块 —— dnd 拖拽中的元素正是 fixed 定位的,会整体偏移半个 + // 弹窗。这里换成 inset + auto margin 居中,不引入任何变换。 + inset: 0; + translate: none; + margin: auto; + height: fit-content; +} + +.header { + display: flex; + align-items: center; + flex-shrink: 0; + padding: 16px 22px; + border-bottom: 1px solid var(--color-separator); +} + +.headerTitle { + font-size: 1rem; + font-weight: 600; + color: var(--color-text-highlight); +} + +.headerClose { + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; + width: 26px; + height: 26px; + border: none; + border-radius: 50%; + background: var(--color-badge-bg); + color: var(--color-text-secondary); + cursor: pointer; + + &:hover { + background: var(--color-hover-soft); + color: var(--color-text-highlight); + } +} + +.body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 4px 22px 20px; +} + +/* ---------- 单行设置 ---------- */ + +.row { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 0; + border-bottom: 1px solid var(--color-separator); +} + +.rowLabel { + font-size: 0.85rem; + color: var(--color-text); + white-space: nowrap; +} + +.rowHint { + font-size: 0.72rem; + color: var(--color-text-secondary); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.rowControl { + display: flex; + align-items: center; + margin-left: auto; +} + +.textInput { + width: 240px; + max-width: 100%; + height: 34px; + padding: 0 11px; + border-radius: 9px; + border: 1px solid var(--color-input-border); + background: var(--color-input-bg); + color: var(--color-text); + font: inherit; + font-size: 0.8rem; + outline: none; + + &:focus { + border-color: var(--color-focus-blue); + } +} + +/* ---------- 分区 ---------- */ + +.section { + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px 0 0; +} + +.sectionTitle { + font-size: 0.85rem; + font-weight: 500; + color: var(--color-text-highlight); +} + +.sectionHint { + font-size: 0.72rem; + color: var(--color-text-secondary); + margin-top: -6px; +} + +/* ---------- 列管理 ---------- */ + +.columnsGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + + @media (max-width: 640px) { + grid-template-columns: 1fr; + } +} + +.columnsPane { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.paneHead { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.72rem; + color: var(--color-text-secondary); +} + +.paneCount { + margin-left: auto; + padding: 1px 8px; + border-radius: 6px; + background: var(--color-badge-bg); + color: var(--color-badge-fg); + font-variant-numeric: tabular-nums; +} + +.paneBodyFilled, +.paneBodyOutlined { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + border-radius: 12px; + min-height: 120px; + max-height: 240px; + overflow-y: auto; +} + +.paneBodyFilled { + background: var(--color-track); +} + +.paneBodyOutlined { + border: 1px solid var(--color-card-border); +} + +.enabledItem { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 9px; + background: var(--color-card); + border: 1px solid var(--color-card-border); +} + +.enabledItemDragging { + border-color: var(--color-focus-blue); + box-shadow: var(--shadow-popover); +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 22px; + height: 22px; + border-radius: 6px; + color: var(--color-text-secondary); + cursor: grab; + + &:hover { + background: var(--color-hover-soft); + color: var(--color-text-highlight); + } + + &:active { + cursor: grabbing; + } + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: 1px; + } +} + +.availableItem { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 10px; + border: none; + border-radius: 9px; + background: transparent; + color: var(--color-text); + font: inherit; + cursor: pointer; + text-align: left; + + &:hover { + background: var(--color-hover-soft); + } +} + +.itemLabel { + flex: 1; + min-width: 0; + font-size: 0.78rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.addSign { + flex-shrink: 0; + color: var(--color-text-secondary); +} + +.removeBtn { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + + &:hover:not(:disabled) { + background: var(--color-hover-soft); + color: var(--color-text-highlight); + } + + &:disabled { + opacity: 0.35; + cursor: default; + } +} + +.removeBtn:hover { + color: var(--color-danger); +} + +.paneEmpty { + padding: 34px 0; + text-align: center; + font-size: 0.75rem; + color: var(--color-text-secondary); + opacity: 0.7; +} + +/* ---------- 客户端标签 ---------- */ + +.tagList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.tagRow { + display: flex; + align-items: center; + gap: 8px; + + .textInput { + flex: 1; + width: auto; + min-width: 0; + } +} + +/* ---------- 页脚 ---------- */ + +.footer { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; + padding: 14px 22px; + border-top: 1px solid var(--color-separator); + background: var(--color-track); +} + +.ghostBtn { + height: 36px; + padding: 0 15px; + border-radius: 10px; + border: 1px solid var(--color-card-border); + background: var(--color-card); + color: var(--color-text); + font: inherit; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + align-self: flex-start; + + &:hover { + border-color: var(--color-focus-blue); + color: var(--color-focus-blue); + } +} + +.primaryBtn { + height: 36px; + margin-left: auto; + padding: 0 20px; + border-radius: 10px; + border: none; + background: var(--color-focus-blue); + color: #fff; + font: inherit; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + + &:hover { + filter: brightness(1.08); + } +} diff --git a/src/components/connections/ConnectionSettingsModal.tsx b/src/components/connections/ConnectionSettingsModal.tsx new file mode 100644 index 0000000..1d0d5e4 --- /dev/null +++ b/src/components/connections/ConnectionSettingsModal.tsx @@ -0,0 +1,252 @@ +import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd'; +import cx from 'clsx'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Menu, Trash2, X } from '~/components/shared/FeatherIcons'; +import Modal from '~/components/shared/Modal'; +import Switch from '~/components/shared/SwitchThemed'; +import { ConnectionColumn, ConnectionSettings, SourceMapItem } from '~/modules/connections/utils'; + +import s from './ConnectionSettingsModal.module.scss'; + +type Props = { + isOpen: boolean; + onRequestClose: () => void; + settings: ConnectionSettings; + updateSettings: (patch: Partial<ConnectionSettings>) => void; + visibleColumns: ConnectionColumn[]; + availableColumns: ConnectionColumn[]; + addColumn: (id: string) => void; + removeColumn: (id: string) => void; + reorderColumns: (fromIndex: number, toIndex: number) => void; + resetColumns: () => void; + sourceMap: SourceMapItem[]; + setSourceMap: (updater: React.SetStateAction<SourceMapItem[]>) => void; +}; + +function ToggleRow({ + label, + hint, + checked, + onChange, +}: { + label: string; + hint: string; + checked: boolean; + onChange: (value: boolean) => void; +}) { + return ( + <div className={s.row}> + <span className={s.rowLabel}>{label}</span> + <span className={s.rowHint}>{hint}</span> + <div className={s.rowControl}> + <Switch checked={checked} onChange={onChange} /> + </div> + </div> + ); +} + +export default function ConnectionSettingsModal({ + isOpen, + onRequestClose, + settings, + updateSettings, + visibleColumns, + availableColumns, + addColumn, + removeColumn, + reorderColumns, + resetColumns, + sourceMap, + setSourceMap, +}: Props) { + const { t } = useTranslation(); + + const setSource = (key: keyof SourceMapItem, index: number, value: string) => { + setSourceMap((prev) => prev.map((item, i) => (i === index ? { ...item, [key]: value } : item))); + }; + + const onDragEnd = (result: DropResult) => { + if (!result.destination) return; + reorderColumns(result.source.index, result.destination.index); + }; + + return ( + <Modal + isOpen={isOpen} + onRequestClose={onRequestClose} + title={t('conn_settings')} + className={s.modal} + > + <div className={s.header}> + <span className={s.headerTitle}>{t('conn_settings')}</span> + <button + type="button" + className={s.headerClose} + onClick={onRequestClose} + aria-label={t('close_all_confirm_no')} + > + <X size={15} /> + </button> + </div> + + <div className={s.body}> + <div className={s.row}> + <span className={s.rowLabel}>{t('hide_conn_regex')}</span> + <div className={s.rowControl}> + <input + type="text" + className={s.textInput} + value={settings.hideRegex} + placeholder="direct|dns-out" + onChange={(e) => updateSettings({ hideRegex: e.target.value })} + /> + </div> + </div> + + <ToggleRow + label={t('hide_conn')} + hint={t('hide_conn_hint')} + checked={settings.hideEnabled} + onChange={(value) => updateSettings({ hideEnabled: value })} + /> + <ToggleRow + label={t('full_chain')} + hint={t('full_chain_hint')} + checked={settings.fullChain} + onChange={(value) => updateSettings({ fullChain: value })} + /> + + <section className={s.section}> + <span className={s.sectionTitle}>{t('custom_columns')}</span> + <div className={s.columnsGrid}> + <div className={s.columnsPane}> + <div className={s.paneHead}> + <span>{t('columns_enabled')}</span> + <span className={s.paneCount}>{visibleColumns.length}</span> + </div> + <DragDropContext onDragEnd={onDragEnd}> + <Droppable droppableId="enabled-columns"> + {(provided) => ( + <div + className={s.paneBodyFilled} + ref={provided.innerRef} + {...provided.droppableProps} + > + {visibleColumns.map((column, index) => ( + <Draggable key={column.id} draggableId={column.id} index={index}> + {(dragProvided, snapshot) => ( + <div + ref={dragProvided.innerRef} + {...dragProvided.draggableProps} + className={cx(s.enabledItem, { + [s.enabledItemDragging]: snapshot.isDragging, + })} + > + <span + {...dragProvided.dragHandleProps} + className={s.dragHandle} + title={t('drag_to_reorder')} + > + <Menu size={13} /> + </span> + <span className={s.itemLabel}>{t(column.labelKey)}</span> + <button + type="button" + className={s.removeBtn} + onClick={() => removeColumn(column.id)} + title={t('remove')} + > + <X size={13} /> + </button> + </div> + )} + </Draggable> + ))} + {provided.placeholder} + </div> + )} + </Droppable> + </DragDropContext> + </div> + + <div className={s.columnsPane}> + <div className={s.paneHead}> + <span>{t('columns_available')}</span> + <span className={s.paneCount}>{availableColumns.length}</span> + </div> + <div className={s.paneBodyOutlined}> + {availableColumns.map((column) => ( + <button + key={column.id} + type="button" + className={s.availableItem} + onClick={() => addColumn(column.id)} + > + <span className={s.itemLabel}>{t(column.labelKey)}</span> + <span className={s.addSign} aria-hidden> + + + </span> + </button> + ))} + {availableColumns.length === 0 ? ( + <span className={s.paneEmpty}>{t('columns_all_enabled')}</span> + ) : null} + </div> + </div> + </div> + </section> + + <section className={s.section}> + <span className={s.sectionTitle}>{t('client_tag')}</span> + <span className={s.sectionHint}>{t('sourceip_tip')}</span> + <div className={s.tagList}> + {sourceMap.map((item, index) => ( + <div key={index} className={s.tagRow}> + <input + type="text" + className={s.textInput} + value={item.reg} + placeholder={t('c_source')} + onChange={(e) => setSource('reg', index, e.target.value)} + /> + <input + type="text" + className={s.textInput} + value={item.name} + placeholder={t('device_name')} + onChange={(e) => setSource('name', index, e.target.value)} + /> + <button + type="button" + className={s.removeBtn} + onClick={() => setSourceMap((prev) => prev.filter((_, i) => i !== index))} + title={t('delete')} + > + <Trash2 size={13} /> + </button> + </div> + ))} + </div> + <button + type="button" + className={s.ghostBtn} + onClick={() => setSourceMap((prev) => [...prev, { reg: '', name: '' }])} + > + {t('add_tag')} + </button> + </section> + </div> + + <div className={s.footer}> + <button type="button" className={s.ghostBtn} onClick={resetColumns}> + {t('reset_default_columns')} + </button> + <button type="button" className={s.primaryBtn} onClick={onRequestClose}> + {t('done')} + </button> + </div> + </Modal> + ); +} diff --git a/src/components/connections/ConnectionStats.module.scss b/src/components/connections/ConnectionStats.module.scss new file mode 100644 index 0000000..e74715f --- /dev/null +++ b/src/components/connections/ConnectionStats.module.scss @@ -0,0 +1,100 @@ +.grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + padding: 16px 32px 0; + + @media (max-width: 1024px) { + padding: 12px 16px 0; + } + + // 窄屏保持四格一行,只是缩成一条紧凑的统计带 + @media (max-width: 768px) { + gap: 6px; + padding: 8px 12px 0; + } +} + +.card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 13px 15px; + border-radius: 14px; + background: var(--color-card); + border: 1px solid var(--color-card-border); + box-shadow: var(--shadow-card); + min-width: 0; + + @media (max-width: 768px) { + gap: 1px; + padding: 7px 8px; + border-radius: 10px; + box-shadow: none; + } +} + +.label { + font-size: 0.72rem; + letter-spacing: 0.02em; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + @media (max-width: 768px) { + font-size: 0.62rem; + } +} + +.valueRow { + display: flex; + align-items: baseline; + gap: 5px; + min-width: 0; +} + +.value { + font-size: 1.35rem; + font-weight: 600; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + color: var(--color-text-highlight); + + @media (max-width: 768px) { + font-size: 0.95rem; + } +} + +.download { + color: var(--color-success); +} + +.upload { + color: var(--color-focus-blue); +} + +.unit { + font-size: 0.72rem; + color: var(--color-text-secondary); + + @media (max-width: 768px) { + font-size: 0.6rem; + } +} + +/* 空 hint 也占位,四张卡片高度才对齐 */ +.hint { + font-size: 0.7rem; + color: var(--color-text-secondary); + opacity: 0.8; + min-height: 1em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + // 窄屏放不下,累计流量的上下行明细只在宽屏显示 + @media (max-width: 768px) { + display: none; + } +} diff --git a/src/components/connections/ConnectionStats.tsx b/src/components/connections/ConnectionStats.tsx new file mode 100644 index 0000000..83f48e2 --- /dev/null +++ b/src/components/connections/ConnectionStats.tsx @@ -0,0 +1,81 @@ +import cx from 'clsx'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; + +import prettyBytes from '~/misc/pretty-bytes'; + +import s from './ConnectionStats.module.scss'; + +/** prettyBytes 的输出拆成数值和单位,便于两者用不同字号 */ +function splitBytes(n: number): [string, string] { + const [value, unit] = prettyBytes(n).split(' '); + return [value, unit]; +} + +type Props = { + activeCount: number; + downloadSpeed: number; + uploadSpeed: number; + downloadTotal: number; + uploadTotal: number; +}; + +function Stat({ + label, + value, + unit, + hint, + tone, +}: { + label: string; + value: string; + unit: string; + hint?: string; + tone?: 'download' | 'upload'; +}) { + return ( + <div className={s.card}> + <span className={s.label}>{label}</span> + <div className={s.valueRow}> + <span + className={cx(s.value, { + [s.download]: tone === 'download', + [s.upload]: tone === 'upload', + })} + > + {value} + </span> + <span className={s.unit}>{unit}</span> + </div> + <span className={s.hint}>{hint ?? ''}</span> + </div> + ); +} + +export function ConnectionStats({ + activeCount, + downloadSpeed, + uploadSpeed, + downloadTotal, + uploadTotal, +}: Props) { + const { t } = useTranslation(); + + const [dlValue, dlUnit] = splitBytes(downloadSpeed); + const [ulValue, ulUnit] = splitBytes(uploadSpeed); + const [totalValue, totalUnit] = splitBytes(downloadTotal + uploadTotal); + + return ( + <div className={s.grid}> + <Stat label={t('Active Connections')} value={String(activeCount)} unit={t('conn_unit')} /> + <Stat label={t('c_dl_speed')} value={dlValue} unit={`${dlUnit}/s`} tone="download" /> + <Stat label={t('c_ul_speed')} value={ulValue} unit={`${ulUnit}/s`} tone="upload" /> + <Stat + label={t('total_traffic')} + value={totalValue} + unit={totalUnit} + hint={`↓ ${prettyBytes(downloadTotal)} · ↑ ${prettyBytes(uploadTotal)}`} + /> + </div> + ); +} diff --git a/src/components/connections/ConnectionTable.module.scss b/src/components/connections/ConnectionTable.module.scss index e8e3daf..11280aa 100644 --- a/src/components/connections/ConnectionTable.module.scss +++ b/src/components/connections/ConnectionTable.module.scss @@ -1,256 +1,401 @@ -.tr { - transition: all 0.2s ease; - cursor: pointer; +.card { display: flex; - align-items: stretch; - width: 100%; - min-width: fit-content; - - &:hover { - // hover 作用到每个 td,避免 odd 行背景覆盖 - .td { - background: rgba(66, 133, 244, 0.08); - } + flex-direction: column; + flex: 1; + min-height: 0; + position: relative; + border-radius: 16px; + background: var(--color-card); + border: 1px solid var(--color-card-border); + box-shadow: var(--shadow-card); + overflow: hidden; - .odd { - background: rgba(66, 133, 244, 0.08); - } + @media (max-width: 768px) { + background: transparent; + border: none; + box-shadow: none; + border-radius: 0; } } -.th { - height: 48px; - background: var(--bg-log-info-card); - top: 0; - font-size: 0.8em; +.body { + flex: 1; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; +} + +/* ---------- 表头 ---------- */ + +.headWrap { + overflow: hidden; + flex-shrink: 0; + background: var(--color-track); + border-bottom: 1px solid var(--color-card-border); +} + +// 列间距和左右内边距由 ConnectionTable 的 COLUMN_GAP / ROW_PADDING_X 内联施加, +// 因为列宽计算必须扣掉它们,写在这里两边会不同步 +.headRow { + display: grid; + align-items: center; + height: 42px; +} + +.headCell { + display: flex; + align-items: center; + gap: 3px; + min-width: 0; + font-size: 0.72rem; font-weight: 600; - user-select: none; - text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.02em; color: var(--color-text-secondary); - border-bottom: 2px solid var(--color-separator); - position: sticky; - z-index: 20; - padding: 0 8px; white-space: nowrap; - overflow: hidden; - flex-shrink: 0; // Ensure fixed width is respected + user-select: none; + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: -2px; + border-radius: 4px; + } +} + +.headCellSortable { + cursor: pointer; &:hover { color: var(--color-text-highlight); } } -.headerText { - overflow: hidden; - text-overflow: ellipsis; +.headCellActive { + color: var(--color-focus-blue); + + &:hover { + color: var(--color-focus-blue); + } } -.cellText { +.headText { overflow: hidden; text-overflow: ellipsis; - width: 100%; } -.sortIconContainer { - margin-left: 4px; +.headArrow { + font-size: 0.62rem; flex-shrink: 0; - display: flex; - align-items: center; } -.rotate180 { - transform: rotate(180deg); +/* ---------- 行 ---------- */ + +.listWrap { + flex: 1; + min-height: 0; + min-width: 0; } -.btnSection { - button { - margin-right: 15px; - } +.list { + overflow-x: auto; } -.break { - word-wrap: break-word; - word-break: break-all; - align-items: center; - text-align: left; +.mobileList { + overflow-x: hidden; } -.td { - padding: 0 8px; - font-size: 0.875em; - cursor: pointer; - vertical-align: middle; - white-space: nowrap; +.rowWrap { border-bottom: 1px solid var(--color-separator); - transition: color 0.15s ease; - box-sizing: border-box; - flex-shrink: 0; // Ensure fixed width is respected +} + +// 同上,gap / padding 见 ConnectionTable 的常量 +.row { + display: grid; + align-items: center; + height: 44px; + cursor: pointer; + user-select: none; &:hover { - color: var(--color-text-highlight); + background: var(--color-hover-soft); } - font-family: var(--font-normal); + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: -2px; + } } -.overlay { - background: #444; +.cell { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; } -.modal { - background-color: var(--bg-modal); +.cellRight { + justify-content: flex-end; } -.table { - border-collapse: separate; - border-spacing: 0; - width: 100%; - background: transparent; +/* ---------- 单元格内容 ---------- */ - @media (max-width: 768px) { - display: none; +.closeBtn { + display: inline-flex; + align-items: center; + justify-content: center; + appearance: none; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--color-badge-bg); + color: var(--color-text-secondary); + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease; + + &:hover { + background: var(--color-danger-soft-bg); + color: var(--color-danger); } } -.tableWrapper { - margin-top: 0; - border-radius: 12px; - overflow: hidden; - background: transparent; - box-shadow: none; +.dot { + flex: 0 0 7px; + width: 7px; + height: 7px; + border-radius: 50%; +} - @media (max-width: 768px) { - background: transparent; - box-shadow: none; +.dotBusy { + background: var(--color-success); + box-shadow: 0 0 0 3px var(--color-success-soft-bg); + animation: pulseDot 2.4s ease-in-out infinite; +} + +.dotIdle { + background: var(--color-text-secondary); + opacity: 0.4; +} + +@keyframes pulseDot { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.45; + transform: scale(0.82); } } -.theadWrapper { - width: 100%; +.hostText { + font-size: 0.82rem; + font-weight: 500; + color: var(--color-text-highlight); overflow: hidden; - background: var(--bg-log-info-card); - border-top-left-radius: 12px; - border-top-right-radius: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chip { + display: inline-block; + max-width: 100%; + padding: 3px 8px; + border-radius: 7px; + font-size: 0.7rem; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.cardsView { - display: none; +.chipTcp { + background: var(--color-accent-soft-bg); + color: var(--color-accent-soft-fg); +} - @media (max-width: 768px) { - display: block; - padding: 0 4px; - } +.chipUdp { + background: var(--color-udp-bg); + color: var(--color-udp-fg); } -.mobileSortToolbar { - display: flex; - align-items: center; - gap: 12px; - padding: 0; - margin-bottom: 12px; +.chipNeutral { + background: var(--color-badge-bg); + color: var(--color-badge-fg); } -.sortSelectWrapper { - display: flex; - align-items: center; - gap: 10px; - flex: 1; - background: var(--color-bg-card); - padding: 8px 12px; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +.chainGroup, +.chainFull { + font-size: 0.76rem; + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chainSep { + flex: 0 0 auto; + font-size: 0.7rem; + color: var(--color-text-secondary); + opacity: 0.6; +} + +.chainNode { + font-size: 0.76rem; + font-weight: 500; color: var(--color-focus-blue); - position: relative; - border: 1px solid var(--color-bg-card-border, rgba(0, 0, 0, 0.05)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} - select { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0; - cursor: pointer; - z-index: 2; - } +.chainDirect { + color: var(--color-success); +} - .selectedValue { - flex: 1; - display: flex; - align-items: center; - gap: 8px; - font-size: 0.95rem; - font-weight: 600; - color: var(--color-text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } +.chainReject { + color: var(--color-danger); } -.selectArrow { - opacity: 0.5; +.num { + font-size: 0.75rem; color: var(--color-text-secondary); - flex-shrink: 0; + font-variant-numeric: tabular-nums; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.sortDirBtn { - background: var(--color-bg-card); - border: 1px solid var(--color-bg-card-border, rgba(0, 0, 0, 0.05)); +.speed { + font-size: 0.76rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.speedDl { + color: var(--color-success); +} + +.speedUl { color: var(--color-focus-blue); +} + +.speedDim { + color: var(--color-text-secondary); + opacity: 0.55; +} + +/* ---------- 空态 ---------- */ + +.empty { display: flex; + flex-direction: column; align-items: center; justify-content: center; - width: 38px; - height: 38px; - border-radius: 12px; - cursor: pointer; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); - transition: all 0.2s ease; + gap: 6px; + height: 100%; +} - &:active { - transform: scale(0.95); - background: var(--color-bg-secondary); - } +.emptyTitle { + font-size: 0.88rem; + color: var(--color-text); } -.table thead tr:first-child th:first-child { - border-top-left-radius: 12px; +.emptyHint { + font-size: 0.78rem; + color: var(--color-text-secondary); } -.table thead tr:first-child th:last-child { - border-top-right-radius: 12px; +/* ---------- 页脚 ---------- */ + +.footer { + display: flex; + align-items: center; + gap: 14px; + height: 36px; + flex-shrink: 0; + padding: 0 18px; + border-top: 1px solid var(--color-card-border); + background: var(--color-track); + font-size: 0.72rem; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; } -.table tbody tr:last-child td:first-child { - border-bottom-left-radius: 12px; +.footerNote { + margin-left: auto; + overflow: hidden; + text-overflow: ellipsis; + + @media (max-width: 768px) { + display: none; + } } -.table tbody tr:last-child td:last-child { - border-bottom-right-radius: 12px; +/* ---------- 移动端排序工具条 ---------- */ + +.mobileToolbar { + display: flex; + align-items: center; + gap: 8px; + height: 42px; + flex-shrink: 0; } -.td.odd { - background: var(--color-row-odd); +.sortSelect { + position: relative; + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + height: 34px; + padding: 0 10px; + border-radius: 10px; + background: var(--color-card); + border: 1px solid var(--color-card-border); + color: var(--color-text); + font-size: 0.82rem; + + span { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + select { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + } } -/* download upload td cells */ -.center { - min-width: 7em; - text-align: center; +.sortSelectArrow { + flex-shrink: 0; + color: var(--color-text-secondary); } -.sortIconContainer { - display: inline-flex; +.sortDirBtn { + display: flex; align-items: center; justify-content: center; - margin-left: 4px; - width: 16px; - height: 16px; - opacity: 0.7; -} - -.rotate180 { - transform: rotate(180deg); + width: 34px; + height: 34px; + flex-shrink: 0; + border-radius: 10px; + border: 1px solid var(--color-card-border); + background: var(--color-card); + color: var(--color-focus-blue); + cursor: pointer; } diff --git a/src/components/connections/ConnectionTable.scss b/src/components/connections/ConnectionTable.scss deleted file mode 100644 index 1559fb5..0000000 --- a/src/components/connections/ConnectionTable.scss +++ /dev/null @@ -1,84 +0,0 @@ -.connections-table { - .ctrl { - min-width: 3.5em; - text-align: center; - display: flex; - justify-content: center; - align-items: center; - - svg { - height: 16px; - width: 16px; - opacity: 0.6; - transition: all 0.2s ease; - cursor: pointer; - - &:hover { - opacity: 1; - color: #e74c3c; - transform: scale(1.1); - } - } - } - - .type { - min-width: 8em; - text-align: center; - - // 类型标签样式 - &::before { - content: ''; - } - } - - .start, - .downloadSpeedCurr, - .uploadSpeedCurr, - .download, - .upload { - min-width: 7em; - text-align: center; - font-variant-numeric: tabular-nums; - } - - .downloadSpeedCurr, - .download { - color: #27ae60; - } - - .uploadSpeedCurr, - .upload { - color: #3498db; - } - - // 进程列 - .process { - max-width: 12em; - overflow: hidden; - text-overflow: ellipsis; - } - - // 域名/Host列 - .host { - max-width: 20em; - overflow: hidden; - text-overflow: ellipsis; - } - - // 规则列 - .rule { - max-width: 15em; - overflow: hidden; - text-overflow: ellipsis; - } - - // 节点链 - .chains { - // 不截断:完整展示节点链(必要时允许换行) - max-width: none; - overflow: visible; - text-overflow: unset; - white-space: nowrap; - word-break: break-word; - } -} diff --git a/src/components/connections/ConnectionTable.tsx b/src/components/connections/ConnectionTable.tsx index 80bed4f..f62972b 100644 --- a/src/components/connections/ConnectionTable.tsx +++ b/src/components/connections/ConnectionTable.tsx @@ -1,378 +1,518 @@ -import './ConnectionTable.scss'; - -import { - ColumnDef, - getCoreRowModel, - getSortedRowModel, - SortingState, - useReactTable, - VisibilityState, -} from '@tanstack/react-table'; import cx from 'clsx'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { List as VirtualList, RowComponentProps } from 'react-window'; +import { RowComponentProps, List as VirtualList } from 'react-window'; -import * as connAPI from '~/api/connections'; -import { ArrowDown, ArrowUp, ChevronDown, Sliders, XCircle } from '~/components/shared/FeatherIcons'; +import { ArrowDown, ArrowUp, ChevronDown, Sliders, X } from '~/components/shared/FeatherIcons'; import prettyBytes from '~/misc/pretty-bytes'; -import { formatElapsed, getDateFnsLocale } from '~/modules/connections/utils'; +import { useElementWidth } from '~/modules/connections/hooks'; +import { + ConnectionColumn, + formatElapsed, + getDateFnsLocale, + SortState, +} from '~/modules/connections/utils'; import { FormattedConn } from '~/store/connections'; - import ConnectionCard from './ConnectionCard'; +import ConnectionDetailModal from './ConnectionDetailModal'; import s from './ConnectionTable.module.scss'; -import MOdalCloseConnection from './ModalCloseAllConnections'; -import ModalConnectionDetails from './ModalConnectionDetails'; -const sortById = { id: 'id', desc: true }; +const ROW_HEIGHT = 44; +const CARD_HEIGHT = 120; +/** 纵向滚动条宽度,见 main.scss */ +const SCROLLBAR_SIZE = 8; +// 列间距和行内边距由 JS 施加而非写在 scss 里:computeWidths 必须把它们算进可用 +// 宽度,两处一旦不同步表格就会横向溢出 +const COLUMN_GAP = 10; +const ROW_PADDING_X = 12; -const COLUMN_WIDTHS = { - ctrl: 50, - start: 100, - type: 120, - host: 300, - rule: 200, - chains: 250, - download: 100, - upload: 100, - downloadSpeedCurr: 100, - uploadSpeedCurr: 100, - source: 170, - destinationIP: 170, - process: 130, - sniffHost: 150, -}; +/** + * 按 grow 把剩余宽度分给流式列,返回每列实际像素宽和整行宽度。 + * + * 分配受 max 约束:吃满上限的列退出分配、把余量让给还有余地的列,循环到分完为止。 + * 全部吃满后剩下的空间就留白,不再无限撑宽那几列。 + */ +function computeWidths(columns: ConnectionColumn[], containerWidth: number) { + const chrome = Math.max(0, columns.length - 1) * COLUMN_GAP + ROW_PADDING_X * 2; + const widths = columns.map((c) => c.width); + const minTotal = widths.reduce((sum, w) => sum + w, 0); + // 列宽之外还要放下 gap 和 padding,可用于列的空间要先把它们扣掉 + const contentSpace = Math.max(0, containerWidth - SCROLLBAR_SIZE - chrome); + + let extra = contentSpace - minTotal; + const pool = new Set(columns.map((c, i) => i).filter((i) => (columns[i].grow ?? 0) > 0)); -const TOTAL_WIDTH = Object.values(COLUMN_WIDTHS).reduce((a, b) => a + b, 0); + while (extra > 0.5 && pool.size > 0) { + const growTotal = [...pool].reduce((sum, i) => sum + (columns[i].grow ?? 0), 0); + const saturated: number[] = []; + let consumed = 0; -const getColumnStyle = (columnId: string) => { - const width = COLUMN_WIDTHS[columnId] || 100; - const style: React.CSSProperties = { - width, - minWidth: width, - flex: `0 0 ${width}px`, - flexShrink: 0, - }; + for (const i of pool) { + const share = (extra * (columns[i].grow ?? 0)) / growTotal; + const room = (columns[i].max ?? Infinity) - widths[i]; + const add = Math.min(share, room); + widths[i] += add; + consumed += add; + if (add < share) saturated.push(i); + } - if (['download', 'upload', 'downloadSpeedCurr', 'uploadSpeedCurr', 'start'].includes(columnId)) { - style.justifyContent = 'flex-end'; + extra -= consumed; + if (consumed <= 0.5) break; + for (const i of saturated) pool.delete(i); } - if (columnId === 'ctrl') { - style.justifyContent = 'center'; + const columnsTotal = widths.reduce((sum, w) => sum + w, 0); + // 列没占满时行仍然铺满容器,这样 hover 背景和分隔线不会只画一半 + const tableWidth = Math.max(columnsTotal + chrome, containerWidth - SCROLLBAR_SIZE); + return { widths, tableWidth }; +} + +function Cell({ + column, + conn, + isClosed, + fullChain, + locale, + onClose, +}: { + column: ConnectionColumn; + conn: FormattedConn; + isClosed: boolean; + fullChain: boolean; + locale: ReturnType<typeof getDateFnsLocale>; + onClose: (id: string, e: React.MouseEvent) => void; +}) { + switch (column.kind) { + case 'ctrl': + return ( + <button + type="button" + className={s.closeBtn} + onClick={(e) => onClose(conn.id, e)} + title="close" + > + <X size={13} /> + </button> + ); + + case 'host': { + const busy = !isClosed && (conn.downloadSpeedCurr ?? 0) + (conn.uploadSpeedCurr ?? 0) > 0; + return ( + <> + <span className={cx(s.dot, { [s.dotBusy]: busy, [s.dotIdle]: !busy })} aria-hidden /> + <span className={s.hostText} title={conn.host}> + {conn.host} + </span> + </> + ); + } + + case 'chip': { + if (column.id === 'type') { + const udp = conn.network === 'udp'; + return ( + <span className={cx(s.chip, udp ? s.chipUdp : s.chipTcp)} title={conn.type}> + {conn.type} + </span> + ); + } + const value = String((conn as any)[column.id] ?? ''); + return ( + <span className={cx(s.chip, s.chipNeutral)} title={value}> + {value} + </span> + ); + } + + case 'chain': { + if (fullChain) { + return ( + <span className={s.chainFull} title={conn.chainsFull}> + {conn.chainsFull} + </span> + ); + } + return ( + <> + {conn.chainGroup ? ( + <> + <span className={s.chainGroup} title={conn.chainGroup}> + {conn.chainGroup} + </span> + <span className={s.chainSep} aria-hidden> + › + </span> + </> + ) : null} + <span + className={cx(s.chainNode, { + [s.chainDirect]: conn.outboundType === 'Direct', + [s.chainReject]: conn.outboundType === 'Reject', + })} + title={conn.chainNode} + > + {conn.chainNode} + </span> + </> + ); + } + + default: { + switch (column.id) { + case 'start': + return ( + <span className={s.num}>{isClosed ? '—' : formatElapsed(conn.start, locale)}</span> + ); + case 'download': + case 'upload': + return <span className={s.num}>{prettyBytes((conn as any)[column.id])}</span>; + case 'downloadSpeedCurr': + case 'uploadSpeedCurr': { + const speed = (conn as any)[column.id] as number; + const dim = isClosed || speed < 1; + return ( + <span + className={cx(s.speed, { + [s.speedDim]: dim, + [s.speedDl]: !dim && column.id === 'downloadSpeedCurr', + [s.speedUl]: !dim && column.id === 'uploadSpeedCurr', + })} + > + {isClosed ? '—' : `${prettyBytes(speed)}/s`} + </span> + ); + } + case 'network': + return <span className={s.num}>{conn.network.toUpperCase()}</span>; + default: { + const value = String((conn as any)[column.id] ?? ''); + return ( + <span className={s.num} title={value}> + {value} + </span> + ); + } + } + } } +} - return style; +type RowProps = { + rows: FormattedConn[]; + columns: ConnectionColumn[]; + widths: number[]; + tableWidth: number; + gridTemplate: string; + isClosed: boolean; + fullChain: boolean; + locale: ReturnType<typeof getDateFnsLocale>; + onClose: (id: string, e: React.MouseEvent) => void; + onOpenDetails: (conn: FormattedConn) => void; }; -function Table({ data, columns, hiddenColumns, apiConfig, height }) { - const { t, i18n } = useTranslation(); - const [operationId, setOperationId] = useState(''); - const [showModalDisconnect, setShowModalDisconnect] = useState(false); - const [selectedConn, setSelectedConn] = useState<FormattedConn | null>(null); +function DesktopRow({ + index, + style, + rows, + columns, + tableWidth, + gridTemplate, + isClosed, + fullChain, + locale, + onClose, + onOpenDetails, +}: RowComponentProps<RowProps>) { + const conn = rows[index]; + + return ( + <div style={{ ...style, width: tableWidth }} className={s.rowWrap}> + <div + className={s.row} + style={{ + gridTemplateColumns: gridTemplate, + columnGap: COLUMN_GAP, + paddingLeft: ROW_PADDING_X, + paddingRight: ROW_PADDING_X, + }} + onClick={() => onOpenDetails(conn)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpenDetails(conn); + } + }} + > + {columns.map((column) => ( + <div key={column.id} className={cx(s.cell, { [s.cellRight]: column.align === 'right' })}> + <Cell + column={column} + conn={conn} + isClosed={isClosed} + fullChain={fullChain} + locale={locale} + onClose={onClose} + /> + </div> + ))} + </div> + </div> + ); +} - const [isMobile, setIsMobile] = useState(false); +function MobileRow({ index, style, rows, onClose, onOpenDetails }: RowComponentProps<RowProps>) { + const conn = rows[index]; + return ( + <div style={style}> + <ConnectionCard conn={conn} onDisconnect={onClose} onClick={() => onOpenDetails(conn)} /> + </div> + ); +} - const headerRef = React.useRef<HTMLDivElement>(null); +type Props = { + data: FormattedConn[]; + totalCount: number; + columns: ConnectionColumn[]; + sort: SortState; + setSort: (key: string) => void; + isClosed: boolean; + fullChain: boolean; + onCloseConn: (id: string) => void; +}; + +export default function ConnectionTable({ + data, + totalCount, + columns, + sort, + setSort, + isClosed, + fullChain, + onCloseConn, +}: Props) { + const { t, i18n } = useTranslation(); + // 只存 id,每次渲染从实时 data 里取最新对象,弹窗里的流量/时长才能实时更新 + const [detailId, setDetailId] = React.useState<string | null>(null); + const [isMobile, setIsMobile] = React.useState(false); + const [containerRef, containerWidth] = useElementWidth<HTMLDivElement>(); + const headRef = React.useRef<HTMLDivElement>(null); - useEffect(() => { + React.useEffect(() => { const mql = window.matchMedia('(max-width: 768px)'); setIsMobile(mql.matches); - const listener = (e) => setIsMobile(e.matches); + const listener = (e: MediaQueryListEvent) => setIsMobile(e.matches); mql.addEventListener('change', listener); return () => mql.removeEventListener('change', listener); }, []); - // react-table v8 列定义:从项目内部的 ConnectionColumn 形态映射而来 - const columnDefs = useMemo<ColumnDef<FormattedConn>[]>( - () => - columns.map((c) => ({ - id: c.accessor, - accessorFn: (row: FormattedConn) => (row as any)[c.accessor], - header: c.Header ?? c.accessor, - enableSorting: c.accessor !== 'ctrl', - sortDescFirst: c.sortDescFirst ?? false, - })), - [columns] - ); - - // 从本地存储加载排序状态(v7 sortBy 与 v8 SortingState 形态一致,可直接复用) - const [sorting, setSorting] = useState<SortingState>(() => { - return JSON.parse(localStorage.getItem('tableSortBy')) || [sortById]; - }); + const locale = getDateFnsLocale(i18n.language); - // hiddenColumns 为需隐藏的列 id 列表,转换为 v8 的可见性映射 - const columnVisibility = useMemo<VisibilityState>( - () => Object.fromEntries(hiddenColumns.map((id: string) => [id, false])), - [hiddenColumns] + const { widths, tableWidth } = React.useMemo( + () => computeWidths(columns, containerWidth), + [columns, containerWidth], ); + const gridTemplate = React.useMemo(() => widths.map((w) => `${w}px`).join(' '), [widths]); - const table = useReactTable({ - data, - columns: columnDefs, - state: { sorting, columnVisibility }, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - autoResetAll: false, - }); - - const rows = table.getRowModel().rows; - - const sortOptions = useMemo(() => { - return columns - .filter((c) => c.accessor !== 'id' && c.accessor !== 'ctrl') - .map((c) => ({ - label: t(c.Header), - value: c.accessor, - })); - }, [columns, t]); + const handleClose = React.useCallback( + (id: string, e: React.MouseEvent) => { + e.stopPropagation(); + onCloseConn(id); + }, + [onCloseConn], + ); - const currentSort = sorting[0] || sortById; + const handleOpenDetails = React.useCallback((conn: FormattedConn) => { + setDetailId(conn.id); + }, []); - const locale = getDateFnsLocale(i18n.language); + const handleCloseDetails = React.useCallback(() => { + setDetailId(null); + }, []); - const disconnectOperation = useCallback(() => { - connAPI.closeConnById(apiConfig, operationId); - setShowModalDisconnect(false); - }, [apiConfig, operationId]); + // 详情里的连接被关闭/移出列表(如切换标签、筛选变化)时自动关闭弹窗 + const detailConn = detailId ? (data.find((c) => c.id === detailId) ?? null) : null; + React.useEffect(() => { + if (detailId && !detailConn) setDetailId(null); + }, [detailId, detailConn]); - const handlerDisconnect = useCallback((id, e) => { - e.stopPropagation(); - setOperationId(id); - setShowModalDisconnect(true); - }, []); + // Esc 关闭详情 + React.useEffect(() => { + if (!detailConn) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setDetailId(null); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [detailConn]); - const renderCell = useCallback( - (cell, locale) => { - switch (cell.column.id) { - case 'ctrl': - return ( - <XCircle - style={{ cursor: 'pointer' }} - onClick={(e) => handlerDisconnect(cell.row.original.id, e)} - ></XCircle> - ); - case 'start': - return formatElapsed(cell.getValue(), locale); - case 'download': - case 'upload': - return prettyBytes(cell.getValue()); - case 'downloadSpeedCurr': - case 'uploadSpeedCurr': - return prettyBytes(cell.getValue()) + '/s'; - default: - return cell.getValue(); - } - }, - [handlerDisconnect] + const rowProps = React.useMemo<RowProps>( + () => ({ + rows: data, + columns, + widths, + tableWidth, + gridTemplate, + isClosed, + fullChain, + locale, + onClose: handleClose, + onOpenDetails: handleOpenDetails, + }), + [ + data, + columns, + widths, + tableWidth, + gridTemplate, + isClosed, + fullChain, + locale, + handleClose, + handleOpenDetails, + ], ); - // 当排序状态改变时,将新状态保存到本地存储 - useEffect(() => { - localStorage.setItem('tableSortBy', JSON.stringify(sorting)); - }, [sorting]); + const rowHeight = React.useCallback(() => { + return isMobile ? CARD_HEIGHT : ROW_HEIGHT; + }, [isMobile]); - const MobileRow = useCallback( - ({ index, style }: RowComponentProps) => { - const row = rows[index]; - const conn = row.original as FormattedConn; - return ( - <div style={style}> - <ConnectionCard - key={conn.id} - conn={conn} - onDisconnect={handlerDisconnect} - onClick={() => setSelectedConn(conn)} - /> - </div> - ); - }, - [rows, handlerDisconnect] - ); + const rowKey = React.useCallback((index: number, props: RowProps) => props.rows[index].id, []); - const DesktopRow = useCallback( - ({ index, style }: RowComponentProps) => { - const row = rows[index]; - return ( - <div - style={{ - ...style, - display: 'flex', - width: TOTAL_WIDTH, - }} - className={s.tr} - onClick={() => setSelectedConn(row.original as FormattedConn)} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setSelectedConn(row.original as FormattedConn); - } - }} - > - {row.getVisibleCells().map((cell) => { - const columnStyle = getColumnStyle(cell.column.id); - return ( - <div - key={cell.id} - className={cx(s.td, index % 2 === 0 ? s.odd : false, cell.column.id)} - style={{ - display: 'flex', - alignItems: 'center', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - ...columnStyle, - }} - > - <span className={s.cellText}>{renderCell(cell, locale)}</span> - </div> - ); - })} - </div> - ); - }, - [rows, renderCell, locale] + const syncHeadScroll = React.useCallback((e: React.UIEvent<HTMLDivElement>) => { + if (headRef.current) headRef.current.scrollLeft = e.currentTarget.scrollLeft; + }, []); + + const sortableColumns = React.useMemo( + () => columns.filter((c) => c.sortable !== false), + [columns], ); + const sortLabel = sortableColumns.find((c) => c.id === sort.key)?.labelKey; - const handleDesktopListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { - if (headerRef.current) { - headerRef.current.scrollLeft = e.currentTarget.scrollLeft; - } - }, []); + const empty = data.length === 0; return ( - <div className={s.tableWrapper} style={{ height, overflow: 'hidden' }}> - {isMobile ? ( - <div className={s.cardsView}> - <div className={s.mobileSortToolbar}> - <div className={s.sortSelectWrapper}> - <div className={s.selectedValue}> - <Sliders size={14} /> - <span> - {t('Sort')}: {sortOptions.find((opt) => opt.value === currentSort.id)?.label} - </span> - </div> - <select - value={currentSort.id} - onChange={(e) => setSorting([{ id: e.target.value, desc: currentSort.desc }])} - > - {sortOptions.map((opt) => ( - <option key={opt.value} value={opt.value}> - {opt.label} + <div className={s.card}> + <div className={s.body} ref={containerRef}> + {isMobile ? ( + <div className={s.mobileToolbar}> + <div className={s.sortSelect}> + <Sliders size={14} /> + <span> + {t('Sort')}: {sortLabel ? t(sortLabel) : ''} + </span> + <select value={sort.key} onChange={(e) => setSort(e.target.value)}> + {sortableColumns.map((column) => ( + <option key={column.id} value={column.id}> + {t(column.labelKey)} </option> ))} </select> - <ChevronDown size={14} className={s.selectArrow} /> + <ChevronDown size={14} className={s.sortSelectArrow} /> </div> <button + type="button" className={s.sortDirBtn} - onClick={() => setSorting([{ id: currentSort.id, desc: !currentSort.desc }])} + onClick={() => setSort(sort.key)} + aria-label={t(sort.dir === 'desc' ? 'sort_desc' : 'sort_asc')} > - {currentSort.desc ? <ArrowDown size={18} /> : <ArrowUp size={18} />} + {sort.dir === 'desc' ? <ArrowDown size={16} /> : <ArrowUp size={16} />} </button> </div> - <VirtualList - style={{ height: height - 50, width: '100%' }} - rowCount={rows.length} - rowHeight={120} - rowComponent={MobileRow} - rowProps={{}} - /> - </div> - ) : ( - <div - className={cx(s.table, 'connections-table')} - style={{ - display: 'flex', - flexDirection: 'column', - height: '100%', - width: '100%', - }} - > - <div - className={s.theadWrapper} - ref={headerRef} - style={{ overflow: 'hidden', width: '100%' }} - > - <div className={s.thead} style={{ width: TOTAL_WIDTH }}> - {table.getHeaderGroups().map((headerGroup) => ( - <div className={s.tr} key={headerGroup.id} style={{ display: 'flex' }}> - {headerGroup.headers.map((header) => { - const columnStyle = getColumnStyle(header.column.id); - const sortDir = header.column.getIsSorted(); - const canSort = header.column.getCanSort(); - const sortHandler = header.column.getToggleSortingHandler(); - return ( - <div - key={header.id} - className={s.th} - onClick={sortHandler} - onKeyDown={ - canSort - ? (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - sortHandler?.(e); - } - } - : undefined - } - role={canSort ? 'button' : undefined} - tabIndex={canSort ? 0 : undefined} - style={{ - display: 'flex', - alignItems: 'center', - cursor: canSort ? 'pointer' : 'default', - ...columnStyle, - }} - > - <span className={s.headerText}> - {t(header.column.columnDef.header as string)} - </span> - {header.column.id !== 'ctrl' ? ( - <span className={s.sortIconContainer}> - {sortDir ? ( - <ChevronDown - size={14} - className={sortDir === 'desc' ? '' : s.rotate180} - /> - ) : null} - </span> - ) : null} - </div> - ); - })} - </div> - ))} + ) : ( + <div className={s.headWrap} ref={headRef}> + <div + className={s.headRow} + style={{ + width: tableWidth, + gridTemplateColumns: gridTemplate, + columnGap: COLUMN_GAP, + paddingLeft: ROW_PADDING_X, + paddingRight: ROW_PADDING_X, + }} + role="row" + > + {columns.map((column) => { + const sortable = column.sortable !== false; + const active = sortable && sort.key === column.id; + return ( + // role 是条件表达式,oxlint 静态分析不出来 + // oxlint-disable-next-line jsx-a11y/no-static-element-interactions + <div + key={column.id} + className={cx(s.headCell, { + [s.cellRight]: column.align === 'right', + [s.headCellSortable]: sortable, + [s.headCellActive]: active, + })} + onClick={sortable ? () => setSort(column.id) : undefined} + onKeyDown={ + sortable + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSort(column.id); + } + } + : undefined + } + role={sortable ? 'button' : undefined} + tabIndex={sortable ? 0 : undefined} + > + <span className={s.headText}> + {column.kind === 'ctrl' ? '' : t(column.labelKey)} + </span> + {active ? ( + <span className={s.headArrow}>{sort.dir === 'desc' ? '↓' : '↑'}</span> + ) : null} + </div> + ); + })} </div> </div> - <VirtualList - style={{ height: height - 50, width: '100%' }} - onScroll={handleDesktopListScroll} - rowCount={rows.length} - rowHeight={44} - rowComponent={DesktopRow} - rowProps={{}} - /> + )} + + <div className={s.listWrap}> + {empty ? ( + <div className={s.empty}> + <span className={s.emptyTitle}>{t('conn_empty_title')}</span> + <span className={s.emptyHint}>{t('conn_empty_hint')}</span> + </div> + ) : ( + <VirtualList + className={isMobile ? s.mobileList : s.list} + style={{ height: '100%', width: '100%' }} + onScroll={isMobile ? undefined : syncHeadScroll} + rowCount={data.length} + rowHeight={rowHeight} + rowComponent={isMobile ? MobileRow : DesktopRow} + rowKey={rowKey} + rowProps={rowProps} + /> + )} </div> - )} - <MOdalCloseConnection - confirm={'disconnect'} - isOpen={showModalDisconnect} - onRequestClose={() => setShowModalDisconnect(false)} - primaryButtonOnTap={disconnectOperation} - ></MOdalCloseConnection> - <ModalConnectionDetails - isOpen={!!selectedConn} - onRequestClose={() => setSelectedConn(null)} - connection={selectedConn} - /> + </div> + + {detailConn ? ( + <ConnectionDetailModal conn={detailConn} onRequestClose={handleCloseDetails} /> + ) : null} + + <div className={s.footer}> + <span>{t('conn_shown', { shown: data.length, total: totalCount })}</span> + {sortLabel ? ( + <span> + {t('conn_sorted_by', { + column: t(sortLabel), + dir: t(sort.dir === 'desc' ? 'sort_desc' : 'sort_asc'), + })} + </span> + ) : null} + <span className={s.footerNote}> + {isClosed ? t('conn_note_closed') : t('conn_note_active')} + </span> + </div> </div> ); } - -export default Table; diff --git a/src/components/connections/Connections.css b/src/components/connections/Connections.css deleted file mode 100644 index a7bec45..0000000 --- a/src/components/connections/Connections.css +++ /dev/null @@ -1,71 +0,0 @@ -.react-tabs { - -webkit-tap-highlight-color: transparent; -} - -.react-tabs__tab-list { - margin: 0px; - padding: 0; - display: flex; - align-items: center; - gap: 4px; -} - -.react-tabs__tab { - display: inline-flex; - align-items: center; - border: none; - border-radius: 8px; - position: relative; - list-style: none; - padding: 8px 16px; - cursor: pointer; - font-size: 1em; - font-weight: 500; - opacity: 0.6; - background: transparent; - transition: all 0.2s ease; -} - -.react-tabs__tab:hover { - opacity: 1; - color: var(--color-focus-blue); - background: rgba(176, 206, 255, 0.221); -} - -.react-tabs__tab--selected { - opacity: 1; - background: var(--color-focus-blue); - color: white; -} - -.react-tabs__tab--selected:hover { - background: var(--color-focus-blue); - opacity: 1; -} - -.react-tabs__tab--disabled { - color: GrayText; - cursor: default; -} - -.react-tabs__tab:focus { - outline: none; - box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.3); -} - -.react-tabs__tab:focus:after { - content: ''; - position: absolute; -} - -.react-tabs__tab-panel { - display: none; -} - -.react-tabs__tab-panel--selected { - display: block; -} - -._btn_lzu00_1 { - margin-right: 10px; -} diff --git a/src/components/connections/Connections.module.scss b/src/components/connections/Connections.module.scss index 918a66c..7e65ab2 100644 --- a/src/components/connections/Connections.module.scss +++ b/src/components/connections/Connections.module.scss @@ -1,227 +1,22 @@ -.placeHolder { - margin-top: 15%; - height: 100%; +.page { display: flex; flex-direction: column; - align-items: center; - justify-content: center; - color: var(--color-text-secondary); - opacity: 0.15; - - @media (max-width: 768px) { - margin-top: 25%; - } -} - -.connQty { - font-family: var(--font-normal); - font-size: 0.7em; - margin-left: 6px; - padding: 2px 8px; - display: inline-flex; - justify-content: center; - align-items: center; - background-color: rgba(255, 255, 255, 0.15); - border-radius: 10px; - font-weight: 600; - min-width: 20px; -} - -.inputWrapper { - width: 100%; - max-width: 300px; - min-width: 0; - margin-left: auto; - - @media (max-width: 768px) { - max-width: none; - grid-column: 1 / -1; - margin-left: 0; - order: 3; - } -} - -.input { - -webkit-appearance: none; - appearance: none; - background-color: var(--color-input-bg); - background-image: none; - border-radius: 8px; - border: 1px solid transparent; - box-sizing: border-box; - color: var(--color-text); - display: inline-block; - font-size: 0.95em; - height: 40px; - outline: none; - padding: 0 16px; - transition: all 0.2s ease; - width: 100%; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); - - &::placeholder { - color: var(--color-text-secondary); - opacity: 0.6; - } - - &:focus { - border-color: var(--color-focus-blue); - box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.15); - } -} - -.toolbar { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; - padding: 2px; - border-radius: 10px; - - @media (max-width: 768px) { - grid-column: 2; - grid-row: 2; - justify-content: flex-end; - width: auto; - padding: 2px; - background: transparent; - } -} - -.toolbarBtn { - display: flex; - align-items: center; - justify-content: center; - position: relative; - width: 34px; - height: 34px; - border: none; - border-radius: 8px; - background-color: transparent; - color: var(--color-text-secondary); - cursor: pointer; - transition: all 0.2s ease; - - &:hover { - background-color: var(--color-focus-blue); - color: white; - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(66, 133, 244, 0.3); - } - - &:active { - transform: translateY(0) scale(0.95); - } -} - -.toolbarBtnBadge { - position: absolute; - top: -3px; - right: -3px; - font-size: 9px; - font-weight: 700; - background: linear-gradient(135deg, #e74c3c, #c0392b); - color: white; - border-radius: 50%; - width: 15px; - height: 15px; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 2px 4px rgba(231, 76, 60, 0.4); -} - -.controls { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - width: 100%; - min-width: 0; - - @media (max-width: 768px) { - display: grid; - grid-template-columns: 1fr auto; - gap: 8px; - padding: 4px 0; - } -} - -.tabGroup { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; - - @media (max-width: 768px) { - display: contents; - } + height: 100%; + min-height: 0; } -.tabList { - padding: 0 !important; - border: none !important; +.tableArea { display: flex; - align-items: center; - gap: 4px; - min-width: 0; - background-color: var(--color-bg-sidebar); - border-radius: 12px; - padding: 4px !important; - - @media (max-width: 768px) { - grid-column: 1 / -1; - justify-content: space-between; - width: 100%; + flex-direction: column; + flex: 1; + min-height: 0; + margin: 12px 32px 24px; - :global(.react-tabs__tab) { - flex: 1; - text-align: center; - justify-content: center; - padding: 6px 4px !important; - font-size: 0.9em; - } + @media (max-width: 1024px) { + margin: 12px 16px 16px; } -} - -.sourceSelect { - width: auto; - min-width: 130px; - height: 40px; - flex-shrink: 0; @media (max-width: 768px) { - width: 100% !important; - grid-column: 1; - height: 36px; + margin: 8px 12px 10px; } } - -.toolbarDivider { - width: 1px; - height: 20px; - background: var(--color-text-secondary); - opacity: 0.2; - margin: 0 2px; -} - -.contentWrapper { - margin: 0 45px 20px; - min-width: 0; - background-color: var(--bg-log-info-card); - border-radius: 12px; - box-shadow: var(--shadow-card); - border: 1px solid var(--color-separator); - overflow: hidden; - - @media (max-width: 768px) { - margin: 10px 15px 15px; - background-color: transparent; - border: none; - box-shadow: none; - } -} - -.scrollArea { - overflow: visible; -} diff --git a/src/components/connections/Connections.tsx b/src/components/connections/Connections.tsx index 9e19d33..9c60cc1 100644 --- a/src/components/connections/Connections.tsx +++ b/src/components/connections/Connections.tsx @@ -1,60 +1,26 @@ -import './Connections.css'; - -import React from 'react'; +import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { Tab, TabList, TabPanel, Tabs } from 'react-tabs'; -import * as connAPI from '~/api/connections'; -import ContentHeader from '~/components/ContentHeader'; -import Input from '~/components/Input'; -import { Fab, position as fabPosition } from '~/components/shared/Fab'; -import { Pause, Play, RefreshCcw, Settings, Tag, X as IconClose } from '~/components/shared/FeatherIcons'; -import Select from '~/components/shared/Select'; -import SvgYacd from '~/components/SvgYacd'; -import useRemainingViewPortHeight from '~/hooks/useRemainingViewPortHeight'; import { + useCloseConnections, useConnectionColumns, useConnectionFilters, + useConnectionSettings, useConnectionsStream, + useConnectionStats, useSourceMapState, } from '~/modules/connections/hooks'; -import { CONNECTIONS_PADDING_BOTTOM } from '~/modules/connections/utils'; -import { FormattedConn } from '~/store/connections'; +import { getInitialSort, saveSort, sortConns, SortState } from '~/modules/connections/utils'; import { ClashAPIConfig } from '~/types'; import s from './Connections.module.scss'; +import ConnectionSettingsModal from './ConnectionSettingsModal'; +import { ConnectionsHeader, ConnTabKey } from './ConnectionsHeader'; +import { ConnectionStats } from './ConnectionStats'; import ConnectionTable from './ConnectionTable'; import ModalCloseAllConnections from './ModalCloseAllConnections'; -import ModalManageConnectionColumns from './ModalManageConnectionColumns'; -import ModalSourceIP from './ModalSourceIP'; - -const { useState, useCallback } = React; -function renderTableOrPlaceholder( - columns, - hiddenColumns, - conns: FormattedConn[], - height: number, - apiConfig: ClashAPIConfig -) { - return conns.length > 0 ? ( - <ConnectionTable - data={conns} - columns={columns} - hiddenColumns={hiddenColumns} - height={height} - apiConfig={apiConfig} - /> - ) : ( - <div className={s.placeHolder}> - <SvgYacd width={200} height={200} c1="var(--color-text)" /> - </div> - ); -} - -function ConnQty({ qty }) { - return qty < 100 ? '' + qty : '99+'; -} +const { useCallback, useState } = React; type Props = { apiConfig: ClashAPIConfig; @@ -62,19 +28,23 @@ type Props = { export default function Connections({ apiConfig }: Props) { const { t } = useTranslation(); - const [showModalColumn, setModalColumn] = useState(false); - const { hiddenColumns, setHiddenColumns, columns, setColumns, resetColumns } = - useConnectionColumns(); + const [activeTab, setActiveTab] = useState<ConnTabKey>('active'); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const closeModalColumn = () => { - setModalColumn(false); - }; + const { sourceMap, setSourceMap } = useSourceMapState(); + const { settings, updateSettings } = useConnectionSettings(); + const { + visibleColumns, + availableColumns, + addColumn, + removeColumn, + reorderColumns, + resetColumns, + } = useConnectionColumns(); - const { sourceMapModal, sourceMap, setSourceMap, openModalSource, closeModalSource } = - useSourceMapState(); - const [refContainer, containerHeight] = useRemainingViewPortHeight(); - const { conns, closedConns, isRefreshPaused, toggleIsRefreshPaused, closeAllConnections } = + const { conns, closedConns, total, isRefreshPaused, toggleIsRefreshPaused, closeAllConnections } = useConnectionsStream(apiConfig, sourceMap); + const { filterKeyword, setFilterKeyword, @@ -83,158 +53,103 @@ export default function Connections({ apiConfig }: Props) { filteredConns, filteredClosedConns, connIpSet, - } = useConnectionFilters({ conns, closedConns, sourceMap, t }); + isFiltering, + } = useConnectionFilters({ conns, closedConns, sourceMap, settings, t }); + + const stats = useConnectionStats(conns, total); + + const [sort, setSortState] = useState<SortState>(() => getInitialSort()); + // 点同一列切换升降序,点别的列换列并回到升序 + const setSort = useCallback((key: string) => { + setSortState((prev) => { + const next: SortState = + prev.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' }; + saveSort(next); + return next; + }); + }, []); - const [isCloseFilterModalOpen, setIsCloseFilterModalOpen] = useState(false); - const openCloseFilterModal = useCallback(() => setIsCloseFilterModalOpen(true), []); - const closeCloseFilterModal = useCallback(() => setIsCloseFilterModalOpen(false), []); + const isClosedTab = activeTab === 'closed'; + const rows = isClosedTab ? filteredClosedConns : filteredConns; + const poolSize = isClosedTab ? closedConns.length : conns.length; + + const sortedRows = React.useMemo(() => sortConns(rows, sort), [rows, sort]); + + const { closeConn, closeConns } = useCloseConnections(apiConfig); - const closeFilterConnections = useCallback(async () => { - await Promise.allSettled( - filteredConns.map((connection) => connAPI.closeConnById(apiConfig, connection.id)) - ); - closeCloseFilterModal(); - }, [apiConfig, filteredConns, closeCloseFilterModal]); const [isCloseAllModalOpen, setIsCloseAllModalOpen] = useState(false); - const openCloseAllModal = useCallback(() => setIsCloseAllModalOpen(true), []); - const closeCloseAllModal = useCallback(() => setIsCloseAllModalOpen(false), []); - const handleCloseAllConnections = useCallback(() => { + const [isCloseFilteredModalOpen, setIsCloseFilteredModalOpen] = useState(false); + + const handleCloseAll = useCallback(() => { closeAllConnections(); - closeCloseAllModal(); - }, [closeAllConnections, closeCloseAllModal]); + setIsCloseAllModalOpen(false); + }, [closeAllConnections]); + + const handleCloseFiltered = useCallback(async () => { + await closeConns(filteredConns); + setIsCloseFilteredModalOpen(false); + }, [closeConns, filteredConns]); return ( - <div> - <Tabs> - <ContentHeader> - <div className={s.controls}> - <div className={s.tabGroup}> - <TabList className={s.tabList}> - <Tab> - <span>{t('Active')}</span> - <span className={s.connQty}> - <ConnQty qty={filteredConns.length} /> - </span> - </Tab> - <Tab> - <span>{t('Closed')}</span> - <span className={s.connQty}> - <ConnQty qty={filteredClosedConns.length} /> - </span> - </Tab> - </TabList> - <Select - options={connIpSet} - selected={filterSourceIpStr} - className={s.sourceSelect} - onChange={(e) => setFilterSourceIpStr(e.target.value)} - /> - </div> - <div style={{ flex: 1 }} /> - <div className={s.inputWrapper}> - <Input - type="text" - name="filter" - autoComplete="off" - className={s.input} - value={filterKeyword} - placeholder={t('Search')} - onChange={(e) => setFilterKeyword(e.target.value)} - /> - </div> - <div className={s.toolbar}> - <button - className={s.toolbarBtn} - onClick={openCloseAllModal} - title={t('close_all_connections')} - > - <IconClose size={15} /> - </button> - <button - className={s.toolbarBtn} - onClick={openCloseFilterModal} - title={t('close_filter_connections')} - > - <IconClose size={13} /> - <span className={s.toolbarBtnBadge}>F</span> - </button> - <span className={s.toolbarDivider} /> - <button - className={s.toolbarBtn} - onClick={() => setModalColumn(true)} - title={t('manage_column')} - > - <Settings size={15} /> - </button> - <button className={s.toolbarBtn} onClick={resetColumns} title={t('reset_column')}> - <RefreshCcw size={15} /> - </button> - <button className={s.toolbarBtn} onClick={openModalSource} title={t('client_tag')}> - <Tag size={15} /> - </button> - </div> - </div> - </ContentHeader> - <div ref={refContainer} className={s.contentWrapper}> - <div - className={s.scrollArea} - style={{ - height: containerHeight - CONNECTIONS_PADDING_BOTTOM, - }} - > - <TabPanel> - {renderTableOrPlaceholder( - columns, - hiddenColumns, - filteredConns, - containerHeight - CONNECTIONS_PADDING_BOTTOM, - apiConfig - )} - <Fab - icon={isRefreshPaused ? <Play size={16} /> : <Pause size={16} />} - mainButtonStyles={isRefreshPaused ? { background: '#e74c3c' } : {}} - style={fabPosition} - text={isRefreshPaused ? t('Resume Refresh') : t('Pause Refresh')} - onClick={toggleIsRefreshPaused} - /> - </TabPanel> - <TabPanel> - {renderTableOrPlaceholder( - columns, - hiddenColumns, - filteredClosedConns, - containerHeight - CONNECTIONS_PADDING_BOTTOM, - apiConfig - )} - </TabPanel> - </div> - </div> - <ModalCloseAllConnections - isOpen={isCloseAllModalOpen} - primaryButtonOnTap={handleCloseAllConnections} - onRequestClose={closeCloseAllModal} - /> - <ModalCloseAllConnections - confirm={'close_filter_connections'} - isOpen={isCloseFilterModalOpen} - primaryButtonOnTap={closeFilterConnections} - onRequestClose={closeCloseFilterModal} - /> - <ModalManageConnectionColumns - isOpen={showModalColumn} - onRequestClose={closeModalColumn} - columns={columns} - hiddenColumns={hiddenColumns} - setColumns={setColumns} - setHiddenColumns={setHiddenColumns} - /> - <ModalSourceIP - isOpen={sourceMapModal} - onRequestClose={closeModalSource} - sourceMap={sourceMap} - setSourceMap={setSourceMap} + <div className={s.page}> + <ConnectionsHeader + activeTab={activeTab} + setActiveTab={setActiveTab} + activeCount={filteredConns.length} + closedCount={filteredClosedConns.length} + connIpSet={connIpSet} + filterSourceIpStr={filterSourceIpStr} + setFilterSourceIpStr={setFilterSourceIpStr} + filterKeyword={filterKeyword} + setFilterKeyword={setFilterKeyword} + isRefreshPaused={isRefreshPaused} + toggleIsRefreshPaused={toggleIsRefreshPaused} + isFiltering={isFiltering} + onCloseFiltered={() => setIsCloseFilteredModalOpen(true)} + onCloseAll={() => setIsCloseAllModalOpen(true)} + onOpenSettings={() => setIsSettingsOpen(true)} + /> + + <ConnectionStats {...stats} /> + + <div className={s.tableArea}> + <ConnectionTable + data={sortedRows} + totalCount={poolSize} + columns={visibleColumns} + sort={sort} + setSort={setSort} + isClosed={isClosedTab} + fullChain={settings.fullChain} + onCloseConn={closeConn} /> - </Tabs> + </div> + + <ModalCloseAllConnections + isOpen={isCloseAllModalOpen} + primaryButtonOnTap={handleCloseAll} + onRequestClose={() => setIsCloseAllModalOpen(false)} + /> + <ModalCloseAllConnections + confirm={'close_filter_connections'} + isOpen={isCloseFilteredModalOpen} + primaryButtonOnTap={handleCloseFiltered} + onRequestClose={() => setIsCloseFilteredModalOpen(false)} + /> + <ConnectionSettingsModal + isOpen={isSettingsOpen} + onRequestClose={() => setIsSettingsOpen(false)} + settings={settings} + updateSettings={updateSettings} + visibleColumns={visibleColumns} + availableColumns={availableColumns} + addColumn={addColumn} + removeColumn={removeColumn} + reorderColumns={reorderColumns} + resetColumns={resetColumns} + sourceMap={sourceMap} + setSourceMap={setSourceMap} + /> </div> ); } diff --git a/src/components/connections/ConnectionsHeader.module.scss b/src/components/connections/ConnectionsHeader.module.scss new file mode 100644 index 0000000..3b71761 --- /dev/null +++ b/src/components/connections/ConnectionsHeader.module.scss @@ -0,0 +1,64 @@ +/** + * 只放连接页特有的东西,顶栏本身的样式在 shared/PageHeader.module.scss。 + * + * DOM 顺序就是桌面端的视觉顺序(标题 · 标签 · 来源 · 搜索 · 操作), + * 窄屏要把操作钮提到第一行、搜索和来源并排到第二行,所以这里只拨 order。 + */ + +.sourceSelect { + min-width: 132px; + max-width: 190px; + + @media (max-width: 1024px) { + flex: 1 1 auto; + max-width: none; + } + + @media (max-width: 768px) { + order: 4; + flex: 0 0 auto; + min-width: 96px; + max-width: 130px; + } +} + +.search { + @media (max-width: 768px) { + order: 3; + } +} + +.actions { + @media (max-width: 768px) { + order: 1; + } +} + +.rowBreak { + @media (max-width: 768px) { + order: 2; + } +} + +/** 「关闭筛选结果」的图标,右上角挂一个 F 角标以区别于「全部关闭」 */ +.btnIconBadged { + position: relative; + display: inline-flex; + align-items: center; +} + +.btnBadge { + position: absolute; + top: -5px; + right: -6px; + min-width: 12px; + height: 12px; + padding: 0 2px; + border-radius: 6px; + background: var(--color-danger); + color: #fff; + font-size: 8px; + font-weight: 700; + line-height: 12px; + text-align: center; +} diff --git a/src/components/connections/ConnectionsHeader.tsx b/src/components/connections/ConnectionsHeader.tsx new file mode 100644 index 0000000..b4845e8 --- /dev/null +++ b/src/components/connections/ConnectionsHeader.tsx @@ -0,0 +1,136 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Pause, Play, Sliders, X } from '~/components/shared/FeatherIcons'; +import { + HeaderActions, + HeaderButton, + HeaderIconButton, + HeaderRowBreak, + HeaderSearch, + headerSearchInlineClass, + headerSelectClass, + HeaderTab, + HeaderTabs, + HeaderTitle, + PageHeader, +} from '~/components/shared/PageHeader'; +import Select from '~/components/shared/Select'; + +import s from './ConnectionsHeader.module.scss'; + +export type ConnTabKey = 'active' | 'closed'; + +type Props = { + activeTab: ConnTabKey; + setActiveTab: (tab: ConnTabKey) => void; + activeCount: number; + closedCount: number; + connIpSet: string[][]; + filterSourceIpStr: string; + setFilterSourceIpStr: (value: string) => void; + filterKeyword: string; + setFilterKeyword: (value: string) => void; + isRefreshPaused: boolean; + toggleIsRefreshPaused: () => void; + isFiltering: boolean; + onCloseFiltered: () => void; + onCloseAll: () => void; + onOpenSettings: () => void; +}; + +export function ConnectionsHeader({ + activeTab, + setActiveTab, + activeCount, + closedCount, + connIpSet, + filterSourceIpStr, + setFilterSourceIpStr, + filterKeyword, + setFilterKeyword, + isRefreshPaused, + toggleIsRefreshPaused, + isFiltering, + onCloseFiltered, + onCloseAll, + onOpenSettings, +}: Props) { + const { t } = useTranslation(); + + return ( + <PageHeader> + <HeaderTitle>{t('Connections')}</HeaderTitle> + + <HeaderTabs label={t('Connections')}> + <HeaderTab + active={activeTab === 'active'} + label={t('Active')} + count={activeCount} + onClick={() => setActiveTab('active')} + /> + <HeaderTab + active={activeTab === 'closed'} + label={t('Closed')} + count={closedCount} + onClick={() => setActiveTab('closed')} + /> + </HeaderTabs> + + <Select + options={connIpSet} + selected={filterSourceIpStr} + className={`${headerSelectClass} ${s.sourceSelect}`} + aria-label={t('c_source')} + onChange={(e) => setFilterSourceIpStr(e.target.value)} + /> + + <HeaderSearch + className={`${headerSearchInlineClass} ${s.search}`} + value={filterKeyword} + onChange={setFilterKeyword} + placeholder={t('search_conns_placeholder')} + /> + + <HeaderActions className={s.actions}> + <HeaderButton + variant={isRefreshPaused ? 'paused' : 'ghost'} + icon={isRefreshPaused ? <Play size={14} /> : <Pause size={14} />} + label={isRefreshPaused ? t('Resume Refresh') : t('Pause Refresh')} + hideLabelAt="md" + onClick={toggleIsRefreshPaused} + /> + + {isFiltering ? ( + <HeaderButton + icon={ + <span className={s.btnIconBadged}> + <X size={14} /> + <span className={s.btnBadge}>F</span> + </span> + } + label={t('close_filtered')} + title={t('close_filter_connections')} + onClick={onCloseFiltered} + /> + ) : null} + + <HeaderButton + variant="danger" + icon={<X size={14} />} + label={t('close_all')} + onClick={onCloseAll} + /> + + <HeaderIconButton + icon={<Sliders size={17} />} + label={t('conn_settings')} + onClick={onOpenSettings} + /> + </HeaderActions> + + {/* 窄屏下强制换行,让搜索和来源筛选独占第二行 */} + <HeaderRowBreak className={s.rowBreak} /> + </PageHeader> + ); +} diff --git a/src/components/connections/ModalCloseAllConnections.module.scss b/src/components/connections/ModalCloseAllConnections.module.scss index 2b2befa..6fd4ea0 100644 --- a/src/components/connections/ModalCloseAllConnections.module.scss +++ b/src/components/connections/ModalCloseAllConnections.module.scss @@ -6,13 +6,6 @@ color: var(--color-text); max-width: 300px; line-height: 1.4; - transform: scale(1.2); - opacity: 0.6; - transition: all 0.3s ease; -} -.afterOpen { - opacity: 1; - transform: scale(1); } .btngrp { diff --git a/src/components/connections/ModalCloseAllConnections.tsx b/src/components/connections/ModalCloseAllConnections.tsx index 2c376bd..09e0f3d 100644 --- a/src/components/connections/ModalCloseAllConnections.tsx +++ b/src/components/connections/ModalCloseAllConnections.tsx @@ -1,14 +1,12 @@ -import cx from 'clsx'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import Button from '~/components/Button'; -import Modal from '~/components/Modal'; -import modalStyle from '~/components/Modal.module.scss'; +import Button from '~/components/shared/Button'; +import Modal from '~/components/shared/Modal'; import s from './ModalCloseAllConnections.module.scss'; -const { useRef, useCallback, useMemo } = React; +const { useRef, useCallback } = React; type Props = { confirm?: string; @@ -30,21 +28,14 @@ export default function Comp({ primaryButtonRef.current.focus(); } }, []); - const className = useMemo( - () => ({ - base: cx(modalStyle.content, s.cnt), - afterOpen: s.afterOpen, - beforeClose: '', - }), - [] - ); return ( <Modal isOpen={isOpen} onRequestClose={onRequestClose} onAfterOpen={onAfterOpen} - className={className} - overlayClassName={cx(modalStyle.overlay, s.overlay)} + title={t(confirm)} + className={s.cnt} + overlayClassName={s.overlay} > <p>{t(confirm)}</p> <div className={s.btngrp}> diff --git a/src/components/connections/ModalConnectionDetails.module.scss b/src/components/connections/ModalConnectionDetails.module.scss deleted file mode 100644 index 67834cd..0000000 --- a/src/components/connections/ModalConnectionDetails.module.scss +++ /dev/null @@ -1,49 +0,0 @@ -.content { - max-width: 600px; - width: 90%; - max-height: 90vh; - overflow-y: auto; - padding: 24px; - background-color: var(--bg-modal); - border-radius: 12px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); - border: 1px solid var(--color-border); -} - -.overlay { - background-color: rgba(0, 0, 0, 0.1) !important; - backdrop-filter: blur(2px); -} - -.details { - display: grid; - grid-template-columns: min-content 1fr; - gap: 12px 24px; - font-size: 14px; - line-height: 1.5; -} - -.label { - font-weight: 600; - text-align: right; - color: var(--color-text-secondary); - white-space: nowrap; -} - -.value { - color: var(--color-text-primary); - word-break: break-all; - font-family: 'Roboto Mono', Consolas, Menlo, monospace; -} - -.header { - font-size: 20px; - font-weight: 700; - margin-bottom: 24px; - padding-bottom: 12px; - border-bottom: 1px solid var(--color-border); - color: var(--color-text-primary); - display: flex; - justify-content: space-between; - align-items: center; -} diff --git a/src/components/connections/ModalConnectionDetails.tsx b/src/components/connections/ModalConnectionDetails.tsx deleted file mode 100644 index a65e16e..0000000 --- a/src/components/connections/ModalConnectionDetails.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import cx from 'clsx'; -import { formatDistance } from 'date-fns'; -import { enUS, zhCN, zhTW } from 'date-fns/locale'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - - -import Modal from '~/components/Modal'; -import modalStyle from '~/components/Modal.module.scss'; -import prettyBytes from '~/misc/pretty-bytes'; -import { FormattedConn } from '~/store/connections'; - -import s from './ModalConnectionDetails.module.scss'; - -type Props = { - isOpen: boolean; - onRequestClose: () => void; - connection?: FormattedConn; -}; - -export default function ModalConnectionDetails({ isOpen, onRequestClose, connection }: Props) { - const { t, i18n } = useTranslation(); - - let locale = enUS; - if (i18n.language === 'zh-CN') { - locale = zhCN; - } else if (i18n.language === 'zh-TW') { - locale = zhTW; - } - - if (!connection) return null; - - const rows = [ - { label: 'ID', value: connection.id }, - { label: 'Host', value: connection.host }, - { label: 'Sniff Host', value: connection.sniffHost }, - { label: 'Process', value: connection.process }, - { label: 'Destination', value: `${connection.destinationIP}:${connection.destinationPort}` }, - { label: 'Remote Destination', value: connection.remoteDestination }, - { label: 'Rule', value: connection.rule }, - { label: 'Chains', value: connection.chains }, - { label: 'Type', value: connection.type }, - { label: 'Network', value: connection.network }, - { label: 'Source', value: `${connection.sourceIP}:${connection.sourcePort}` }, - { label: 'Upload', value: prettyBytes(connection.upload) }, - { label: 'Download', value: prettyBytes(connection.download) }, - { - label: 'Start Time', - value: formatDistance(connection.start, 0, { locale }), - }, - ]; - - return ( - <Modal - isOpen={isOpen} - onRequestClose={onRequestClose} - className={cx(modalStyle.content, s.content)} - overlayClassName={cx(modalStyle.overlay, s.overlay)} - shouldCloseOnOverlayClick={true} - shouldCloseOnEsc={true} - > - <div className={s.header}>{t('Connection Details')}</div> - <div className={s.details}> - {rows.map((row) => ( - <React.Fragment key={row.label}> - <div className={s.label}>{row.label}:</div> - <div className={s.value}>{row.value || '-'}</div> - </React.Fragment> - ))} - </div> - </Modal> - ); -} diff --git a/src/components/connections/ModalManageConnectionColumns.module.scss b/src/components/connections/ModalManageConnectionColumns.module.scss deleted file mode 100644 index e0fd36b..0000000 --- a/src/components/connections/ModalManageConnectionColumns.module.scss +++ /dev/null @@ -1,61 +0,0 @@ -.columnManagerRow { - width: 280px; - display: flex; - margin: 4px 0; - padding: 8px 12px; - align-items: center; - background: var(--bg-near-transparent); - border-radius: 10px; - transition: all 0.2s ease; - - &:hover { - background: rgba(66, 133, 244, 0.1); - } - - .columnManageLabel { - flex: 1; - margin-left: 12px; - font-size: 0.95em; - font-weight: 500; - } - - .columnMoveButtons { - display: flex; - flex-direction: column; - margin-right: 8px; - gap: 2px; - } - - .moveBtn { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 16px; - padding: 0; - border: none; - border-radius: 4px; - background-color: transparent; - color: var(--color-text-secondary); - cursor: pointer; - transition: all 0.15s ease; - - &:hover:not(:disabled) { - background-color: var(--color-focus-blue); - color: white; - transform: scale(1.1); - } - - &:disabled { - opacity: 0.25; - cursor: not-allowed; - } - } - - .columnManageSwitch { - transform: scale(0.75); - height: 22px; - display: flex; - align-items: center; - } -} diff --git a/src/components/connections/ModalManageConnectionColumns.tsx b/src/components/connections/ModalManageConnectionColumns.tsx deleted file mode 100644 index e884569..0000000 --- a/src/components/connections/ModalManageConnectionColumns.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import BaseModal from '~/components/shared/BaseModal'; -import { ChevronDown, ChevronUp, Menu } from '~/components/shared/FeatherIcons'; -import Switch from '~/components/SwitchThemed'; -import { ConnectionColumn } from '~/modules/connections/utils'; - -import s from './ModalManageConnectionColumns.module.scss'; - -const getItemStyle = (isDragging, draggableStyle) => { - return { - ...draggableStyle, - ...(isDragging && { - background: 'transparent', - }), - }; -}; - -export default function ModalManageConnectionColumns({ - isOpen, - onRequestClose, - columns, - hiddenColumns, - setColumns, - setHiddenColumns, -}) { - const { t } = useTranslation(); - - const onDragEnd = (result) => { - if (!result.destination) { - return; - } - - const items = Array.from(columns); - const [removed] = items.splice(result.source.index, 1); - items.splice(result.destination.index, 0, removed); - setColumns(items); - }; - - const onShowChange = (column, val) => { - const nextHiddenColumns = !val - ? [...hiddenColumns, column.accessor] - : hiddenColumns.filter((accessor) => accessor !== column.accessor); - - setHiddenColumns(nextHiddenColumns); - }; - - const moveColumn = (columnAccessor: string, direction: 'up' | 'down') => { - const items: ConnectionColumn[] = Array.from(columns); - const currentIndex = items.findIndex((c) => c.accessor === columnAccessor); - if (currentIndex === -1) return; - - // 计算目标位置,跳过 id 列 - let targetIndex = currentIndex; - if (direction === 'up') { - // 往上移动 - targetIndex = currentIndex - 1; - // 跳过 id 列 - while (targetIndex >= 0 && items[targetIndex].accessor === 'id') { - targetIndex--; - } - if (targetIndex < 0) return; - } else { - // 往下移动 - targetIndex = currentIndex + 1; - // 跳过 id 列 - while (targetIndex < items.length && items[targetIndex].accessor === 'id') { - targetIndex++; - } - if (targetIndex >= items.length) return; - } - - // 交换位置 - const [removed] = items.splice(currentIndex, 1); - items.splice(targetIndex, 0, removed); - setColumns(items); - }; - - // 获取非 id 列的显示列表 - const visibleColumns = columns.filter((i) => i.accessor !== 'id'); - - return ( - <BaseModal isOpen={isOpen} onRequestClose={onRequestClose}> - <div> - <DragDropContext onDragEnd={onDragEnd}> - <Droppable droppableId="droppable-modal"> - {(provided) => ( - <div {...provided.droppableProps} ref={provided.innerRef}> - {visibleColumns.map((column, displayIndex) => { - const show = !hiddenColumns.includes(column.accessor); - const isFirst = displayIndex === 0; - const isLast = displayIndex === visibleColumns.length - 1; - - return ( - <Draggable - key={column.accessor} - draggableId={column.accessor} - index={columns.findIndex((a) => a.accessor === column.accessor)} - > - {(provided, snapshot) => ( - <div - ref={provided.innerRef} - {...provided.draggableProps} - {...provided.dragHandleProps} - className={s.columnManagerRow} - style={getItemStyle(snapshot.isDragging, provided.draggableProps.style)} - > - <Menu size={16} /> - <span className={s.columnManageLabel}>{t(column.Header)}</span> - <div className={s.columnMoveButtons}> - <button - className={s.moveBtn} - onClick={(e) => { - e.stopPropagation(); - moveColumn(column.accessor, 'up'); - }} - disabled={isFirst} - title={t('Move Up')} - > - <ChevronUp size={14} /> - </button> - <button - className={s.moveBtn} - onClick={(e) => { - e.stopPropagation(); - moveColumn(column.accessor, 'down'); - }} - disabled={isLast} - title={t('Move Down')} - > - <ChevronDown size={14} /> - </button> - </div> - <div className={s.columnManageSwitch}> - <Switch - size="mini" - checked={show} - onChange={(val) => onShowChange(column, val)} - /> - </div> - </div> - )} - </Draggable> - ); - })} - {provided.placeholder} - </div> - )} - </Droppable> - </DragDropContext> - </div> - </BaseModal> - ); -} diff --git a/src/components/connections/ModalSourceIP.module.scss b/src/components/connections/ModalSourceIP.module.scss deleted file mode 100644 index 7a60dcf..0000000 --- a/src/components/connections/ModalSourceIP.module.scss +++ /dev/null @@ -1,9 +0,0 @@ -.sourceipTable { - input { - width: 120px; - } -} - -.iptableTipContainer { - width: 300px; -} diff --git a/src/components/connections/ModalSourceIP.tsx b/src/components/connections/ModalSourceIP.tsx deleted file mode 100644 index 357cf7d..0000000 --- a/src/components/connections/ModalSourceIP.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import Button from '~/components/Button'; -import Input from '~/components/Input'; -import BaseModal from '~/components/shared/BaseModal'; - -import s from './ModalSourceIP.module.scss'; - -export default function ModalSourceIP({ isOpen, onRequestClose, sourceMap, setSourceMap }) { - const { t } = useTranslation(); - const setSource = (key, index, val) => { - setSourceMap((prev) => - prev.map((source, currentIndex) => - currentIndex === index ? { ...source, [key]: val } : source - ) - ); - }; - - const removeSource = (index) => { - setSourceMap((prev) => prev.filter((_, currentIndex) => currentIndex !== index)); - }; - - const addSource = () => { - setSourceMap((prev) => [...prev, { reg: '', name: '' }]); - }; - - return ( - <BaseModal isOpen={isOpen} onRequestClose={onRequestClose}> - <table className={s.sourceipTable}> - <thead> - <tr> - <th>{t('c_source')}</th> - <th>{t('device_name')}</th> - </tr> - </thead> - <tbody> - {sourceMap.map((source, index) => ( - <tr key={`${index}`}> - <td> - <Input - type="text" - name="reg" - autoComplete="off" - value={source.reg} - onChange={(e) => setSource('reg', index, e.target.value)} - /> - </td> - <td> - <Input - type="text" - name="name" - autoComplete="off" - value={source.name} - onChange={(e) => setSource('name', index, e.target.value)} - /> - </td> - <td> - <Button onClick={() => removeSource(index)}>{t('delete')}</Button> - </td> - </tr> - ))} - </tbody> - </table> - <div> - <div className={s.iptableTipContainer}>{t('sourceip_tip')}</div> - <Button onClick={addSource}>{t('add_tag')}</Button> - </div> - </BaseModal> - ); -} diff --git a/src/components/home/Home.module.scss b/src/components/home/Home.module.scss index 7fa6ada..940a906 100644 --- a/src/components/home/Home.module.scss +++ b/src/components/home/Home.module.scss @@ -1,8 +1,195 @@ -@use '~/styles/utils/custom-media' as *; +.page { + color: var(--color-text); + padding: 16px 16px 32px; -.root { - padding: 6px 15px; - @media screen and (min-width: 30em) { - padding: 10px 40px; + @media (min-width: 768px) { + padding: 28px 40px 48px; } } + +.pageHeader { + margin-bottom: 24px; +} + +.eyebrow { + margin: 0 0 4px; + font-size: 0.8125rem; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--color-text-secondary); +} + +.pageTitle { + margin: 0; + font-size: 2rem; + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.15; + color: var(--color-text-highlight); +} + +.grid { + display: grid; + gap: 20px; + grid-template-columns: 1fr; + + @media (min-width: 768px) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.card { + display: flex; + flex-direction: column; + padding: 20px; + border: 1px solid var(--color-separator); + border-radius: 16px; + background-color: var(--color-bg-card); + box-shadow: var(--shadow-card); + min-width: 0; +} + +.trafficCard { + @media (min-width: 768px) { + grid-column: span 2; + } +} + +.cardLabel { + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-secondary); +} + +.cardTitle { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--color-text-highlight); +} + +.cardSubtitle { + margin: 2px 0 0; + font-size: 0.8125rem; + color: var(--color-text-secondary); +} + +/* 大号数值 + 小号单位 */ +.value { + display: flex; + align-items: baseline; + gap: 6px; + margin-top: 10px; + font-size: 2.25rem; + font-weight: 800; + line-height: 1.1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + color: var(--color-text-highlight); +} + +.unit { + font-size: 0.875rem; + font-weight: 600; + font-style: normal; + color: var(--color-text-secondary); +} + +.meta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 14px; + font-size: 0.8125rem; + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; +} + +.dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +/* 实时流量卡片 */ +.cardHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.legend { + display: flex; + gap: 28px; +} + +.legendItem { + display: flex; + flex-direction: column; + align-items: flex-start; + /* 固定列宽 + 右对齐,速率位数变化时数字不会推着标签左右抖动 */ + min-width: 104px; + + @media (min-width: 768px) { + align-items: flex-end; + } +} + +.legendLabel { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8125rem; + color: var(--color-text-secondary); +} + +.legendValue { + display: flex; + align-items: baseline; + gap: 4px; + margin-top: 4px; + font-size: 1.5rem; + font-weight: 800; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + color: var(--color-text-highlight); +} + +.chart { + height: 220px; + margin-top: 16px; +} + +.memoryChart { + height: 96px; + margin-top: 12px; +} + +.metaRows { + margin-top: 16px; + border-top: 1px solid var(--color-separator); +} + +.metaRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + font-size: 0.8125rem; + color: var(--color-text-secondary); + + & + & { + border-top: 1px solid var(--color-separator); + } +} + +.metaValue { + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--color-text); +} diff --git a/src/components/home/Home.tsx b/src/components/home/Home.tsx index 489f4c5..6ea1e62 100644 --- a/src/components/home/Home.tsx +++ b/src/components/home/Home.tsx @@ -1,9 +1,11 @@ -import React from 'react'; +import { useTranslation } from 'react-i18next'; import { ClashAPIConfig } from '~/types'; -import s0 from './Home.module.scss'; -import TrafficNow from './TrafficNow'; +import s from './Home.module.scss'; +import MemoryCard from './MemoryCard'; +import StatCards from './StatCards'; +import TrafficCard from './TrafficCard'; type Props = { apiConfig: ClashAPIConfig; @@ -11,10 +13,18 @@ type Props = { }; export default function Home({ apiConfig, selectedChartStyleIndex }: Props) { + const { t } = useTranslation(); + return ( - <div> - <div className={s0.root}> - <TrafficNow apiConfig={apiConfig} selectedChartStyleIndex={selectedChartStyleIndex} /> + <div className={s.page}> + <header className={s.pageHeader}> + <p className={s.eyebrow}>{t('overview_eyebrow')}</p> + <h1 className={s.pageTitle}>{t('Overview')}</h1> + </header> + <div className={s.grid}> + <StatCards apiConfig={apiConfig} selectedChartStyleIndex={selectedChartStyleIndex} /> + <TrafficCard apiConfig={apiConfig} selectedChartStyleIndex={selectedChartStyleIndex} /> + <MemoryCard apiConfig={apiConfig} selectedChartStyleIndex={selectedChartStyleIndex} /> </div> </div> ); diff --git a/src/components/home/MemoryCard.tsx b/src/components/home/MemoryCard.tsx new file mode 100644 index 0000000..7537bd7 --- /dev/null +++ b/src/components/home/MemoryCard.tsx @@ -0,0 +1,50 @@ +import { Suspense } from 'react'; +import { useTranslation } from 'react-i18next'; + +import prettyBytes from '~/misc/pretty-bytes'; +import { CHART_WINDOW, useMemory, useRulesCount } from '~/modules/home/hooks'; +import { formatCount, latestOf, peakOf, splitBytes } from '~/modules/home/utils'; +import { ClashAPIConfig } from '~/types'; + +import s from './Home.module.scss'; +import MemoryChart from './MemoryChart'; + +type Props = { + apiConfig: ClashAPIConfig; + selectedChartStyleIndex: number; +}; + +export default function MemoryCard({ apiConfig, selectedChartStyleIndex }: Props) { + const { t } = useTranslation(); + const memory = useMemory(apiConfig); + const rulesCount = useRulesCount(apiConfig); + + const { value, unit } = splitBytes(latestOf(memory.inuse)); + const peak = peakOf(memory.inuse, CHART_WINDOW); + + return ( + <section className={s.card}> + <h2 className={s.cardTitle}>{t('Memory Usage')}</h2> + <p className={s.cardSubtitle}>{t('current_process_memory')}</p> + <div className={s.value}> + <span>{value}</span> + <em className={s.unit}>{unit}</em> + </div> + <div className={s.memoryChart}> + <Suspense fallback={null}> + <MemoryChart memory={memory} styleIndex={selectedChartStyleIndex} /> + </Suspense> + </div> + <div className={s.metaRows}> + <div className={s.metaRow}> + <span>{t('memory_peak')}</span> + <span className={s.metaValue}>{prettyBytes(peak)}</span> + </div> + <div className={s.metaRow}> + <span>{t('rule_count')}</span> + <span className={s.metaValue}>{formatCount(rulesCount)}</span> + </div> + </div> + </section> + ); +} diff --git a/src/components/home/MemoryChart.tsx b/src/components/home/MemoryChart.tsx new file mode 100644 index 0000000..344dedd --- /dev/null +++ b/src/components/home/MemoryChart.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { Line } from 'react-chartjs-2'; + +import { chartJSResource, chartStyles, commonDataSetProps } from '~/misc/chart'; +import prettyBytes from '~/misc/pretty-bytes'; +import { CHART_WINDOW, useScrollingChart } from '~/modules/home/hooks'; +import { gradientFill, rangeOf } from '~/modules/home/utils'; + +const { useMemo, useRef } = React; + +/** 多画几个窗口外的点,左边缘的线段才不会在数据滚动时被截断 */ +const OVERSCAN = 3; + +/** 内存读数很平稳,量程上下各留 2% 的余量,免得细微波动被放大成剧烈起伏 */ +const PAD_RATIO = 0.02; + +type Memory = { inuse: number[]; labels: number[] }; + +type Props = { + memory: Memory; + styleIndex: number; +}; + +const options: any = { + responsive: true, + maintainAspectRatio: false, + parsing: false, + animation: false, + interaction: { + mode: 'index', + intersect: false, + }, + plugins: { + legend: { display: false }, + tooltip: { + enabled: true, + displayColors: false, + callbacks: { + title: (items: any[]) => new Date(items[0].parsed.x).toLocaleTimeString(), + label: (ctx: any) => ` ${prettyBytes(ctx.parsed.y)}`, + }, + }, + }, + scales: { + // x 的窗口、y 的量程都由动画帧逐帧写入 + x: { type: 'time', display: false }, + y: { display: false }, + }, + elements: { + line: { borderWidth: 2, cubicInterpolationMode: 'monotone' }, + point: { radius: 0, hitRadius: 10, hoverRadius: 4 }, + }, +}; + +export default function MemoryChart({ memory, styleIndex }: Props) { + chartJSResource.read(); + + const chartRef = useRef<any>(null); + const size = CHART_WINDOW + OVERSCAN; + + // 内存不是从 0 开始的量,量程跟着数据区间走,否则曲线会被压成贴顶的一条直线 + useScrollingChart(chartRef, () => rangeOf(memory.inuse, size, PAD_RATIO)); + + const style = chartStyles[styleIndex] || chartStyles[0]; + + const data = useMemo(() => { + const labels = memory.labels.slice(-size); + return { + datasets: [ + { + ...commonDataSetProps, + borderWidth: 2, + borderColor: style.inuse.borderColor, + backgroundColor: gradientFill(style.inuse.borderColor), + // 内核推送的首帧是 0,和缓冲里未填充的 null 一样当作没有数据 + data: memory.inuse.slice(-size).map((y, i) => ({ x: labels[i], y: y > 0 ? y : null })), + fill: true, + }, + ], + }; + }, [memory, size, style]); + + return <Line ref={chartRef} data={data} options={options} redraw={false} />; +} diff --git a/src/components/home/StatCards.tsx b/src/components/home/StatCards.tsx new file mode 100644 index 0000000..cd82366 --- /dev/null +++ b/src/components/home/StatCards.tsx @@ -0,0 +1,72 @@ +import { useTranslation } from 'react-i18next'; + +import { chartStyles } from '~/misc/chart'; +import { useConnectionSummary } from '~/modules/home/hooks'; +import { formatCount, splitBytes } from '~/modules/home/utils'; +import { ClashAPIConfig } from '~/types'; + +import s from './Home.module.scss'; + +type Props = { + apiConfig: ClashAPIConfig; + selectedChartStyleIndex: number; +}; + +type StatCardProps = { + label: string; + value: string; + unit: string; + meta: string; + dotColor: string; +}; + +function StatCard({ label, value, unit, meta, dotColor }: StatCardProps) { + return ( + <section className={s.card}> + <span className={s.cardLabel}>{label}</span> + <div className={s.value}> + <span>{value}</span> + <em className={s.unit}>{unit}</em> + </div> + <div className={s.meta}> + <span className={s.dot} style={{ backgroundColor: dotColor }} /> + <span>{meta}</span> + </div> + </section> + ); +} + +export default function StatCards({ apiConfig, selectedChartStyleIndex }: Props) { + const { t } = useTranslation(); + const { upTotal, dlTotal, connNumber, tcpNumber, udpNumber } = useConnectionSummary(apiConfig); + const style = chartStyles[selectedChartStyleIndex] || chartStyles[0]; + + const dl = splitBytes(dlTotal); + const up = splitBytes(upTotal); + + return ( + <> + <StatCard + label={t('Download Total')} + value={dl.value} + unit={dl.unit} + meta={t('since_core_start')} + dotColor={style.down.borderColor} + /> + <StatCard + label={t('Upload Total')} + value={up.value} + unit={up.unit} + meta={t('since_core_start')} + dotColor={style.up.borderColor} + /> + <StatCard + label={t('Active Connections')} + value={formatCount(connNumber)} + unit={t('conn_unit')} + meta={`TCP ${formatCount(tcpNumber)} · UDP ${formatCount(udpNumber)}`} + dotColor="#22c55e" + /> + </> + ); +} diff --git a/src/components/home/TrafficCard.tsx b/src/components/home/TrafficCard.tsx new file mode 100644 index 0000000..7489798 --- /dev/null +++ b/src/components/home/TrafficCard.tsx @@ -0,0 +1,74 @@ +import cx from 'clsx'; +import { Suspense } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { chartStyles } from '~/misc/chart'; +import { CHART_WINDOW, useTraffic } from '~/modules/home/hooks'; +import { splitTrafficRate } from '~/modules/home/utils'; +import { ClashAPIConfig } from '~/types'; + +import s from './Home.module.scss'; +import TrafficChart from './TrafficChart'; + +type Props = { + apiConfig: ClashAPIConfig; + selectedChartStyleIndex: number; +}; + +function Legend({ label, color, rate }: { label: string; color: string; rate: number }) { + const { value, unit } = splitTrafficRate(rate); + return ( + <div className={s.legendItem}> + <span className={s.legendLabel}> + <span className={s.dot} style={{ backgroundColor: color }} /> + {label} + </span> + <span className={s.legendValue}> + <span>{value}</span> + <em className={s.unit}>{unit}</em> + </span> + </div> + ); +} + +export default function TrafficCard({ apiConfig, selectedChartStyleIndex }: Props) { + const { t } = useTranslation(); + const traffic = useTraffic(apiConfig); + const style = chartStyles[selectedChartStyleIndex] || chartStyles[0]; + + const downLabel = t('Download'); + const upLabel = t('Upload'); + + return ( + <section className={cx(s.card, s.trafficCard)}> + <div className={s.cardHead}> + <div> + <h2 className={s.cardTitle}>{t('realtime_traffic')}</h2> + <p className={s.cardSubtitle}>{t('last_n_seconds', { seconds: CHART_WINDOW })}</p> + </div> + <div className={s.legend}> + <Legend + label={downLabel} + color={style.down.borderColor} + rate={traffic.down[traffic.down.length - 1] || 0} + /> + <Legend + label={upLabel} + color={style.up.borderColor} + rate={traffic.up[traffic.up.length - 1] || 0} + /> + </div> + </div> + <div className={s.chart}> + <Suspense fallback={null}> + <TrafficChart + traffic={traffic} + styleIndex={selectedChartStyleIndex} + downLabel={downLabel} + upLabel={upLabel} + /> + </Suspense> + </div> + </section> + ); +} diff --git a/src/components/home/TrafficChart.tsx b/src/components/home/TrafficChart.tsx new file mode 100644 index 0000000..2584886 --- /dev/null +++ b/src/components/home/TrafficChart.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import { Line } from 'react-chartjs-2'; + +import { chartJSResource, chartStyles, commonDataSetProps } from '~/misc/chart'; +import { CHART_WINDOW, useScrollingChart } from '~/modules/home/hooks'; +import { formatTrafficRate, gradientFill, peakOf } from '~/modules/home/utils'; + +const { useMemo, useRef } = React; + +/** 多画几个窗口外的点,左边缘的线段才不会在数据滚动时被截断 */ +const OVERSCAN = 3; + +/** 纵轴最小量程,空闲时不至于把噪声放大成大起大落 */ +const Y_FLOOR = 100 * 1024; + +/** + * 速率跨度可以从几百 B/s 到几十 MB/s,直接画会被尖峰压成一条贴底的直线。 + * 用 log1p 压缩后再画(0 仍然映射到 0),读数时用 expm1 还原。 + */ +const compress = (v: number) => Math.log1p(v); +const decompress = (v: number) => Math.expm1(v); + +type Traffic = { up: number[]; down: number[]; labels: number[] }; + +type Props = { + traffic: Traffic; + styleIndex: number; + downLabel: string; + upLabel: string; +}; + +const options: any = { + responsive: true, + maintainAspectRatio: false, + parsing: false, + // 曲线的移动完全交给 useScrollingChart 逐帧推进坐标轴,元素本身不做补间 + animation: false, + interaction: { + mode: 'index', + intersect: false, + }, + plugins: { + legend: { display: false }, + tooltip: { + enabled: true, + callbacks: { + title: (items: any[]) => new Date(items[0].parsed.x).toLocaleTimeString(), + // 画的是压缩后的值,提示里要还原成真实速率 + label: (ctx: any) => + ` ${ctx.dataset.label} ${formatTrafficRate(decompress(ctx.parsed.y))}`, + }, + }, + }, + scales: { + // x 的窗口、y 的量程都由动画帧逐帧写入 + x: { type: 'time', display: false }, + y: { + display: true, + border: { display: false }, + grid: { + color: 'rgba(148, 163, 184, 0.22)', + drawTicks: false, + }, + // 固定 5 条刻度,网格线始终落在同样的像素高度,量程变化时不会跳动 + ticks: { display: false, count: 5 }, + }, + }, + elements: { + // monotone 插值:曲线平滑但不会在两点之间过冲到负值,面积图不会出现假的凹陷 + line: { borderWidth: 2, cubicInterpolationMode: 'monotone' }, + point: { radius: 0, hitRadius: 10, hoverRadius: 4 }, + }, +}; + +export default function TrafficChart({ traffic, styleIndex, downLabel, upLabel }: Props) { + chartJSResource.read(); + + const chartRef = useRef<any>(null); + const size = CHART_WINDOW + OVERSCAN; + + useScrollingChart(chartRef, () => ({ + min: 0, + max: compress(Math.max(peakOf(traffic.up, size), peakOf(traffic.down, size), Y_FLOOR)) * 1.15, + })); + + const style = chartStyles[styleIndex] || chartStyles[0]; + + const data = useMemo(() => { + const labels = traffic.labels.slice(-size); + const toPoints = (values: number[]) => + values.slice(-size).map((y, i) => ({ x: labels[i], y: y === null ? null : compress(y) })); + + return { + datasets: [ + { + ...commonDataSetProps, + borderWidth: 2, + label: downLabel, + borderColor: style.down.borderColor, + backgroundColor: gradientFill(style.down.borderColor), + data: toPoints(traffic.down), + fill: true, + }, + { + ...commonDataSetProps, + borderWidth: 2, + label: upLabel, + borderColor: style.up.borderColor, + backgroundColor: gradientFill(style.up.borderColor), + data: toPoints(traffic.up), + fill: true, + }, + ], + }; + }, [traffic, size, style, downLabel, upLabel]); + + return <Line ref={chartRef} data={data} options={options} redraw={false} />; +} diff --git a/src/components/home/TrafficNow.module.scss b/src/components/home/TrafficNow.module.scss deleted file mode 100644 index 2b0bcdf..0000000 --- a/src/components/home/TrafficNow.module.scss +++ /dev/null @@ -1,85 +0,0 @@ -.TrafficNow { - color: var(--color-text); - display: flex; - flex-direction: column; - grid-gap: 20px; - gap: 20px; - padding: 10px 0; - - .overview { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 20px; - - & > div:nth-child(3) { - grid-column: 1 / -1; - } - - @media (min-width: 768px) { - grid-template-columns: 1fr 1fr 1fr; - - & > div:nth-child(3) { - grid-column: auto; - } - } - } - - .chartsRow { - display: flex; - flex-direction: column; - gap: 20px; - - @media (min-width: 768px) { - flex-direction: row; - - & > .sec { - flex: 1; - min-width: 0; - } - } - } - - .sec { - padding: 20px; - background-color: var(--color-bg-card); - border-radius: 12px; - box-shadow: - 0 4px 6px -1px rgba(0, 0, 0, 0.1), - 0 2px 4px -1px rgba(0, 0, 0, 0.06); - transition: - transform 0.2s ease, - box-shadow 0.2s ease; - display: flex; - flex-direction: column; - justify-content: space-between; - min-height: 140px; - - &:hover { - box-shadow: - 0 10px 15px -3px rgba(0, 0, 0, 0.1), - 0 4px 6px -2px rgba(0, 0, 0, 0.05); - } - - .header { - display: flex; - align-items: center; - color: var(--color-text-secondary); - font-size: 0.85rem; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.025em; - - span { - margin-left: 8px; - } - } - - .value { - padding: 12px 0; - font-size: 1.75rem; - font-weight: 700; - color: var(--color-text); - font-family: 'Roboto Mono', monospace; - } - } -} diff --git a/src/components/home/TrafficNow.tsx b/src/components/home/TrafficNow.tsx deleted file mode 100644 index c46cf67..0000000 --- a/src/components/home/TrafficNow.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import * as React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { - Download, - ArrowDown, - ArrowUp, - Cpu, - Link as LinkIcon, - Upload, -} from '~/components/shared/FeatherIcons'; -import Sparkline from '~/components/shared/Sparkline'; -import useMemory from '~/hooks/useMemory'; -import useTraffic from '~/hooks/useTraffic'; -import { useConnectionSummary } from '~/modules/home/hooks'; -import { formatTrafficRate } from '~/modules/home/utils'; -import { ClashAPIConfig } from '~/types'; - -import s0 from './TrafficNow.module.scss'; - -type Props = { - apiConfig: ClashAPIConfig; - selectedChartStyleIndex: number; -}; - -export default function TrafficNow({ apiConfig, selectedChartStyleIndex }: Props) { - const { t } = useTranslation(); - const traffic = useTraffic(apiConfig); - const memory = useMemory(apiConfig); - const { upTotal, dlTotal, connNumber, mUsage } = useConnectionSummary(apiConfig); - - const upStr = formatTrafficRate(traffic.up[traffic.up.length - 1] || 0); - const downStr = formatTrafficRate(traffic.down[traffic.down.length - 1] || 0); - - return ( - <div className={s0.TrafficNow}> - <div className={s0.overview}> - <div className={s0.sec}> - <div className={s0.header}> - <Download size={16} /> - <span>{t('Download Total')}</span> - </div> - <div className={s0.value}>{dlTotal}</div> - </div> - <div className={s0.sec}> - <div className={s0.header}> - <Upload size={16} /> - <span>{t('Upload Total')}</span> - </div> - <div className={s0.value}>{upTotal}</div> - </div> - <div className={s0.sec}> - <div className={s0.header}> - <LinkIcon size={16} /> - <span>{t('Active Connections')}</span> - </div> - <div className={s0.value}>{connNumber}</div> - </div> - </div> - - <div className={s0.chartsRow}> - <div className={s0.sec}> - <div className={s0.header}> - <ArrowDown size={16} /> - <span>{t('Download')}</span> - </div> - <div className={s0.value}>{downStr}</div> - <Sparkline - data={traffic.down} - labels={traffic.labels} - type="down" - styleIndex={selectedChartStyleIndex} - /> - </div> - <div className={s0.sec}> - <div className={s0.header}> - <ArrowUp size={16} /> - <span>{t('Upload')}</span> - </div> - <div className={s0.value}>{upStr}</div> - <Sparkline - data={traffic.up} - labels={traffic.labels} - type="up" - styleIndex={selectedChartStyleIndex} - /> - </div> - <div className={s0.sec}> - <div className={s0.header}> - <Cpu size={16} /> - <span>{t('Memory Usage')}</span> - </div> - <div className={s0.value}>{mUsage}</div> - <Sparkline - data={memory.inuse} - labels={memory.labels} - type="inuse" - styleIndex={selectedChartStyleIndex} - /> - </div> - </div> - </div> - ); -} diff --git a/src/components/logs/LogSearch.ts b/src/components/logs/LogSearch.ts deleted file mode 100644 index d5972a4..0000000 --- a/src/components/logs/LogSearch.ts +++ /dev/null @@ -1,6 +0,0 @@ -import Search from '~/components/Search'; -import { connect } from '~/components/StateProvider'; -import { getSearchText, updateSearchText } from '~/store/logs'; - -const mapState = (s) => ({ searchText: getSearchText(s), updateSearchText }); -export default connect(mapState)(Search); diff --git a/src/components/logs/Logs.module.scss b/src/components/logs/Logs.module.scss index c90071d..47b996f 100644 --- a/src/components/logs/Logs.module.scss +++ b/src/components/logs/Logs.module.scss @@ -1,190 +1,217 @@ -.headerControls { +.page { display: flex; - align-items: center; - gap: 10px; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.listArea { + display: flex; + flex-direction: column; flex: 1; - justify-content: flex-end; - max-width: 360px; + min-height: 0; + margin: 12px 32px 24px; - & > div { - flex: 1; + @media (max-width: 1024px) { + margin: 12px 16px 16px; } @media (max-width: 768px) { - max-width: none; - justify-content: stretch; + margin: 8px 12px 10px; } } -.searchWrapper { +// 与连接页表格 / 规则页列表同一张卡:窄屏下退掉卡片边界 +.card { + position: relative; + display: flex; + flex-direction: column; flex: 1; - max-width: 300px; + min-height: 0; + border-radius: 16px; + background: var(--color-card); + border: 1px solid var(--color-card-border); + box-shadow: var(--shadow-card); + overflow: hidden; @media (max-width: 768px) { - max-width: none; + background: transparent; + border: none; + box-shadow: none; + border-radius: 0; } } -.levelSelect { - flex-shrink: 0; - width: auto; - min-width: 90px; -} +.scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; -.clearBtn { - background: none; - border: none; - color: var(--color-text-secondary); - cursor: pointer; - padding: 8px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - transition: - background-color 0.2s, - color 0.2s; + &::-webkit-scrollbar { + width: 6px; + } - &:hover { - background-color: var(--bg-near-transparent); - color: var(--color-text); + &::-webkit-scrollbar-thumb { + background-color: var(--color-card-border); + border-radius: 3px; } } -.logLine { +/* ---------- 日志行 ---------- */ + +.line { display: flex; - font-family: var(--font-normal); - font-size: 12px; - padding: 4px 8px; - border-bottom: 1px solid var(--bg-near-transparent); - word-break: break-all; + align-items: baseline; + gap: 10px; + padding: 5px 18px; + font-size: 0.75rem; + border-bottom: 1px solid var(--color-card-border); &:hover { - background-color: var(--bg-near-transparent); + background-color: var(--color-hover-soft); } + // 这里不用 content-visibility:自动跟随是拿 scrollHeight 直接顶到底的, + // 屏外行只有估算高度会让落点跳。日志上限 300 行,靠 LogLine 的 memo 就够了 + @media (max-width: 768px) { - flex-direction: column; + // 窄屏放不下三列,时间和级别并排占一行,正文另起一行 + flex-wrap: wrap; + gap: 6px; + padding: 6px 12px; } } -.logMeta { - display: flex; - align-items: center; +.time { flex-shrink: 0; - margin-right: 12px; - min-width: 180px; + width: 148px; + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--color-text-secondary); + opacity: 0.8; @media (max-width: 768px) { - margin-bottom: 4px; + width: auto; } } -.logTime { - color: var(--color-text-secondary); - margin-right: 8px; - opacity: 0.8; -} - -.logType { - text-transform: uppercase; - font-weight: bold; - font-size: 10px; - padding: 1px 6px; - border-radius: 3px; - min-width: 50px; +.type { + flex-shrink: 0; + width: 44px; text-align: center; + text-transform: uppercase; + font-weight: 700; + font-size: 0.6rem; + padding: 1px 0; + border-radius: 4px; &[data-type='debug'] { - color: #389d3d; - background-color: rgba(56, 157, 61, 0.1); + color: var(--color-success); + background-color: var(--color-success-soft-bg); } + &[data-type='info'] { - color: #0ea5e9; - background-color: rgba(14, 165, 233, 0.1); + color: var(--color-accent-soft-fg); + background-color: var(--color-accent-soft-bg); } + &[data-type='warning'] { - color: #f59e0b; - background-color: rgba(245, 158, 11, 0.1); + color: var(--color-warn); + background-color: var(--color-warn-soft-bg); } + &[data-type='error'] { - color: #ef4444; - background-color: rgba(239, 68, 68, 0.1); + color: var(--color-danger); + background-color: var(--color-danger-soft-bg); } } -.logText { - color: var(--color-text); - line-height: 1.5; +.payload { flex: 1; -} - -.logsWrapper { - position: relative; - margin: 20px 45px; - padding: 10px; - background-color: var(--bg-log-info-card); - border-radius: 8px; + min-width: 0; color: var(--color-text); - overflow-y: auto; - box-shadow: - 0 4px 6px -1px rgba(0, 0, 0, 0.1), - 0 2px 4px -1px rgba(0, 0, 0, 0.06); + line-height: 1.5; + word-break: break-all; @media (max-width: 768px) { - margin: 10px 15px; - } - - &::-webkit-scrollbar { - width: 6px; - } - &::-webkit-scrollbar-thumb { - background-color: var(--bg-near-transparent); - border-radius: 3px; + flex-basis: 100%; } } -.scrollToBottomBtn { +/* ---------- 回到底部 ---------- */ + +.toBottomBtn { position: absolute; - bottom: 80px; - right: 20px; - background-color: var(--color-focus-blue); - color: white; - border: none; - border-radius: 50%; - width: 36px; - height: 36px; + right: 18px; + bottom: 52px; + z-index: 2; display: flex; align-items: center; justify-content: center; + appearance: none; + width: 34px; + height: 34px; + border: 1px solid var(--color-card-border); + border-radius: 50%; + background: var(--color-card); + color: var(--color-text); cursor: pointer; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); - z-index: 10; - transition: transform 0.2s; + box-shadow: var(--shadow-popover); + transition: + border-color 0.15s ease, + color 0.15s ease; &:hover { - transform: scale(1.1); + border-color: var(--color-focus-blue); + color: var(--color-focus-blue); } - @media (max-width: 768px) { - right: 25px; + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: 2px; } } -.logPlaceholder { +/* ---------- 空态 ---------- */ + +.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 6px; + height: 100%; +} + +.emptyTitle { + font-size: 0.88rem; + color: var(--color-text); +} + +.emptyHint { + font-size: 0.78rem; color: var(--color-text-secondary); +} - div:nth-child(2) { - font-size: 1.2em; - margin-top: 20px; - opacity: 0.6; - } +/* ---------- 页脚 ---------- */ + +.footer { + display: flex; + align-items: center; + gap: 14px; + height: 36px; + flex-shrink: 0; + padding: 0 18px; + border-top: 1px solid var(--color-card-border); + background: var(--color-track); + font-size: 0.72rem; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; } -.logPlaceholderIcon { - opacity: 0.2; +.paused { + margin-left: auto; + color: var(--color-warn); } diff --git a/src/components/logs/Logs.tsx b/src/components/logs/Logs.tsx index 71acbb8..a8510b8 100644 --- a/src/components/logs/Logs.tsx +++ b/src/components/logs/Logs.tsx @@ -1,109 +1,103 @@ +import { useAtomValue, useSetAtom } from 'jotai'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import ContentHeader from '~/components/ContentHeader'; -import { Fab, position as fabPosition } from '~/components/shared/Fab'; -import { ArrowDown, Pause, Play, Trash2 } from '~/components/shared/FeatherIcons'; -import Select from '~/components/shared/Select'; -import { useStoreActions } from '~/components/StateProvider'; -import SvgYacd from '~/components/SvgYacd'; -import useRemainingViewPortHeight from '~/hooks/useRemainingViewPortHeight'; -import { LOG_LEVEL_OPTIONS } from '~/modules/config/utils'; -import { useLogsPage } from '~/modules/logs/hooks'; -import { LOG_TYPES, LOGS_HEIGHT_RATIO } from '~/modules/logs/utils'; +import { ArrowDown } from '~/components/shared/FeatherIcons'; +import { useFilteredLogs, useLogsPage } from '~/modules/logs/hooks'; +import { LOG_TYPES } from '~/modules/logs/utils'; import { updateConfigs } from '~/store/configs'; -import { clearLogs } from '~/store/logs'; -import { DispatchFn, Log } from '~/store/types'; -import { ClashAPIConfig } from '~/types'; +import { clearLogsAtom, logsForDisplayAtom } from '~/store/logs'; +import { useStoreActions } from '~/store/StateProvider'; +import { DispatchFn } from '~/store/types'; +import { ClashAPIConfig, Log } from '~/types'; import s from './Logs.module.scss'; -import LogSearch from './LogSearch'; +import { LogsHeader } from './LogsHeader'; -type LogLineProps = Partial<Log>; - -function LogLine({ time, payload, type }: LogLineProps) { +// 一屏最多几十行,但列表整体可以有几百行;memo 之后新日志进来只渲染新增的那一条 +const LogLine = React.memo(function LogLine({ time, payload, type }: Log) { return ( - <div className={s.logLine}> - <div className={s.logMeta}> - <span className={s.logTime}>{time}</span> - <span className={s.logType} data-type={type}> - {LOG_TYPES[type]} - </span> - </div> - <div className={s.logText}>{payload}</div> + <div className={s.line}> + <span className={s.time}>{time}</span> + <span className={s.type} data-type={type}> + {LOG_TYPES[type]} + </span> + <span className={s.payload}>{payload}</span> </div> ); -} +}); type Props = { dispatch: DispatchFn; logLevel: string; apiConfig: ClashAPIConfig; - logs: Log[]; logStreamingPaused: boolean; }; -export default function Logs({ dispatch, logLevel, apiConfig, logs, logStreamingPaused }: Props) { +export default function Logs({ dispatch, logLevel, apiConfig, logStreamingPaused }: Props) { + const { t } = useTranslation(); const actions = useStoreActions(); + const logs = useAtomValue(logsForDisplayAtom); const { toggleIsRefreshPaused, scrollRef, isAtBottom, scrollToBottom, onScroll } = useLogsPage({ - dispatch, logLevel, apiConfig, logs, logStreamingPaused, updateAppConfig: actions.app.updateAppConfig, }); - const [refLogsContainer, containerHeight] = useRemainingViewPortHeight(); - const { t } = useTranslation(); + + const visibleLogs = useFilteredLogs(logs); + + const setLogLevel = React.useCallback( + (level: string) => dispatch(updateConfigs(apiConfig, { 'log-level': level })), + [dispatch, apiConfig], + ); + const onClear = useSetAtom(clearLogsAtom); return ( - <div> - <ContentHeader> - <div className={s.headerControls}> - <LogSearch className={s.searchWrapper} /> - <Select - className={s.levelSelect} - options={LOG_LEVEL_OPTIONS} - selected={logLevel ? logLevel.toLowerCase() : 'info'} - onChange={(e) => dispatch(updateConfigs(apiConfig, { 'log-level': e.target.value }))} - /> - <button className={s.clearBtn} onClick={() => dispatch(clearLogs())} title={t('Clear')}> - <Trash2 size={18} /> - </button> - </div> - </ContentHeader> - <div ref={refLogsContainer} style={{ position: 'relative' }}> - <div - className={s.logsWrapper} - style={{ height: containerHeight * LOGS_HEIGHT_RATIO }} - ref={scrollRef} - onScroll={onScroll} - > - {logs.length === 0 ? ( - <div className={s.logPlaceholder} style={{ height: '100%' }}> - <div className={s.logPlaceholderIcon}> - <SvgYacd width={200} height={200} /> + <div className={s.page}> + <LogsHeader + logLevel={logLevel} + setLogLevel={setLogLevel} + isPaused={logStreamingPaused} + toggleIsPaused={toggleIsRefreshPaused} + onClear={onClear} + /> + + <div className={s.listArea}> + <div className={s.card}> + <div className={s.scroll} ref={scrollRef} onScroll={onScroll}> + {visibleLogs.length === 0 ? ( + <div className={s.empty}> + <span className={s.emptyTitle}> + {logs.length === 0 ? t('no_logs') : t('logs_no_match')} + </span> + <span className={s.emptyHint}> + {logs.length === 0 ? t('logs_empty_hint') : t('rules_empty_hint')} + </span> </div> - <div>{t('no_logs')}</div> - </div> - ) : ( - logs.map((log, index) => <LogLine {...log} key={log.id || index} />) - )} - </div> + ) : ( + visibleLogs.map((log, index) => <LogLine {...log} key={log.id || index} />) + )} + </div> - {logs.length > 0 && !isAtBottom && ( - <button className={s.scrollToBottomBtn} onClick={scrollToBottom}> - <ArrowDown size={16} /> - </button> - )} + {visibleLogs.length > 0 && !isAtBottom ? ( + <button + type="button" + className={s.toBottomBtn} + onClick={scrollToBottom} + aria-label={t('logs_scroll_to_bottom')} + title={t('logs_scroll_to_bottom')} + > + <ArrowDown size={16} /> + </button> + ) : null} - <Fab - icon={logStreamingPaused ? <Play size={16} /> : <Pause size={16} />} - mainButtonStyles={logStreamingPaused ? { background: '#e74c3c' } : {}} - style={fabPosition} - text={logStreamingPaused ? t('Resume Refresh') : t('Pause Refresh')} - onClick={toggleIsRefreshPaused} - ></Fab> + <div className={s.footer}> + <span>{t('logs_shown', { count: visibleLogs.length })}</span> + {logStreamingPaused ? <span className={s.paused}>{t('logs_paused')}</span> : null} + </div> + </div> </div> </div> ); diff --git a/src/components/logs/LogsHeader.module.scss b/src/components/logs/LogsHeader.module.scss new file mode 100644 index 0000000..0509bb9 --- /dev/null +++ b/src/components/logs/LogsHeader.module.scss @@ -0,0 +1,33 @@ +/** + * 只放日志页特有的东西,顶栏本身的样式在 shared/PageHeader.module.scss。 + * DOM 顺序即桌面端视觉顺序(标题 · 级别 · 搜索 · 操作), + * 窄屏把操作钮提到第一行,级别和搜索并排到第二行。 + */ + +.levelSelect { + min-width: 104px; + + @media (max-width: 768px) { + order: 4; + min-width: 88px; + max-width: 110px; + } +} + +.search { + @media (max-width: 768px) { + order: 3; + } +} + +.actions { + @media (max-width: 768px) { + order: 1; + } +} + +.rowBreak { + @media (max-width: 768px) { + order: 2; + } +} diff --git a/src/components/logs/LogsHeader.tsx b/src/components/logs/LogsHeader.tsx new file mode 100644 index 0000000..5092429 --- /dev/null +++ b/src/components/logs/LogsHeader.tsx @@ -0,0 +1,67 @@ +import { useTranslation } from 'react-i18next'; + +import { Pause, Play, Trash2 } from '~/components/shared/FeatherIcons'; +import { + HeaderActions, + HeaderButton, + HeaderRowBreak, + HeaderSearch, + headerSearchInlineClass, + headerSelectClass, + HeaderTitle, + PageHeader, +} from '~/components/shared/PageHeader'; +import Select from '~/components/shared/Select'; +import { TextFilter } from '~/components/shared/TextFilter'; +import { LOG_LEVEL_OPTIONS } from '~/modules/config/utils'; +import { logFilterText } from '~/store/logs'; + +import s from './LogsHeader.module.scss'; + +type Props = { + logLevel: string; + setLogLevel: (level: string) => void; + isPaused: boolean; + toggleIsPaused: () => void; + onClear: () => void; +}; + +export function LogsHeader({ logLevel, setLogLevel, isPaused, toggleIsPaused, onClear }: Props) { + const { t } = useTranslation(); + + return ( + <PageHeader> + <HeaderTitle>{t('Logs')}</HeaderTitle> + + <Select + options={LOG_LEVEL_OPTIONS} + selected={logLevel ? logLevel.toLowerCase() : 'info'} + className={`${headerSelectClass} ${s.levelSelect}`} + aria-label={t('log_level')} + onChange={(e) => setLogLevel(e.target.value)} + /> + + <HeaderSearch className={`${headerSearchInlineClass} ${s.search}`}> + <TextFilter textAtom={logFilterText} placeholder={t('search_logs_placeholder')} /> + </HeaderSearch> + + <HeaderActions className={s.actions}> + <HeaderButton + variant={isPaused ? 'paused' : 'ghost'} + icon={isPaused ? <Play size={14} /> : <Pause size={14} />} + label={isPaused ? t('Resume Refresh') : t('Pause Refresh')} + onClick={toggleIsPaused} + /> + <HeaderButton + variant="danger" + icon={<Trash2 size={14} />} + label={t('Clear')} + onClick={onClear} + /> + </HeaderActions> + + {/* 窄屏下强制换行,让级别筛选和搜索独占第二行 */} + <HeaderRowBreak className={s.rowBreak} /> + </PageHeader> + ); +} diff --git a/src/components/proxies/ClosePrevConns.tsx b/src/components/proxies/ClosePrevConns.tsx deleted file mode 100644 index 6718fb2..0000000 --- a/src/components/proxies/ClosePrevConns.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import * as React from 'react'; - -import Button from '../Button'; -import { FlexCenter } from '../shared/Styled'; - -const { useRef, useEffect } = React; - -type Props = { - onClickPrimaryButton?: () => void; - onClickSecondaryButton?: () => void; -}; - -export function ClosePrevConns({ onClickPrimaryButton, onClickSecondaryButton }: Props) { - const primaryButtonRef = useRef<HTMLButtonElement>(null); - const secondaryButtonRef = useRef<HTMLButtonElement>(null); - useEffect(() => { - primaryButtonRef.current.focus(); - }, []); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.keyCode === 39) { - secondaryButtonRef.current.focus(); - } else if (e.keyCode === 37) { - primaryButtonRef.current.focus(); - } - }; - - return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions - <div onKeyDown={handleKeyDown}> - <h2>Close Connections?</h2> - <p> - Click 'Yes' to close those connections that are still using the old selected proxy in this - group - </p> - <div style={{ height: 30 }} /> - <FlexCenter> - <Button onClick={onClickPrimaryButton} ref={primaryButtonRef}> - Yes - </Button> - <div style={{ width: 20 }} /> - <Button onClick={onClickSecondaryButton} ref={secondaryButtonRef}> - No - </Button> - </FlexCenter> - </div> - ); -} diff --git a/src/components/proxies/Proxies.module.scss b/src/components/proxies/Proxies.module.scss index a43d18b..457b173 100644 --- a/src/components/proxies/Proxies.module.scss +++ b/src/components/proxies/Proxies.module.scss @@ -1,153 +1,56 @@ -@use '~/styles/utils/custom-media' as *; - -.topBar { - position: sticky; - top: 0; - z-index: 1; - background-color: var(--color-background2); - backdrop-filter: blur(36px); - - & > div { - width: 100%; - } +.page { + padding-bottom: 32px; } -.topBarRight { +.groupsContainer { + padding: 16px 32px; display: flex; - align-items: center; - flex-wrap: wrap; - flex: 1; - justify-content: flex-end; + flex-direction: column; + gap: 12px; + + @media (max-width: 1024px) { + padding: 12px 16px 16px; + } @media (max-width: 768px) { - order: 3; - flex: 1 1 100%; - width: 100%; - margin-top: 8px; + gap: 8px; + padding: 8px 12px 12px; } } -.textFilterContainer { - max-width: 300px; - min-width: 150px; - flex: 1; - margin-right: 8px; +// 单列布局下 .column 是唯一的一层包裹,间距和 .group 的 order 都得由它提供; +// 双列布局在窄屏会把它 display: contents 掉,改由 .groupsContainer 接管 +.column { + display: flex; + flex-direction: column; + gap: 12px; @media (max-width: 768px) { - max-width: none; - margin-right: 8px; + gap: 8px; } } .group { - padding: 10px 0; // let the browser skip layout/paint for off-screen groups — large // subscriptions can put thousands of proxy nodes in the DOM. `auto` in // contain-intrinsic-size keeps the last rendered size as the placeholder // so scrolling doesn't jump; 200px is only the pre-first-render estimate. content-visibility: auto; contain-intrinsic-size: auto 200px; - - @media (--breakpoint-not-small) { - padding: 10px 0; - } } -.groupsContainer { - margin: 0 45px 20px; - padding: 2px 0 10px; - - @media (max-width: 768px) { - margin: 10px 15px 15px; +.doubleColumn { + @media screen and (min-width: 1200px) { + flex-direction: row; + align-items: flex-start; } - &.doubleColumn { - display: flex; - flex-direction: column; - gap: 0; - - @media screen and (min-width: 1200px) { - flex-direction: row; - align-items: flex-start; - gap: 12px; - } - - .column { - flex: 1; - display: flex; - flex-direction: column; - - @media screen and (max-width: 1199px) { - display: contents; - } - } - - .group { - @media screen and (min-width: 1200px) { - padding: 10px 0; - } - } - } -} - -.tabsContainer { - display: flex; - align-items: center; - background-color: var(--color-bg-sidebar); - border-radius: 12px; - padding: 4px; - - @media (max-width: 768px) { - order: 1; - flex: 1 1 100%; - width: 100%; - } -} - -.tab { - display: flex; - align-items: center; - padding: 8px 16px; - border-radius: 8px; - cursor: pointer; - user-select: none; - font-size: 1em; - font-weight: 500; - color: var(--color-text-secondary); - transition: all 0.2s ease; - user-select: none; - - @media (max-width: 768px) { - padding: 6px 10px; - font-size: 0.85em; + .column { flex: 1; - justify-content: center; min-width: 0; - white-space: nowrap; - } - &:hover { - color: var(--color-focus-blue); - background: rgba(176, 206, 255, 0.221); - } - - &.active { - background-color: var(--color-focus-blue); - color: #fff; + @media screen and (max-width: 1199px) { + display: contents; + } } } - -.tabCount { - font-family: var(--font-normal); - font-size: 0.7em; - margin-left: 6px; - padding: 2px 8px; - display: inline-flex; - justify-content: center; - align-items: center; - background-color: rgba(255, 255, 255, 0.15); - border-radius: 10px; - font-weight: 600; - min-width: 20px; - flex-shrink: 0; -} diff --git a/src/components/proxies/Proxies.tsx b/src/components/proxies/Proxies.tsx index 101e27e..bed071d 100644 --- a/src/components/proxies/Proxies.tsx +++ b/src/components/proxies/Proxies.tsx @@ -1,96 +1,86 @@ import cx from 'clsx'; import * as React from 'react'; -import { useTranslation } from 'react-i18next'; -import Button from '~/components/Button'; -import ContentHeader from '~/components/ContentHeader'; -import { ClosePrevConns } from '~/components/proxies/ClosePrevConns'; +import { ProxiesHeader } from '~/components/proxies/ProxiesHeader'; import { ProxyGroup } from '~/components/proxies/ProxyGroup'; -import { ProxyPageFab } from '~/components/proxies/ProxyPageFab'; import { ProxyProvider } from '~/components/proxies/ProxyProvider'; -import Settings from '~/components/proxies/Settings'; -import BaseModal from '~/components/shared/BaseModal'; -import { TextFilter } from '~/components/shared/TextFitler'; -import { Tooltip } from '~/components/shared/Tooltip'; -import { useStoreActions } from '~/components/StateProvider'; -import Equalizer from '~/components/svg/Equalizer'; -import { useProxiesPage } from '~/modules/proxies/hooks'; -import { formatQty } from '~/modules/proxies/utils'; -import { proxyFilterText } from '~/store/proxies'; -import { DelayMapping, DispatchFn, FormattedProxyProvider, ProxiesMapping } from '~/store/types'; +import { + useCollapseAll, + useDelayMapping, + useProxiesPage, + useProxiesQuery, + useTestAllLatency, + useUpdateProviderItems, + useVisibleGroupNames, + useVisibleProviders, +} from '~/modules/proxies/hooks'; +import { ProxiesAppConfig } from '~/modules/proxies/utils'; import { ClashAPIConfig } from '~/types'; import s0 from './Proxies.module.scss'; -type AppConfig = { - proxySortBy: string; - hideUnavailableProxies: boolean; - autoCloseOldConns: boolean; - proxiesLayout: string; - proxyGroupByProvider: boolean; - latencyTestUrl: string; - latencyTestTimeout: number; - latencyTestExpectedStatus: string; - preferBackendLatencyTestUrl: boolean; - providerHealthcheckTimeout: number; -}; - type Props = { - dispatch: DispatchFn; - groupNames: string[]; - proxies: ProxiesMapping; - delay: DelayMapping; collapsibleIsOpen: Record<string, boolean>; - proxyProviders: FormattedProxyProvider[]; apiConfig: ClashAPIConfig; - showModalClosePrevConns: boolean; - appConfig: AppConfig; + appConfig: ProxiesAppConfig; }; -export default function Proxies({ - dispatch, - groupNames, - proxies, - delay, - collapsibleIsOpen, - proxyProviders, - apiConfig, - showModalClosePrevConns, - appConfig, -}: Props) { +export default function Proxies({ collapsibleIsOpen, apiConfig, appConfig }: Props) { // the panel's configured test URL only feeds the latency-color threshold here; - // the actual URL used per request is resolved in the store thunks + // the actual URL used per request is resolved in the mutation hooks const httpsLatencyTest = appConfig.latencyTestUrl.startsWith('https://'); + + const { data, dataUpdatedAt } = useProxiesQuery(apiConfig); + const { proxies, groupNames, proxyProviders } = data; + const delay = useDelayMapping(proxies, dataUpdatedAt); + + // 搜索同时作用于代理组/提供商本身和它们旗下的节点 + const visibleGroupNames = useVisibleGroupNames(groupNames, proxies); + const visibleProviders = useVisibleProviders(proxyProviders); + const { - isSettingsModalOpen, - openSettingsModal, - closeSettingsModal, + isSettingsOpen, + toggleSettings, + closeSettings, activeTab, setActiveTab, - handleTabKeyDown, proxyGroups, providers, } = useProxiesPage({ - dispatch, - apiConfig, - groupNames, - proxyProviders, + groupNames: visibleGroupNames, + proxyProviders: visibleProviders, proxiesLayout: appConfig.proxiesLayout, }); - const { - proxies: { closeModalClosePrevConns, closePrevConnsAndTheModal }, - } = useStoreActions(); + const providerNames = React.useMemo( + () => proxyProviders.map((item) => item.name), + [proxyProviders], + ); + const visibleProviderNames = React.useMemo( + () => visibleProviders.map((item) => item.name), + [visibleProviders], + ); - const { t } = useTranslation(); + // 折叠是视图操作,只作用于搜索后可见的卡片 + const [toggleCollapseAll, allCollapsed] = useCollapseAll({ + prefix: activeTab === 'proxies' ? 'proxyGroup' : 'proxyProvider', + names: activeTab === 'proxies' ? visibleGroupNames : visibleProviderNames, + collapsibleIsOpen, + }); + + const [testAll, isTestingLatency] = useTestAllLatency(apiConfig, appConfig); + const [updateAllProviders, isUpdatingProviders] = useUpdateProviderItems( + apiConfig, + providerNames, + ); + + const containerClassName = cx(s0.groupsContainer, { + [s0.doubleColumn]: appConfig.proxiesLayout === 'double', + }); const content = activeTab === 'proxies' ? ( - <div - className={cx(s0.groupsContainer, { - [s0.doubleColumn]: appConfig.proxiesLayout === 'double', - })} - > + <div className={containerClassName}> {proxyGroups.map((column, i) => ( <div key={i} className={s0.column}> {column.map(({ name, i: originalIndex }) => ( @@ -99,13 +89,10 @@ export default function Proxies({ name={name} delay={delay} apiConfig={apiConfig} - dispatch={dispatch} + appConfig={appConfig} proxies={proxies} - hideUnavailableProxies={appConfig.hideUnavailableProxies} - proxySortBy={appConfig.proxySortBy} isOpen={Boolean(collapsibleIsOpen[`proxyGroup:${name}`])} httpsLatencyTest={httpsLatencyTest} - proxyGroupByProvider={appConfig.proxyGroupByProvider} /> </div> ))} @@ -113,11 +100,7 @@ export default function Proxies({ ))} </div> ) : ( - <div - className={cx(s0.groupsContainer, { - [s0.doubleColumn]: appConfig.proxiesLayout === 'double', - })} - > + <div className={containerClassName}> {providers.map((column, i) => ( <div key={i} className={s0.column}> {column.map(({ item, i: originalIndex }) => ( @@ -132,11 +115,9 @@ export default function Proxies({ proxyMapping={proxies} httpsLatencyTest={httpsLatencyTest} delay={delay} - hideUnavailableProxies={appConfig.hideUnavailableProxies} - proxySortBy={appConfig.proxySortBy} isOpen={Boolean(collapsibleIsOpen[`proxyProvider:${item.name}`])} - dispatch={dispatch} apiConfig={apiConfig} + appConfig={appConfig} /> </div> ))} @@ -146,58 +127,25 @@ export default function Proxies({ ); return ( - <> - <BaseModal isOpen={isSettingsModalOpen} onRequestClose={closeSettingsModal}> - <Settings appConfig={appConfig} /> - </BaseModal> - <div className={s0.topBar}> - <ContentHeader> - <div className={s0.tabsContainer}> - <div - className={cx(s0.tab, { [s0.active]: activeTab === 'proxies' })} - onClick={() => setActiveTab('proxies')} - onKeyDown={handleTabKeyDown('proxies')} - role="button" - tabIndex={0} - > - {t('Proxies')} - <span className={s0.tabCount}>{formatQty(groupNames.length)}</span> - </div> - {proxyProviders.length > 0 && ( - <div - className={cx(s0.tab, { [s0.active]: activeTab === 'providers' })} - onClick={() => setActiveTab('providers')} - onKeyDown={handleTabKeyDown('providers')} - role="button" - tabIndex={0} - > - {t('proxy_provider')} - <span className={s0.tabCount}>{formatQty(proxyProviders.length)}</span> - </div> - )} - </div> - <div style={{ flex: 1 }} /> - <div className={s0.topBarRight}> - <div className={s0.textFilterContainer}> - <TextFilter textAtom={proxyFilterText} placeholder={t('Search')} /> - </div> - <Tooltip label={t('settings')}> - <Button kind="minimal" onClick={openSettingsModal}> - <Equalizer size={16} /> - </Button> - </Tooltip> - </div> - </ContentHeader> - </div> + <div className={s0.page}> + <ProxiesHeader + activeTab={activeTab} + setActiveTab={setActiveTab} + groupCount={visibleGroupNames.length} + providerCount={proxyProviders.length} + visibleProviderCount={visibleProviders.length} + appConfig={appConfig} + isSettingsOpen={isSettingsOpen} + toggleSettings={toggleSettings} + closeSettings={closeSettings} + onToggleCollapseAll={toggleCollapseAll} + allCollapsed={allCollapsed} + onTestAll={testAll} + isTestingLatency={isTestingLatency} + onUpdateAllProviders={updateAllProviders} + isUpdatingProviders={isUpdatingProviders} + /> {content} - <div style={{ height: 60 }} /> - <ProxyPageFab dispatch={dispatch} apiConfig={apiConfig} proxyProviders={proxyProviders} /> - <BaseModal isOpen={showModalClosePrevConns} onRequestClose={closeModalClosePrevConns}> - <ClosePrevConns - onClickPrimaryButton={() => closePrevConnsAndTheModal(apiConfig)} - onClickSecondaryButton={closeModalClosePrevConns} - /> - </BaseModal> - </> + </div> ); } diff --git a/src/components/proxies/ProxiesHeader.module.scss b/src/components/proxies/ProxiesHeader.module.scss new file mode 100644 index 0000000..22c1006 --- /dev/null +++ b/src/components/proxies/ProxiesHeader.module.scss @@ -0,0 +1,22 @@ +/** + * 只放代理页特有的东西,顶栏本身的样式在 shared/PageHeader.module.scss。 + * + * DOM 顺序就是桌面端的视觉顺序(标题 · 标签 · 搜索 · 操作), + * 窄屏要把操作钮提到第一行、搜索独占第二行,所以这里只拨 order。 + * 代理页的操作钮不多,标题 + 标签 + 操作钮一行放得下;不这么排的话 + * 搜索会先换行并吃掉整行(它的 flex-basis 是 100%),把操作钮挤成第三行。 + * + * 换行本身不用额外的占位元素——搜索的 flex-basis: 100% 已经保证了它独占一行。 + */ + +.actions { + @media (max-width: 1024px) { + order: 1; + } +} + +.search { + @media (max-width: 1024px) { + order: 2; + } +} diff --git a/src/components/proxies/ProxiesHeader.tsx b/src/components/proxies/ProxiesHeader.tsx new file mode 100644 index 0000000..3915d85 --- /dev/null +++ b/src/components/proxies/ProxiesHeader.tsx @@ -0,0 +1,136 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ChevronDown, ChevronUp, Sliders, Zap } from '~/components/shared/FeatherIcons'; +import { + HeaderActions, + HeaderButton, + HeaderIconButton, + HeaderSearch, + HeaderTab, + HeaderTabs, + HeaderTitle, + PageHeader, +} from '~/components/shared/PageHeader'; +import { Popover } from '~/components/shared/Popover'; +import { RotateIcon } from '~/components/shared/RotateIcon'; +import { TextFilter } from '~/components/shared/TextFilter'; +import { proxyFilterText } from '~/store/proxies'; + +import s from './ProxiesHeader.module.scss'; +import Settings from './Settings'; + +type AppConfig = React.ComponentProps<typeof Settings>['appConfig']; + +type TabKey = 'proxies' | 'providers'; + +type Props = { + activeTab: TabKey; + setActiveTab: (tab: TabKey) => void; + /** 搜索后可见的代理组数 */ + groupCount: number; + /** 提供商总数,决定标签是否出现(搜索时标签不该消失) */ + providerCount: number; + /** 搜索后可见的提供商数,只影响标签上的计数 */ + visibleProviderCount: number; + appConfig: AppConfig; + isSettingsOpen: boolean; + toggleSettings: () => void; + closeSettings: () => void; + onToggleCollapseAll: () => void; + allCollapsed: boolean; + onTestAll: () => void; + isTestingLatency: boolean; + onUpdateAllProviders: () => void; + isUpdatingProviders: boolean; +}; + +export function ProxiesHeader({ + activeTab, + setActiveTab, + groupCount, + providerCount, + visibleProviderCount, + appConfig, + isSettingsOpen, + toggleSettings, + closeSettings, + onToggleCollapseAll, + allCollapsed, + onTestAll, + isTestingLatency, + onUpdateAllProviders, + isUpdatingProviders, +}: Props) { + const { t } = useTranslation(); + + return ( + <PageHeader> + <HeaderTitle>{t('Proxies')}</HeaderTitle> + + <HeaderTabs label={t('Proxies')}> + <HeaderTab + active={activeTab === 'proxies'} + label={t('proxy_groups')} + count={groupCount} + onClick={() => setActiveTab('proxies')} + /> + {providerCount > 0 ? ( + <HeaderTab + active={activeTab === 'providers'} + label={t('providers')} + count={visibleProviderCount} + onClick={() => setActiveTab('providers')} + /> + ) : null} + </HeaderTabs> + + <HeaderSearch className={s.search}> + <TextFilter textAtom={proxyFilterText} placeholder={t('search_proxies_placeholder')} /> + </HeaderSearch> + + <HeaderActions className={s.actions}> + {activeTab === 'providers' && providerCount > 0 ? ( + <HeaderButton + icon={<RotateIcon isRotating={isUpdatingProviders} />} + label={t('update_all_proxy_provider')} + hideLabelAt="md" + onClick={onUpdateAllProviders} + /> + ) : null} + + <HeaderButton + icon={allCollapsed ? <ChevronDown size={17} /> : <ChevronUp size={17} />} + label={allCollapsed ? t('expand_all') : t('collapse_all')} + onClick={onToggleCollapseAll} + /> + + <HeaderButton + variant="primary" + icon={<Zap size={16} />} + label={isTestingLatency ? t('testing') : t('test_all')} + busy={isTestingLatency} + disabled={isTestingLatency} + onClick={onTestAll} + /> + + <Popover + isOpen={isSettingsOpen} + onClose={closeSettings} + label={t('settings')} + trigger={ + <HeaderIconButton + icon={<Sliders size={17} />} + label={t('settings')} + active={isSettingsOpen} + expanded={isSettingsOpen} + onClick={toggleSettings} + /> + } + > + <Settings appConfig={appConfig} /> + </Popover> + </HeaderActions> + </PageHeader> + ); +} diff --git a/src/components/proxies/Proxy.module.scss b/src/components/proxies/Proxy.module.scss index 066026f..3ae9511 100644 --- a/src/components/proxies/Proxy.module.scss +++ b/src/components/proxies/Proxy.module.scss @@ -1,125 +1,100 @@ -@use '~/styles/utils/custom-media' as *; - .proxy { - padding: 5px; + padding: 9px 11px; position: relative; - border-radius: var(--border-radius); - overflow: hidden; - box-shadow: var(--shadow-card); - transition: transform 0.2s ease, box-shadow 0.2s ease; - display: flex; flex-direction: column; - justify-content: space-between; - - border: 1px solid var(--color-proxy-border); + gap: 5px; + min-width: 0; + border-radius: 10px; + border: 1px solid var(--color-card-border); + background-color: var(--color-card); + color: var(--color-text); + transition: + border-color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.15s ease; - &:focus { + &:focus-visible { + outline: none; border-color: var(--color-focus-blue); - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.4); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25); } - &:hover { - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); - z-index: 1; - } - - @media (--breakpoint-not-small) { - border-radius: var(--border-radius); - padding: 10px; - } - - background-color: var(--color-bg-proxy); - color: var(--color-text); - &.now { - background-color: var(--color-sb-active-row-bg); - color: var(--color-sb-active-row-font); + background-color: var(--color-node-active-bg); border-color: var(--color-focus-blue); + box-shadow: inset 0 0 0 1px var(--color-focus-blue); } &.error { opacity: 0.5; } + &.selectable { - transition: transform 0.2s ease-in-out; cursor: pointer; + &:hover { - border-color: var(--card-hover-border-lightness); + border-color: var(--color-focus-blue); } } } -.proxyType { - font-family: var(--font-mono); - font-size: 0.6em; - @media (--breakpoint-not-small) { - font-size: 0.7em; - } - color: #f596aa; - opacity: 0.6; - - .now & { - color: inherit; - opacity: 0.8; - } -} -.udpType { - font-family: var(--font-mono); - font-size: 0.6em; - @media (--breakpoint-not-small) { - font-size: 0.7em; - } - color: #51a8dd; - opacity: 0.6; - - .now & { - color: inherit; - opacity: 0.8; - } -} -.tfoType { - padding: 2px; -} -.row { +.topRow, +.bottomRow { display: flex; align-items: center; - height: auto; - font-weight: 400; justify-content: space-between; + gap: 8px; + min-width: 0; } .proxyName { - width: 100%; - margin-bottom: 5px; - font-size: 0.75em; - @media (--breakpoint-not-small) { - font-size: 0.85em; - } + font-size: 0.82rem; + font-weight: 500; + color: var(--color-text-highlight); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; } +.udpBadge { + flex-shrink: 0; + font-family: var(--font-mono); + font-size: 0.6rem; + font-weight: 500; + line-height: 1; + letter-spacing: 0.02em; + padding: 3px 5px; + border-radius: 5px; + background: var(--color-badge-bg); + color: var(--color-badge-fg); +} + +.proxyType { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.72rem; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +/* ---------- 折叠态圆点 ---------- */ + .proxySmall { - position: relative; - width: 15px; - height: 15px; + width: 12px; + height: 12px; border-radius: 50%; - - .now { - position: absolute; - width: 9px; - height: 9px; - margin: auto; - top: 0; - right: 0; - bottom: 0; - left: 0; - border-radius: 50%; - background-color: rgb(255, 253, 253); - } + flex-shrink: 0; &.selectable { - transition: transform 0.1s ease-in-out; cursor: pointer; + transition: transform 0.1s ease-in-out; + &:hover { transform: scale(1.5); } diff --git a/src/components/proxies/Proxy.tsx b/src/components/proxies/Proxy.tsx index dd5a38c..ac140fe 100644 --- a/src/components/proxies/Proxy.tsx +++ b/src/components/proxies/Proxy.tsx @@ -1,12 +1,8 @@ import cx from 'clsx'; import * as React from 'react'; -import { Tooltip } from '~/components/shared/Tooltip'; import { keyCodes } from '~/misc/keycode'; -import { DispatchFn, ProxyItem } from '~/store/types'; -import { ClashAPIConfig } from '~/types'; - -import { healthcheckProxy } from '../../store/proxies'; +import { ProxyItem } from '~/store/types'; import s0 from './Proxy.module.scss'; import { ProxyLatency } from './ProxyLatency'; @@ -20,7 +16,6 @@ const colorMap = { normal: '#d4b75c', // orange bad: '#e67f3c', - // bad: '#F56C6C', na: '#909399', }; @@ -30,47 +25,37 @@ function getLabelColor( }: { number?: number; } = {}, - httpsTest: boolean + httpsTest: boolean, ) { const delayMap = { good: httpsTest ? 800 : 200, normal: httpsTest ? 1500 : 500, }; - if (number === 0) { + // 没测过(undefined)和测出 0 都算不可用;原本靠 undefined 参与比较恒为 false 落到最后一行 + if (typeof number !== 'number' || number === 0) { return colorMap.na; - } else if (number < delayMap.good) { + } + if (number < delayMap.good) { return colorMap.good; - } else if (number < delayMap.normal) { + } + if (number < delayMap.normal) { return colorMap.normal; - } else if (typeof number === 'number') { - return colorMap.bad; } - return colorMap.na; -} - -function getProxyDotBackgroundColor( - latency: { - number?: number; - }, - // proxyType: string, - httpsTest: boolean -) { - // if (NonProxyTypes.indexOf(proxyType) > -1) { - // return 'linear-gradient(135deg, white 15%, #999 15% 30%, white 30% 45%, #999 45% 60%, white 60% 75%, #999 75% 90%, white 90% 100%)'; - // } - return getLabelColor(latency, httpsTest); + return colorMap.bad; } -type ProxyProps = { +type ProxyBaseProps = { name: string; now?: boolean; proxy: ProxyItem; - latency: { number?: number; error?: string; testing?: boolean }; + latency?: { number?: number; error?: string; testing?: boolean }; httpsLatencyTest: boolean; isSelectable?: boolean; onClick?: (proxyName: string) => unknown; - apiConfig: ClashAPIConfig; - dispatch: DispatchFn; +}; + +type ProxyProps = ProxyBaseProps & { + onTestLatency: (name: string, providerName?: string) => void; }; export const ProxySmall = memo(function ProxySmall({ @@ -81,12 +66,12 @@ export const ProxySmall = memo(function ProxySmall({ httpsLatencyTest, isSelectable, onClick, -}: ProxyProps) { +}: ProxyBaseProps) { const delay = proxy.history[proxy.history.length - 1]?.delay; const latencyNumber = latency?.number ?? delay; const color = useMemo( - () => getProxyDotBackgroundColor({ number: latencyNumber }, httpsLatencyTest), - [latencyNumber, httpsLatencyTest] + () => getLabelColor({ number: latencyNumber }, httpsLatencyTest), + [latencyNumber, httpsLatencyTest], ); const title = useMemo(() => { @@ -107,22 +92,28 @@ export const ProxySmall = memo(function ProxySmall({ doSelect(); } }, - [doSelect] + [doSelect], ); + // 当前选中的节点画成空心圆:用单个元素的 inset 阴影描边, + // 避免再叠一个绝对定位的内圆——两个盒子各自取整会让圆心看起来偏掉 + const dotStyle = now + ? { background: 'var(--color-card)', boxShadow: `inset 0 0 0 3px ${color}` } + : { background: color }; + return ( + // role 是条件表达式,oxlint 静态分析不出来 + // oxlint-disable-next-line jsx-a11y/no-static-element-interactions <div title={title} className={cx(s0.proxySmall, { [s0.selectable]: isSelectable, })} - style={{ background: color, scale: now ? '1.2' : '1' }} + style={dotStyle} onClick={doSelect} onKeyDown={handleKeyDown} role={isSelectable ? 'menuitem' : ''} - > - {now && <div className={s0.now} />} - </div> + /> ); }); @@ -131,6 +122,29 @@ function formatProxyType(t: string) { return t; } +function formatUdpType(udp: boolean, xudp?: boolean) { + if (!udp) return ''; + return xudp ? 'XUDP' : 'UDP'; +} + +function TfoIcon() { + return ( + <svg + viewBox="0 0 1024 1024" + version="1.1" + xmlns="http://www.w3.org/2000/svg" + width="10" + height="10" + aria-label="TFO" + > + <path + fill="currentColor" + d="M648.093513 719.209284l-1.492609-40.940127 31.046263-26.739021c202.73892-174.805813 284.022131-385.860697 255.70521-561.306199-176.938111-28.786027-389.698834 51.857494-563.907604 254.511123l-26.31256 30.619803-40.38573-0.938211c-60.557271-1.407317-111.903014 12.79379-162.822297 47.0385l189.561318 127.084977-37.95491 68.489421c-9.126237 16.461343-0.554398 53.307457 29.084549 82.818465 29.5963 29.511008 67.380626 38.381369 83.287571 29.852176l68.318836-36.760822 127.639376 191.267156c36.163779-52.11337 50.450177-103.629696 48.189941-165.039887zM994.336107 16.105249l10.490908 2.686696 2.64405 10.405615c47.46496 178.089552-1.023503 451.492838-274.170913 686.898568 4.051367 111.263324-35.396151 200.222809-127.255561 291.741051l-15.779008 15.693715-145.934494-218.731157c-51.217805 27.59194-128.790816 10.405616-183.93205-44.522388-55.226525-55.013296-72.41285-132.287785-43.498885-184.529093L0.002773 430.325513l15.736362-15.65107c89.300652-88.959484 178.64395-128.108481 289.011709-125.549722C539.730114 15.806727 815.56422-31.061189 994.336107 16.105249zM214.93844 805.098259c28.572797 28.572797 22.346486 79.49208-12.537914 114.376479C156.428175 965.489735 34.034254 986.002445 34.034254 986.002445s25.331704-127.084978 66.612998-168.323627c34.8844-34.8844 85.633099-41.281295 114.291188-12.580559zM661.01524 298.549479a63.968948 63.968948 0 1 0 0 127.937897 63.968948 63.968948 0 0 0 0-127.937897z" + /> + </svg> + ); +} + export const Proxy = memo(function Proxy({ now, name, @@ -139,56 +153,35 @@ export const Proxy = memo(function Proxy({ httpsLatencyTest, isSelectable, onClick, - apiConfig, - dispatch, + onTestLatency, }: ProxyProps) { const delay = proxy.history[proxy.history.length - 1]?.delay; const latencyNumber = typeof latency?.number === 'number' ? latency.number : typeof delay === 'number' - ? delay - : undefined; + ? delay + : undefined; const hasLatencyNumber = typeof latencyNumber === 'number' && latencyNumber > 0; const color = useMemo( () => getLabelColor({ number: hasLatencyNumber ? latencyNumber : undefined }, httpsLatencyTest), - [hasLatencyNumber, latencyNumber, httpsLatencyTest] + [hasLatencyNumber, latencyNumber, httpsLatencyTest], ); const isTestingLatency = Boolean(latency?.testing); const doSelect = React.useCallback(() => { isSelectable && onClick && onClick(name); }, [name, onClick, isSelectable]); - function formatUdpType(udp: boolean, xudp?: boolean) { - if (!udp) return ''; - return xudp ? 'XUDP' : 'UDP'; - } - function formatTfo(t: boolean) { - if (!t) return ''; - return ( - <svg - viewBox="0 0 1024 1024" - version="1.1" - xmlns="http://www.w3.org/2000/svg" - p-id="2962" - width="10" - height="10" - > - <path - d="M648.093513 719.209284l-1.492609-40.940127 31.046263-26.739021c202.73892-174.805813 284.022131-385.860697 255.70521-561.306199-176.938111-28.786027-389.698834 51.857494-563.907604 254.511123l-26.31256 30.619803-40.38573-0.938211c-60.557271-1.407317-111.903014 12.79379-162.822297 47.0385l189.561318 127.084977-37.95491 68.489421c-9.126237 16.461343-0.554398 53.307457 29.084549 82.818465 29.5963 29.511008 67.380626 38.381369 83.287571 29.852176l68.318836-36.760822 127.639376 191.267156c36.163779-52.11337 50.450177-103.629696 48.189941-165.039887zM994.336107 16.105249l10.490908 2.686696 2.64405 10.405615c47.46496 178.089552-1.023503 451.492838-274.170913 686.898568 4.051367 111.263324-35.396151 200.222809-127.255561 291.741051l-15.779008 15.693715-145.934494-218.731157c-51.217805 27.59194-128.790816 10.405616-183.93205-44.522388-55.226525-55.013296-72.41285-132.287785-43.498885-184.529093L0.002773 430.325513l15.736362-15.65107c89.300652-88.959484 178.64395-128.108481 289.011709-125.549722C539.730114 15.806727 815.56422-31.061189 994.336107 16.105249zM214.93844 805.098259c28.572797 28.572797 22.346486 79.49208-12.537914 114.376479C156.428175 965.489735 34.034254 986.002445 34.034254 986.002445s25.331704-127.084978 66.612998-168.323627c34.8844-34.8844 85.633099-41.281295 114.291188-12.580559zM661.01524 298.549479a63.968948 63.968948 0 1 0 0 127.937897 63.968948 63.968948 0 0 0 0-127.937897z" - p-id="2963" - /> - </svg> - ); - } + const handleKeyDown = React.useCallback( (e: React.KeyboardEvent) => { if (e.keyCode === keyCodes.Enter) { doSelect(); } }, - [doSelect] + [doSelect], ); + const className = useMemo(() => { return cx(s0.proxy, { [s0.now]: now, @@ -199,10 +192,14 @@ export const Proxy = memo(function Proxy({ const runLatencyTest = React.useCallback(() => { if (isTestingLatency) return; - dispatch(healthcheckProxy(apiConfig, name)); - }, [apiConfig, dispatch, isTestingLatency, name]); + onTestLatency(name, proxy.providerName); + }, [onTestLatency, isTestingLatency, name, proxy.providerName]); + + const udpLabel = formatUdpType(proxy.udp, proxy.xudp); return ( + // role 是条件表达式,oxlint 静态分析不出来 + // oxlint-disable-next-line jsx-a11y/no-static-element-interactions <div tabIndex={0} className={className} @@ -210,23 +207,18 @@ export const Proxy = memo(function Proxy({ onKeyDown={handleKeyDown} role={isSelectable ? 'menuitem' : ''} > - <div className={cx(s0.proxyName, s0.row)}> - <Tooltip label={name} aria-label={`proxy name: ${name}`}> - <span>{name}</span> - </Tooltip> - <span className={s0.udpType} style={{ paddingLeft: 4 }}> - {formatUdpType(proxy.udp, proxy.xudp)} + <div className={s0.topRow}> + <span className={s0.proxyName} title={name}> + {name} </span> + {udpLabel ? <span className={s0.udpBadge}>{udpLabel}</span> : null} </div> - <div className={s0.row}> - <div className={s0.row}> - <span className={s0.proxyType} style={{ paddingRight: 4 }}> - {formatProxyType(proxy.type)} - </span> - - {formatTfo(proxy.tfo)} - </div> + <div className={s0.bottomRow}> + <span className={s0.proxyType}> + {formatProxyType(proxy.type)} + {proxy.tfo ? <TfoIcon /> : null} + </span> <ProxyLatency number={hasLatencyNumber ? latencyNumber : undefined} diff --git a/src/components/proxies/ProxyCard.module.scss b/src/components/proxies/ProxyCard.module.scss new file mode 100644 index 0000000..b6a956a --- /dev/null +++ b/src/components/proxies/ProxyCard.module.scss @@ -0,0 +1,232 @@ +.card { + padding: 12px 14px; + background-color: var(--color-card); + border: 1px solid var(--color-card-border); + border-radius: 14px; + box-shadow: var(--shadow-card); + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +/* ---------- 头部 ---------- */ + +.cardHeader { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + cursor: pointer; + user-select: none; + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: 4px; + border-radius: 6px; + } + + // 未 hover 时测速按钮隐形但占位,避免布局跳动 + &:hover .iconAction, + &:focus-within .iconAction { + opacity: 1; + pointer-events: auto; + } +} + +.name { + font-size: 0.98rem; + font-weight: 600; + color: var(--color-text-highlight); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.typeBadge { + flex-shrink: 0; + font-size: 0.68rem; + font-weight: 500; + line-height: 1; + padding: 4px 7px; + border-radius: 6px; + background: var(--color-badge-bg); + color: var(--color-badge-fg); +} + +.fixedBadge { + flex-shrink: 0; + font-size: 0.68rem; + font-weight: 500; + line-height: 1; + padding: 4px 7px; + border-radius: 6px; + background: var(--color-accent-soft-bg); + color: var(--color-accent-soft-fg); + cursor: help; +} + +.headerSpacer { + flex: 1; + min-width: 8px; +} + +.headerActions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.iconAction { + display: inline-flex; + align-items: center; + justify-content: center; + appearance: none; + width: 26px; + height: 26px; + padding: 0; + border: none; + border-radius: 7px; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + // 隐藏时同时关掉命中区域,避免误点到看不见的按钮 + opacity: 0; + pointer-events: none; + transition: + opacity 0.15s ease, + background-color 0.15s ease, + color 0.15s ease; + + &:hover { + background: var(--color-hover-soft); + color: var(--color-focus-blue); + } + + &:focus-visible { + opacity: 1; + pointer-events: auto; + outline: 2px solid var(--color-focus-blue); + outline-offset: 1px; + } + + // 触屏设备没有 hover,常驻显示 + @media (hover: none) { + opacity: 1; + pointer-events: auto; + } +} + +.iconActionBusy { + opacity: 1; + pointer-events: auto; + color: var(--color-focus-blue); + animation: zapPulse 1s ease-in-out infinite; +} + +@keyframes zapPulse { + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 1; + } +} + +.headerLatency { + flex-shrink: 0; + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 500; +} + +.chevron { + display: inline-flex; + flex-shrink: 0; + color: var(--color-text-secondary); + transform: rotate(0deg); + transition: transform 0.3s ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +/* ---------- 状态行(当前节点 + 圆点) ---------- */ + +.statusRow { + display: flex; + align-items: center; + gap: 12px; + margin-top: 8px; + // 至少要能放下 hover 放大后的圆点(12px × 1.5) + min-height: 22px; +} + +.nowName { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 1 auto; + min-width: 0; + font-size: 0.8rem; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nowDot { + flex-shrink: 0; + width: 9px; + height: 9px; + border-radius: 50%; +} + +/* 圆点插槽靠右(左侧没有当前节点时改为靠左),宽度由 flex 决定而不是内容, + 测量它来决定圆点还是进度条 */ +.dotsSlot { + flex: 1 1 auto; + min-width: 0; + // 撑满状态行高度、右侧留出 padding,这样 hover 放大的圆点不会被 overflow 裁掉; + // padding 不影响测量(用的是 content box 宽度) + align-self: stretch; + padding-right: 3px; + margin-right: -3px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.dotsSlotStart { + justify-content: flex-start; + padding-right: 0; + margin-right: 0; + padding-left: 3px; + margin-left: -3px; +} + +.availBar { + width: 100%; + max-width: 220px; + display: flex; + align-items: center; +} + +.availBarTrack { + flex: 1; + height: 5px; + border-radius: 3px; + background: var(--color-track); + overflow: hidden; +} + +.availBarFill { + height: 100%; + border-radius: 3px; + background: #67c23a; + transition: width 0.4s ease; +} diff --git a/src/components/proxies/ProxyCard.tsx b/src/components/proxies/ProxyCard.tsx new file mode 100644 index 0000000..03e7a4b --- /dev/null +++ b/src/components/proxies/ProxyCard.tsx @@ -0,0 +1,214 @@ +import cx from 'clsx'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ChevronDown, Zap } from '~/components/shared/FeatherIcons'; +import { DelayMapping } from '~/store/types'; + +import s from './ProxyCard.module.scss'; + +const { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } = React; + +// 折叠态圆点的排布节奏:圆点 12px + 间距 5px +const DOT_PITCH = 17; + +export function getLatencyColor(number: number | undefined, httpsTest: boolean): string { + if (!number || number === 0) return '#909399'; + const good = httpsTest ? 800 : 200; + const normal = httpsTest ? 1500 : 500; + if (number < good) return '#67c23a'; + if (number < normal) return '#d4b75c'; + return '#e67f3c'; +} + +function countAvailableProxies(names: string[], delay: DelayMapping): number { + return names.filter((name) => { + const d = delay[name]; + return d && typeof d.number === 'number' && d.number > 0; + }).length; +} + +/** 代理组 / 提供商卡片的外壳 */ +export function ProxyCard({ children }: { children: React.ReactNode }) { + return <div className={s.card}>{children}</div>; +} + +/** 卡片头部:名称 + 类型徽章 + 右侧延迟 / 测速 / 折叠箭头。整行点击折叠。 */ +export function ProxyCardHeader({ + name, + type, + isOpen, + toggle, + badges, + latency, + latencyColor, + onTest, + isTesting, + extraActions, +}: { + name: string; + type: string; + isOpen: boolean; + toggle: () => void; + badges?: React.ReactNode; + latency?: number; + latencyColor?: string; + onTest?: () => void; + isTesting?: boolean; + extraActions?: React.ReactNode; +}) { + const { t } = useTranslation(); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(); + } + }, + [toggle], + ); + + return ( + <div + className={s.cardHeader} + onClick={toggle} + onKeyDown={handleKeyDown} + role="button" + tabIndex={0} + aria-expanded={isOpen} + > + <span className={s.name} title={name}> + {name} + </span> + {type ? <span className={s.typeBadge}>{type}</span> : null} + {badges} + <span className={s.headerSpacer} /> + {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} + <div className={s.headerActions} onClick={(e) => e.stopPropagation()}> + {extraActions} + {onTest ? ( + <button + type="button" + className={cx(s.iconAction, { [s.iconActionBusy]: isTesting })} + onClick={onTest} + title={t('Test Latency')} + aria-label={t('Test Latency')} + disabled={isTesting} + > + <Zap size={15} /> + </button> + ) : null} + </div> + {typeof latency === 'number' && latency > 0 ? ( + <span className={s.headerLatency} style={{ color: latencyColor }}> + {latency} ms + </span> + ) : null} + <span className={cx(s.chevron, { [s.chevronOpen]: isOpen })} aria-hidden> + <ChevronDown size={18} /> + </span> + </div> + ); +} + +/** 头部下方的可点击图标按钮(提供商卡片的「更新」等) */ +export function ProxyCardAction({ + onClick, + title, + children, + isBusy, +}: { + onClick: () => void; + title: string; + children: React.ReactNode; + isBusy?: boolean; +}) { + return ( + <button + type="button" + className={cx(s.iconAction, { [s.iconActionBusy]: isBusy })} + onClick={onClick} + title={title} + aria-label={title} + > + {children} + </button> + ); +} + +const AvailabilityBar = memo(function AvailabilityBar({ + all, + delay, +}: { + all: string[]; + delay: DelayMapping; +}) { + const total = all.length; + const available = useMemo(() => countAvailableProxies(all, delay), [all, delay]); + const pct = total > 0 ? Math.round((available / total) * 100) : 0; + + return ( + <div className={s.availBar} title={`${available}/${total}`}> + <div className={s.availBarTrack}> + <div className={s.availBarFill} style={{ width: `${pct}%` }} /> + </div> + </div> + ); +}); + +/** + * 状态行:左侧当前节点,右侧节点圆点(没有当前节点时圆点靠左)。圆点插槽的宽度由 flex 决定而不是内容, + * 所以可以直接测量它来判断一行放不放得下——放不下就退化成可用率进度条。 + */ +export function ProxyCardStatusRow({ + nowName, + nowColor, + itemCount, + allItems, + delay, + renderDots, +}: { + nowName?: string | null; + nowColor?: string; + /** 参与圆点渲染的节点数(过滤后) */ + itemCount: number; + /** 计算可用率用的完整节点列表 */ + allItems: string[]; + delay: DelayMapping; + renderDots: () => React.ReactNode; +}) { + const dotsSlotRef = useRef<HTMLDivElement>(null); + const [slotWidth, setSlotWidth] = useState(0); + + useLayoutEffect(() => { + const el = dotsSlotRef.current; + if (!el) return; + // sync read before first paint to avoid flash — content box, to match + // the ResizeObserver's contentRect below (the slot has horizontal padding) + const cs = getComputedStyle(el); + const w = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); + if (w > 0) setSlotWidth(w); + const ro = new ResizeObserver((entries) => { + setSlotWidth(entries[0].contentRect.width); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const dotsPerRow = slotWidth > 0 ? Math.floor(slotWidth / DOT_PITCH) : Infinity; + const showBar = itemCount > dotsPerRow; + + return ( + <div className={s.statusRow}> + {nowName ? ( + <span className={s.nowName} title={nowName}> + <i className={s.nowDot} style={{ background: nowColor }} aria-hidden /> + {nowName} + </span> + ) : null} + <div className={cx(s.dotsSlot, { [s.dotsSlotStart]: !nowName })} ref={dotsSlotRef}> + {showBar ? <AvailabilityBar all={allItems} delay={delay} /> : renderDots()} + </div> + </div> + ); +} diff --git a/src/components/proxies/ProxyGroup.module.scss b/src/components/proxies/ProxyGroup.module.scss deleted file mode 100644 index 19529cf..0000000 --- a/src/components/proxies/ProxyGroup.module.scss +++ /dev/null @@ -1,101 +0,0 @@ -.header { - margin-bottom: 12px; -} - -.group { - padding: 10px; - background-color: var(--bg-log-info-card); - border: 1px solid var(--color-separator); - border-radius: 12px; - box-shadow: var(--shadow-card); - transition: border-color 0.2s ease, box-shadow 0.2s ease; -} - -.zapWrapper { - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; -} - -.arrow { - display: inline-flex; - transform: rotate(0deg); - transition: transform 0.3s; - - &.isOpen { - transform: rotate(180deg); - } - - &:focus { - outline: var(--color-focus-blue) solid 1px; - } -} - -.groupHeader { - display: flex; - align-items: center; - justify-content: space-between; - user-select: none; - - .btnGroup { - display: flex; - flex-direction: row-reverse; - } - - @media screen and (min-width: 768px) { - justify-content: flex-start; - .btnGroup { - flex-direction: row; - } - } -} - -.nowRow { - display: flex; - align-items: center; - justify-content: space-between; - padding: 2px 5px 0; - min-width: 0; -} - -.nowName { - font-size: 0.8em; - color: var(--color-text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - flex: 1; -} - -.nowLatency { - font-size: 0.75em; - font-family: var(--font-mono); - flex-shrink: 0; - margin-left: 8px; -} - -.availBar { - margin: 8px 0; - height: 15px; - padding: 0 10px; - display: flex; - align-items: center; -} - -.availBarTrack { - flex: 1; - height: 6px; - border-radius: 3px; - background: var(--color-separator); - overflow: hidden; -} - -.availBarFill { - height: 100%; - border-radius: 3px; - background: #67c23a; - transition: width 0.4s ease; -} diff --git a/src/components/proxies/ProxyGroup.tsx b/src/components/proxies/ProxyGroup.tsx index d838685..f8be26d 100644 --- a/src/components/proxies/ProxyGroup.tsx +++ b/src/components/proxies/ProxyGroup.tsx @@ -1,25 +1,26 @@ -import { useSuspenseQuery } from '@tanstack/react-query'; -import cx from 'clsx'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; - -import { fetchVersion } from '~/api/version'; -import { ChevronDown, Zap } from '~/components/shared/FeatherIcons'; -import { useFilteredAndSorted } from '~/modules/proxies/hooks'; -import { getProxyLatency } from '~/modules/proxies/utils'; -import { switchProxy } from '~/store/proxies'; -import { DelayMapping, DispatchFn, ProxiesMapping, ProxyItem } from '~/store/types'; +import Collapsible from '~/components/shared/Collapsible'; +import { useVersion } from '~/hooks/useVersion'; +import { + useFilterAwareCollapse, + useFilteredAndSorted, + useFilterSegments, + useSwitchProxy, + useTestGroupLatency, + useTestProxyLatency, +} from '~/modules/proxies/hooks'; +import { getProxyLatency, matchesFilter, ProxiesAppConfig } from '~/modules/proxies/utils'; +import { useStoreActions } from '~/store/StateProvider'; +import { DelayMapping, ProxiesMapping, ProxyItem } from '~/store/types'; import { ClashAPIConfig } from '~/types'; -import Button from '../Button'; -import Collapsible from '../Collapsible'; -import CollapsibleSectionHeader from '../CollapsibleSectionHeader'; -import { useStoreActions } from '../StateProvider'; - -import s0 from './ProxyGroup.module.scss'; +import { getLatencyColor, ProxyCard, ProxyCardHeader, ProxyCardStatusRow } from './ProxyCard'; +import s0 from './ProxyCard.module.scss'; import { ProxyList, ProxyListGroupedByProvider, ProxyListSummaryView } from './ProxyList'; -const { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } = React; +const { memo, useCallback, useMemo } = React; function buildNowChain(proxies: ProxiesMapping, groupName: string): string | null { const group = proxies[groupName] as ProxyItem & { now?: string }; @@ -37,94 +38,51 @@ function buildNowChain(proxies: ProxiesMapping, groupName: string): string | nul depth++; } - return parts.join(' ⊙ '); -} - -function countAvailableProxies(names: string[], delay: DelayMapping): number { - return names.filter((name) => { - const d = delay[name]; - return d && typeof d.number === 'number' && d.number > 0; - }).length; + return parts.join(' › '); } -function getLatencyColor(number: number | undefined, httpsTest: boolean): string { - if (!number || number === 0) return '#909399'; - const good = httpsTest ? 800 : 200; - const normal = httpsTest ? 1500 : 500; - if (number < good) return '#67c23a'; - if (number < normal) return '#d4b75c'; - return '#e67f3c'; -} - -function ZapWrapper() { - return ( - <div className={s0.zapWrapper}> - <Zap size={16} /> - </div> - ); -} - -const ProxyAvailabilityBar = memo(function ProxyAvailabilityBar({ - all, - delay, -}: { - all: string[]; - delay: DelayMapping; -}) { - const total = all.length; - const available = useMemo(() => countAvailableProxies(all, delay), [all, delay]); - const pct = total > 0 ? Math.round((available / total) * 100) : 0; - - return ( - <div className={s0.availBar}> - <div className={s0.availBarTrack}> - <div className={s0.availBarFill} style={{ width: `${pct}%` }} /> - </div> - </div> - ); -}); - type Props = { name: string; delay: DelayMapping; - hideUnavailableProxies: boolean; - proxySortBy: string; proxies: ProxiesMapping; isOpen: boolean; httpsLatencyTest: boolean; apiConfig: ClashAPIConfig; - dispatch: DispatchFn; - proxyGroupByProvider?: boolean; + appConfig: ProxiesAppConfig; }; export const ProxyGroup = memo(function ProxyGroup({ name, delay, - hideUnavailableProxies, - proxySortBy, proxies, isOpen, httpsLatencyTest, apiConfig, - dispatch, - proxyGroupByProvider = false, + appConfig, }: Props) { + const { t } = useTranslation(); const group = proxies[name] as ProxyItem & { all?: string[]; now?: string; fixed?: string }; const { all: allItems = [], type, now, fixed } = group || {}; - const all = useFilteredAndSorted(allItems, delay, hideUnavailableProxies, proxySortBy, proxies); + // 组名本身命中搜索时,整组节点原样展示,不再逐个过滤 + const filterSegments = useFilterSegments(); + const nameMatched = matchesFilter(name, filterSegments); + const all = useFilteredAndSorted( + allItems, + delay, + appConfig.hideUnavailableProxies, + appConfig.proxySortBy, + proxies, + nameMatched, + ); const nowChain = useMemo(() => buildNowChain(proxies, name), [proxies, name]); const nowLatency = useMemo( () => (now ? getProxyLatency(proxies, delay, now) : undefined), [proxies, delay, now], ); - const availableCount = useMemo(() => countAvailableProxies(allItems, delay), [allItems, delay]); - const qtyLabel = `${availableCount}/${allItems.length}`; + const nowLatencyColor = getLatencyColor(nowLatency?.number, httpsLatencyTest); - const { data: version } = useSuspenseQuery({ - queryKey: ['/version', apiConfig], - queryFn: () => fetchVersion('/version', apiConfig), - }); + const version = useVersion(apiConfig); const isSelectable = useMemo( () => ['Selector', version.meta && 'Fallback', version.meta && 'URLTest'].includes(type), @@ -133,139 +91,78 @@ export const ProxyGroup = memo(function ProxyGroup({ const { app: { updateCollapsibleIsOpen }, - proxies: { requestDelayForGroup }, } = useStoreActions(); - const toggle = useCallback(() => { - updateCollapsibleIsOpen('proxyGroup', name, !isOpen); - }, [isOpen, updateCollapsibleIsOpen, name]); + const onToggle = useCallback( + (next: boolean) => updateCollapsibleIsOpen('proxyGroup', name, next), + [updateCollapsibleIsOpen, name], + ); + const [effectiveIsOpen, toggle] = useFilterAwareCollapse({ isOpen, nameMatched, onToggle }); + const switchProxy = useSwitchProxy(apiConfig, appConfig.autoCloseOldConns); const itemOnTapCallback = useCallback( - (proxyName) => { + (proxyName: string) => { if (!isSelectable) return; - dispatch(switchProxy(apiConfig, name, proxyName)); + switchProxy(name, proxyName); }, - [apiConfig, dispatch, name, isSelectable], + [switchProxy, name, isSelectable], ); - const [isTestingLatency, setIsTestingLatency] = useState(false); - // measure collapsed container to decide dots vs bar - const summaryContainerRef = useRef<HTMLDivElement>(null); - const [containerWidth, setContainerWidth] = useState(0); - useLayoutEffect(() => { - const el = summaryContainerRef.current; - if (!el) return; - // sync read before first paint to avoid flash - const w = el.offsetWidth; - if (w > 0) setContainerWidth(w); - const ro = new ResizeObserver((entries) => { - setContainerWidth(entries[0].contentRect.width); - }); - ro.observe(el); - return () => ro.disconnect(); - }, []); - // dot slot = 15px width + 10px gap; padding-left:10px eats into available space - // n items fit when 15 + (n-1)*25 <= containerWidth - 10 → n <= containerWidth/25 - const dotsPerRow = containerWidth > 0 ? Math.floor(containerWidth / 25) : Infinity; - const showBar = all.length > dotsPerRow; - const testLatency = useCallback(async () => { - setIsTestingLatency(true); - try { - await requestDelayForGroup(apiConfig, name, version.meta === true, all); - } catch (err) {} - setIsTestingLatency(false); - }, [all, apiConfig, name, version.meta, requestDelayForGroup]); + const [testGroup, isTestingLatency] = useTestGroupLatency(apiConfig, appConfig); + const testLatency = useCallback( + () => testGroup({ groupName: name, isMeta: version.meta === true, memberNames: all }), + [testGroup, name, version.meta, all], + ); + + const onTestLatency = useTestProxyLatency(apiConfig, appConfig); + + const listProps = { + all, + delay, + httpsLatencyTest, + now, + isSelectable, + itemOnTapCallback, + onTestLatency, + proxies, + }; return ( - <div className={s0.group}> - <div className={s0.groupHeader}> - <CollapsibleSectionHeader - name={name} - type={type} - toggle={toggle} - qty={qtyLabel} - fixed={!!fixed} - /> - <div className={s0.btnGroup}> - <Button - kind="minimal" - onClick={toggle} - className={s0.btn} - title="Toggle collapsible section" - > - <span className={cx(s0.arrow, { [s0.isOpen]: isOpen })}> - <ChevronDown size={20} /> + <ProxyCard> + <ProxyCardHeader + name={name} + type={type} + isOpen={effectiveIsOpen} + toggle={toggle} + latency={nowLatency?.number} + latencyColor={nowLatencyColor} + onTest={testLatency} + isTesting={isTestingLatency} + badges={ + fixed ? ( + <span className={s0.fixedBadge} title={t('group_fixed_tip')}> + {t('group_fixed')} </span> - </Button> - <Button - title="Test latency" - kind="minimal" - onClick={testLatency} - isLoading={isTestingLatency} - > - <ZapWrapper /> - </Button> - </div> - </div> - <Collapsible isOpen={isOpen}> - {proxyGroupByProvider ? ( - <ProxyListGroupedByProvider - apiConfig={apiConfig} - all={all} - delay={delay} - dispatch={dispatch} - httpsLatencyTest={httpsLatencyTest} - now={now} - isSelectable={isSelectable} - itemOnTapCallback={itemOnTapCallback} - proxies={proxies} - /> + ) : null + } + /> + + <ProxyCardStatusRow + nowName={nowChain} + nowColor={nowLatencyColor} + itemCount={all.length} + allItems={allItems} + delay={delay} + renderDots={() => <ProxyListSummaryView {...listProps} />} + /> + + <Collapsible isOpen={effectiveIsOpen}> + {appConfig.proxyGroupByProvider ? ( + <ProxyListGroupedByProvider {...listProps} /> ) : ( - <ProxyList - apiConfig={apiConfig} - all={all} - delay={delay} - dispatch={dispatch} - httpsLatencyTest={httpsLatencyTest} - now={now} - isSelectable={isSelectable} - itemOnTapCallback={itemOnTapCallback} - proxies={proxies} - /> - )} - </Collapsible> - <Collapsible isOpen={!isOpen}> - {nowChain && ( - <div className={s0.nowRow}> - <span className={s0.nowName}>⊙ {nowChain}</span> - {nowLatency?.number ? ( - <span - className={s0.nowLatency} - style={{ color: getLatencyColor(nowLatency.number, httpsLatencyTest) }} - > - {nowLatency.number} ms - </span> - ) : null} - </div> + <ProxyList {...listProps} /> )} - <div ref={summaryContainerRef}> - {showBar ? ( - <ProxyAvailabilityBar all={allItems} delay={delay} /> - ) : ( - <ProxyListSummaryView - apiConfig={apiConfig} - all={all} - delay={delay} - dispatch={dispatch} - httpsLatencyTest={httpsLatencyTest} - now={now} - isSelectable={isSelectable} - itemOnTapCallback={itemOnTapCallback} - proxies={proxies} - /> - )} - </div> </Collapsible> - </div> + </ProxyCard> ); }); diff --git a/src/components/proxies/ProxyLatency.module.scss b/src/components/proxies/ProxyLatency.module.scss index ac39100..b94b4e3 100644 --- a/src/components/proxies/ProxyLatency.module.scss +++ b/src/components/proxies/ProxyLatency.module.scss @@ -1,26 +1,23 @@ -@use '~/styles/utils/custom-media' as *; - .proxyLatency { display: inline-flex; align-items: center; - justify-content: center; - min-width: 50px; - padding: 4px 10px; + justify-content: flex-end; gap: 4px; - border-radius: 9999px; - border: 1px solid var(--color-proxy-border); - /* Use theme-aware latency background with sensible default */ - background: var(--bg-latency, #ffffff); + flex-shrink: 0; + padding: 2px 4px; + margin: -2px -4px; + border-radius: 6px; + font-family: var(--font-mono); + font-size: 0.74rem; + font-weight: 500; color: inherit; - font-size: 0.75em; - transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, - color 0.15s ease, transform 0.15s ease; + background: transparent; + transition: + background-color 0.15s ease, + color 0.15s ease; user-select: none; outline: none; - @media (--breakpoint-not-small) { - padding: 5px 12px; - font-size: 0.8em; - } + white-space: nowrap; } .clickable { @@ -29,9 +26,12 @@ .clickable:hover, .clickable:focus-visible { - background: var(--color-bg-proxy); - border-color: var(--card-hover-border-lightness); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + background: var(--color-hover-soft); +} + +.clickable:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: 1px; } .placeholder { diff --git a/src/components/proxies/ProxyLatency.tsx b/src/components/proxies/ProxyLatency.tsx index 8267f2e..facced7 100644 --- a/src/components/proxies/ProxyLatency.tsx +++ b/src/components/proxies/ProxyLatency.tsx @@ -64,7 +64,7 @@ export function ProxyLatency({ number, color, isTesting, error, onClick }: Proxy e.stopPropagation(); onClick(); }, - [isTesting, onClick] + [isTesting, onClick], ); const handleKeyDown = React.useCallback( @@ -76,10 +76,12 @@ export function ProxyLatency({ number, color, isTesting, error, onClick }: Proxy onClick(); } }, - [isTesting, onClick] + [isTesting, onClick], ); return ( + // role 是条件表达式,oxlint 静态分析不出来 + // oxlint-disable-next-line jsx-a11y/no-static-element-interactions <span className={className} style={{ color: hasNumber ? color : undefined }} diff --git a/src/components/proxies/ProxyList.module.scss b/src/components/proxies/ProxyList.module.scss index f4b1211..23c084a 100644 --- a/src/components/proxies/ProxyList.module.scss +++ b/src/components/proxies/ProxyList.module.scss @@ -1,33 +1,43 @@ -@use '~/styles/utils/custom-media' as *; - .list { - margin: 8px 0; display: grid; - grid-gap: 10px; + gap: 8px; } .detail { - grid-template-columns: auto auto; + margin-top: 12px; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - @media (--breakpoint-not-small) { - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + @media (max-width: 480px) { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } } .summary { - grid-template-columns: repeat(auto-fill, 15px); - padding-left: 10px; + display: flex; + flex-wrap: nowrap; + gap: 5px; + align-items: center; } .providerGroup { - margin-top: 8px; + margin-top: 12px; } .providerLabel { - font-size: 0.75rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 0.72rem; font-weight: 600; - color: var(--color-text-secondary, #909399); - padding: 2px 0 4px; - border-bottom: 1px solid var(--color-separator); - margin-bottom: 4px; + color: var(--color-text-secondary); +} + +.providerQty { + font-family: var(--font-mono); + font-weight: 400; +} + +.providerGroup .detail { + margin-top: 6px; } diff --git a/src/components/proxies/ProxyList.tsx b/src/components/proxies/ProxyList.tsx index 01ea729..1063941 100644 --- a/src/components/proxies/ProxyList.tsx +++ b/src/components/proxies/ProxyList.tsx @@ -1,9 +1,9 @@ import cx from 'clsx'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; import { getProxyLatency } from '~/modules/proxies/utils'; -import { DelayMapping, DispatchFn, ProxiesMapping } from '~/store/types'; -import { ClashAPIConfig } from '~/types'; +import { DelayMapping, ProxiesMapping } from '~/store/types'; import { Proxy, ProxySmall } from './Proxy'; import s from './ProxyList.module.scss'; @@ -13,8 +13,7 @@ type ProxyListProps = { proxies: ProxiesMapping; delay: DelayMapping; httpsLatencyTest: boolean; - apiConfig: ClashAPIConfig; - dispatch: DispatchFn; + onTestLatency: (name: string, providerName?: string) => void; now?: string; isSelectable?: boolean; itemOnTapCallback?: (x: string) => void; @@ -26,8 +25,7 @@ export function ProxyList({ proxies, delay, httpsLatencyTest, - apiConfig, - dispatch, + onTestLatency, now, isSelectable, itemOnTapCallback, @@ -46,9 +44,8 @@ export function ProxyList({ }; return ( <Proxy - apiConfig={apiConfig} - dispatch={dispatch} proxy={proxy} + onTestLatency={onTestLatency} latency={getProxyLatency(proxies, delay, proxyName)} httpsLatencyTest={httpsLatencyTest} key={proxyName} @@ -68,8 +65,6 @@ export function ProxyListSummaryView({ proxies, delay, httpsLatencyTest, - apiConfig, - dispatch, now, isSelectable, itemOnTapCallback, @@ -86,8 +81,6 @@ export function ProxyListSummaryView({ }; return ( <ProxySmall - apiConfig={apiConfig} - dispatch={dispatch} proxy={proxy} latency={getProxyLatency(proxies, delay, proxyName)} httpsLatencyTest={httpsLatencyTest} @@ -108,12 +101,12 @@ export function ProxyListGroupedByProvider({ proxies, delay, httpsLatencyTest, - apiConfig, - dispatch, + onTestLatency, now, isSelectable, itemOnTapCallback, }: ProxyListProps) { + const { t } = useTranslation(); // Group proxy names by their providerName const groups: { label: string; names: string[] }[] = React.useMemo(() => { const map = new Map<string, string[]>(); @@ -129,7 +122,12 @@ export function ProxyListGroupedByProvider({ <div> {groups.map(({ label, names }) => ( <div key={label} className={s.providerGroup}> - {label ? <div className={s.providerLabel}>{label}</div> : null} + {label ? ( + <div className={s.providerLabel}> + <span>{label}</span> + <span className={s.providerQty}>{t('node_qty', { n: names.length })}</span> + </div> + ) : null} <div className={cx(s.list, s.detail)}> {names.map((proxyName) => { const proxy = proxies[proxyName] || { @@ -141,9 +139,8 @@ export function ProxyListGroupedByProvider({ }; return ( <Proxy - apiConfig={apiConfig} - dispatch={dispatch} proxy={proxy} + onTestLatency={onTestLatency} latency={getProxyLatency(proxies, delay, proxyName)} httpsLatencyTest={httpsLatencyTest} key={proxyName} diff --git a/src/components/proxies/ProxyPageFab.tsx b/src/components/proxies/ProxyPageFab.tsx deleted file mode 100644 index 62739d7..0000000 --- a/src/components/proxies/ProxyPageFab.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { Action, Fab, IsFetching, position as fabPosition } from '~/components/shared/Fab'; -import { Zap } from '~/components/shared/FeatherIcons'; -import { RotateIcon } from '~/components/shared/RotateIcon'; -import { useTestLatencyAction, useUpdateProviderItems } from '~/modules/proxies/hooks'; -import { DispatchFn, FormattedProxyProvider } from '~/store/types'; -import { ClashAPIConfig } from '~/types'; - -function StatefulZap({ isLoading }: { isLoading: boolean }) { - return isLoading ? ( - <IsFetching> - <Zap width={16} height={16} /> - </IsFetching> - ) : ( - <Zap width={16} height={16} /> - ); -} - -export function ProxyPageFab({ - dispatch, - apiConfig, - proxyProviders, -}: { - dispatch: DispatchFn; - apiConfig: ClashAPIConfig; - proxyProviders: FormattedProxyProvider[]; -}) { - const { t } = useTranslation(); - const [requestDelayAllFn, isTestingLatency] = useTestLatencyAction({ - dispatch, - apiConfig, - }); - - const [updateProviders, isUpdating] = useUpdateProviderItems({ - apiConfig, - dispatch, - names: proxyProviders.map((item) => item.name), - }); - - return ( - <Fab - icon={<StatefulZap isLoading={isTestingLatency} />} - onClick={requestDelayAllFn} - text={t('Test Latency')} - style={fabPosition} - > - {proxyProviders.length > 0 ? ( - <Action text={t('update_all_proxy_provider')} onClick={updateProviders}> - <RotateIcon isRotating={isUpdating} /> - </Action> - ) : null} - </Fab> - ); -} diff --git a/src/components/proxies/ProxyProvider.module.scss b/src/components/proxies/ProxyProvider.module.scss index d22e50b..bee8a58 100644 --- a/src/components/proxies/ProxyProvider.module.scss +++ b/src/components/proxies/ProxyProvider.module.scss @@ -1,39 +1,20 @@ -@use '~/styles/utils/custom-media' as *; - -.updatedAt { - margin-bottom: 12px; - margin-left: 5px; - small { - color: #777; - } -} - -.body { - padding: 10px; - background-color: var(--bg-log-info-card); - border: 1px solid var(--color-separator); - border-radius: 12px; - box-shadow: var(--shadow-card); - transition: border-color 0.2s ease, box-shadow 0.2s ease; - - &:hover { - border-color: var(--color-focus-blue); - } -} - -.actionFooter { +.meta { display: flex; - button { - margin: 0 5px; - &:first-child { - margin-left: 0; - } - } + flex-wrap: wrap; + gap: 4px 12px; + margin-top: 6px; + font-size: 0.72rem; + color: var(--color-text-secondary); } -.refresh { - display: flex; - justify-content: center; - align-items: center; - cursor: pointer; +.qtyBadge { + flex-shrink: 0; + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 500; + line-height: 1; + padding: 4px 7px; + border-radius: 6px; + background: var(--color-badge-bg); + color: var(--color-badge-fg); } diff --git a/src/components/proxies/ProxyProvider.tsx b/src/components/proxies/ProxyProvider.tsx index 83e3f66..c681a0b 100644 --- a/src/components/proxies/ProxyProvider.tsx +++ b/src/components/proxies/ProxyProvider.tsx @@ -1,209 +1,169 @@ -import cx from 'clsx'; import { formatDistance } from 'date-fns'; -import { LazyMotion, domAnimation, m } from 'framer-motion'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; - -import Button from '~/components/Button'; -import Collapsible from '~/components/Collapsible'; -import CollapsibleSectionHeader from '~/components/CollapsibleSectionHeader'; -import s0 from '~/components/proxies/ProxyGroup.module.scss'; -import { ChevronDown, RotateCw, Zap } from '~/components/shared/FeatherIcons'; -import { useStoreActions } from '~/components/StateProvider'; -import { useFilteredAndSorted, useUpdateProviderItem } from '~/modules/proxies/hooks'; -import { healthcheckProviderByName } from '~/store/proxies'; -import { DelayMapping, DispatchFn, ProxiesMapping, SubscriptionInfo } from '~/store/types'; +import Collapsible from '~/components/shared/Collapsible'; +import { RotateIcon } from '~/components/shared/RotateIcon'; +import { + useFilterAwareCollapse, + useFilteredAndSorted, + useFilterSegments, + useHealthcheckProvider, + useTestProxyLatency, + useUpdateProviderItem, +} from '~/modules/proxies/hooks'; +import { matchesFilter, ProxiesAppConfig } from '~/modules/proxies/utils'; +import { useStoreActions } from '~/store/StateProvider'; +import { DelayMapping, ProxiesMapping, SubscriptionInfo } from '~/store/types'; import { ClashAPIConfig } from '~/types'; +import { ProxyCard, ProxyCardAction, ProxyCardHeader, ProxyCardStatusRow } from './ProxyCard'; import { ProxyList, ProxyListSummaryView } from './ProxyList'; import s from './ProxyProvider.module.scss'; -const { memo, useState, useCallback } = React; +const { memo, useCallback } = React; type Props = { name: string; proxies: Array<string>; delay: DelayMapping; - hideUnavailableProxies: boolean; - proxySortBy: string; type: 'Proxy' | 'Rule'; vehicleType: 'HTTP' | 'File' | 'Compatible'; updatedAt?: string; subscriptionInfo?: SubscriptionInfo; proxyMapping: ProxiesMapping; httpsLatencyTest: boolean; - dispatch: DispatchFn; isOpen: boolean; apiConfig: ClashAPIConfig; + appConfig: ProxiesAppConfig; }; export const ProxyProvider = memo(function ProxyProvider({ name, proxies: all, delay, - hideUnavailableProxies, - proxySortBy, vehicleType, updatedAt, subscriptionInfo, proxyMapping, httpsLatencyTest, isOpen, - dispatch, apiConfig, + appConfig, }: Props) { - const proxies = useFilteredAndSorted(all, delay, hideUnavailableProxies, proxySortBy); - const [isHealthcheckLoading, setIsHealthcheckLoading] = useState(false); + const { t } = useTranslation(); + // 提供商名本身命中搜索时,旗下节点原样展示,不再逐个过滤 + const filterSegments = useFilterSegments(); + const nameMatched = matchesFilter(name, filterSegments); + const proxies = useFilteredAndSorted( + all, + delay, + appConfig.hideUnavailableProxies, + appConfig.proxySortBy, + undefined, + nameMatched, + ); - const updateProvider = useUpdateProviderItem({ dispatch, apiConfig, name }); + const [updateProviderItem, isUpdating] = useUpdateProviderItem(apiConfig); + const updateProvider = useCallback(() => updateProviderItem(name), [updateProviderItem, name]); - const healthcheckProvider = useCallback(async () => { - setIsHealthcheckLoading(true); - await dispatch(healthcheckProviderByName(apiConfig, name)); - setIsHealthcheckLoading(false); - }, [apiConfig, dispatch, name, setIsHealthcheckLoading]); + const [healthcheck, isHealthcheckLoading] = useHealthcheckProvider( + apiConfig, + appConfig.providerHealthcheckTimeout, + ); + const healthcheckProvider = useCallback(() => healthcheck(name), [healthcheck, name]); const { app: { updateCollapsibleIsOpen }, } = useStoreActions(); - const toggle = useCallback(() => { - updateCollapsibleIsOpen('proxyProvider', name, !isOpen); - }, [isOpen, updateCollapsibleIsOpen, name]); + const onToggle = useCallback( + (next: boolean) => updateCollapsibleIsOpen('proxyProvider', name, next), + [updateCollapsibleIsOpen, name], + ); + const [effectiveIsOpen, toggle] = useFilterAwareCollapse({ isOpen, nameMatched, onToggle }); + + const onTestLatency = useTestProxyLatency(apiConfig, appConfig); - const timeAgo = formatDistance(new Date(updatedAt), new Date()); - const total = subscriptionInfo ? formatBytes(subscriptionInfo.Total) : 0; + const listProps = { + all: proxies, + proxies: proxyMapping, + delay, + httpsLatencyTest, + onTestLatency, + }; + + const timeAgo = updatedAt ? formatDistance(new Date(updatedAt), new Date()) : null; const used = subscriptionInfo ? formatBytes(subscriptionInfo.Download + subscriptionInfo.Upload) - : 0; + : null; + const total = subscriptionInfo ? formatBytes(subscriptionInfo.Total) : null; const percentage = subscriptionInfo ? ( ((subscriptionInfo.Download + subscriptionInfo.Upload) / subscriptionInfo.Total) * 100 - ).toFixed(2) - : 0; + ).toFixed(1) + : null; + const expireStr = () => { - if (subscriptionInfo.Expire === 0) { - return 'Null'; - } + if (!subscriptionInfo || subscriptionInfo.Expire === 0) return null; const expire = new Date(subscriptionInfo.Expire * 1000); - const getYear = expire.getFullYear() + '-'; - const getMonth = - (expire.getMonth() + 1 < 10 ? '0' + (expire.getMonth() + 1) : expire.getMonth() + 1) + '-'; - const getDate = (expire.getDate() < 10 ? '0' + expire.getDate() : expire.getDate()) + ' '; - return getYear + getMonth + getDate; + const month = String(expire.getMonth() + 1).padStart(2, '0'); + const date = String(expire.getDate()).padStart(2, '0'); + return `${expire.getFullYear()}-${month}-${date}`; }; + const expire = expireStr(); + return ( - <div className={s.body}> - <div - style={{ - display: 'flex', - alignItems: 'center', - flexWrap: 'wrap', - justifyContent: 'space-between', - userSelect: 'none', - }} - > - <CollapsibleSectionHeader - name={name} - toggle={toggle} - type={vehicleType} - isOpen={isOpen} - qty={proxies.length} - /> - <div style={{ display: 'flex' }}> - <Button - kind="minimal" - onClick={toggle} - className={s0.btn} - title="Toggle collapsible section" + <ProxyCard> + <ProxyCardHeader + name={name} + type={vehicleType} + isOpen={effectiveIsOpen} + toggle={toggle} + onTest={healthcheckProvider} + isTesting={isHealthcheckLoading} + badges={<span className={s.qtyBadge}>{t('node_qty', { n: proxies.length })}</span>} + extraActions={ + <ProxyCardAction + onClick={updateProvider} + title={t('update_proxy_provider')} + isBusy={isUpdating} > - <span className={cx(s0.arrow, { [s0.isOpen]: isOpen })}> - <ChevronDown size={20} /> - </span> - </Button> - <Button kind="minimal" start={<Refresh />} onClick={updateProvider} /> - <Button - kind="minimal" - start={<Zap size={16} />} - onClick={healthcheckProvider} - isLoading={isHealthcheckLoading} - /> - </div> - </div> - <div className={s.updatedAt}> - {subscriptionInfo && ( - <small> - {used} / {total} ( {percentage}% ) Expire: {expireStr()}{' '} - </small> - )} - <br /> - <small>Updated {timeAgo} ago</small> + <RotateIcon isRotating={isUpdating} /> + </ProxyCardAction> + } + /> + + <div className={s.meta}> + {subscriptionInfo ? ( + <span> + {used} / {total} ({percentage}%) + </span> + ) : null} + {expire ? <span>{t('expire_at', { date: expire })}</span> : null} + {timeAgo ? <span>{t('updated_ago', { time: timeAgo })}</span> : null} </div> - <Collapsible isOpen={isOpen}> - <ProxyList - all={proxies} - proxies={proxyMapping} - delay={delay} - httpsLatencyTest={httpsLatencyTest} - apiConfig={apiConfig} - dispatch={dispatch} - /> - <div className={s.actionFooter}> - <Button text="Update" start={<Refresh />} onClick={updateProvider} /> - <Button - text="Health Check" - start={<Zap size={16} />} - onClick={healthcheckProvider} - isLoading={isHealthcheckLoading} - /> - </div> - </Collapsible> - <Collapsible isOpen={!isOpen}> - <ProxyListSummaryView - all={proxies} - proxies={proxyMapping} - delay={delay} - httpsLatencyTest={httpsLatencyTest} - apiConfig={apiConfig} - dispatch={dispatch} - /> + + <ProxyCardStatusRow + itemCount={proxies.length} + allItems={all} + delay={delay} + renderDots={() => <ProxyListSummaryView {...listProps} />} + /> + + <Collapsible isOpen={effectiveIsOpen}> + <ProxyList {...listProps} /> </Collapsible> - </div> + </ProxyCard> ); }); -const button = { - rest: { scale: 1 }, - pressed: { scale: 0.95 }, -}; -const arrow = { - rest: { rotate: 0 }, - hover: { rotate: 360, transition: { duration: 0.3 } }, -}; - -function formatBytes(bytes, decimals = 2) { - if (!+bytes) return '0 Bytes'; +function formatBytes(bytes: number, decimals = 2) { + if (!+bytes) return '0 B'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; } -function Refresh() { - return ( - <LazyMotion features={domAnimation}> - <m.div - className={s.refresh} - variants={button} - initial="rest" - whileHover="hover" - whileTap="pressed" - > - <m.div className="flexCenter" variants={arrow}> - <RotateCw size={16} /> - </m.div> - </m.div> - </LazyMotion> - ); -} diff --git a/src/components/proxies/ProxyProviderList.tsx b/src/components/proxies/ProxyProviderList.tsx deleted file mode 100644 index a4a268e..0000000 --- a/src/components/proxies/ProxyProviderList.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import * as React from 'react'; - -import { ProxyProvider } from '~/components/proxies/ProxyProvider'; -import { DelayMapping, DispatchFn, FormattedProxyProvider, ProxiesMapping } from '~/store/types'; -import { ClashAPIConfig } from '~/types'; - -export function ProxyProviderList({ - items, - delay, - proxies, - httpsLatencyTest, - hideUnavailableProxies, - proxySortBy, - dispatch, - apiConfig, - collapsibleIsOpen, -}: { - items: FormattedProxyProvider[]; - delay: DelayMapping; - proxies: ProxiesMapping; - httpsLatencyTest: boolean; - hideUnavailableProxies: boolean; - proxySortBy: string; - dispatch: DispatchFn; - apiConfig: ClashAPIConfig; - collapsibleIsOpen: Record<string, boolean>; -}) { - if (items.length === 0) return null; - return ( - <div> - {items.map((item) => ( - <ProxyProvider - key={item.name} - name={item.name} - proxies={item.proxies} - type={item.type} - vehicleType={item.vehicleType} - updatedAt={item.updatedAt} - subscriptionInfo={item.subscriptionInfo} - proxyMapping={proxies} - httpsLatencyTest={httpsLatencyTest} - delay={delay} - hideUnavailableProxies={hideUnavailableProxies} - proxySortBy={proxySortBy} - dispatch={dispatch} - apiConfig={apiConfig} - isOpen={Boolean(collapsibleIsOpen[`proxyProvider:${item.name}`])} - /> - ))} - </div> - ); -} diff --git a/src/components/proxies/Settings.module.scss b/src/components/proxies/Settings.module.scss index d3eb540..3bddfe6 100644 --- a/src/components/proxies/Settings.module.scss +++ b/src/components/proxies/Settings.module.scss @@ -1,84 +1,99 @@ -.labeledInput { - max-width: 85vw; - width: 400px; +.panel { + width: 340px; + // 让内容跟着弹层收缩,宽度不够时不要顶出横向滚动条 + max-width: 100%; display: flex; + flex-direction: column; + gap: 10px; +} + +.sectionTitle { + margin: 0; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-secondary); +} + +.row { + display: flex; + align-items: center; justify-content: space-between; + gap: 12px; +} + +.stackedRow { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} + +.rowLabel { + font-size: 0.8rem; + color: var(--color-text); + line-height: 1.35; +} + +.rowControl { + display: flex; align-items: center; - font-size: 13px; - padding: 13px 0; + flex-shrink: 0; } -.urlInputWrapper { +.inputWrapper { position: relative; display: flex; align-items: center; - width: 240px; } -.urlInput { +.input { width: 100%; - padding: 4px 24px 4px 8px; - border: 1px solid var(--color-separator); - border-radius: 4px; - background: var(--color-bg-1, transparent); - color: inherit; - font-size: 12px; + padding: 8px 26px 8px 10px; + border: 1px solid var(--color-card-border); + border-radius: 10px; + background: var(--color-track); + color: var(--color-text); + font-family: inherit; + font-size: 0.78rem; outline: none; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; + + &::placeholder { + color: var(--color-text-secondary); + opacity: 0.7; + } &:focus { - border-color: var(--color-focus-blue, #409eff); + border-color: var(--color-focus-blue); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); } } -.urlClearBtn { +.clearBtn { position: absolute; - right: 6px; + right: 8px; background: none; border: none; cursor: pointer; - color: var(--color-text-secondary, #909399); + color: var(--color-text-secondary); padding: 0; line-height: 1; - font-size: 14px; + font-size: 15px; display: flex; align-items: center; &:hover { - color: var(--color-text, inherit); - } -} - -.timeoutInputWrapper { - display: flex; - align-items: center; - gap: 4px; -} - -.timeoutInput { - width: 70px; - padding: 4px 8px; - border: 1px solid var(--color-separator); - border-radius: 4px; - background: var(--color-bg-1, transparent); - color: inherit; - font-size: 12px; - text-align: right; - outline: none; - - &:focus { - border-color: var(--color-focus-blue, #409eff); + color: var(--color-text); } } -.timeoutUnit { - font-size: 12px; - color: var(--color-text-secondary, #909399); -} - -hr { +.divider { height: 1px; background-color: var(--color-separator); border: none; - outline: none; - margin: 1rem 0px; + margin: 2px 0; } diff --git a/src/components/proxies/Settings.tsx b/src/components/proxies/Settings.tsx index 33e20ff..e823fd9 100644 --- a/src/components/proxies/Settings.tsx +++ b/src/components/proxies/Settings.tsx @@ -1,222 +1,237 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import Select from '~/components/shared/Select'; -import { PROXY_SORT_OPTIONS } from '~/modules/proxies/utils'; - -import { useStoreActions } from '../StateProvider'; -import Switch from '../SwitchThemed'; +import { SegmentedControl } from '~/components/shared/SegmentedControl'; +import Switch from '~/components/shared/SwitchThemed'; +import { + getProxySortDirection, + getProxySortKey, + HEALTHCHECK_TIMEOUT_PRESETS, + LATENCY_TIMEOUT_PRESETS, + nextProxySortBy, + ProxiesAppConfig, + ProxySortKey, + withCurrentTimeout, +} from '~/modules/proxies/utils'; +import { useStoreActions } from '~/store/StateProvider'; import s from './Settings.module.scss'; -const { useCallback } = React; - -type AppConfig = { - proxySortBy: string; - hideUnavailableProxies: boolean; - autoCloseOldConns: boolean; - proxiesLayout: string; - proxyGroupByProvider: boolean; - latencyTestUrl: string; - latencyTestTimeout: number; - latencyTestExpectedStatus: string; - preferBackendLatencyTestUrl: boolean; - providerHealthcheckTimeout: number; -}; +const { useCallback, useMemo } = React; type Props = { - appConfig: AppConfig; + appConfig: ProxiesAppConfig; }; +function Row({ label, children }: { label: React.ReactNode; children: React.ReactNode }) { + return ( + <div className={s.row}> + <span className={s.rowLabel}>{label}</span> + <div className={s.rowControl}>{children}</div> + </div> + ); +} + +/** 单独一行、控件占满宽度(输入框、分段控件用) */ +function StackedRow({ label, children }: { label: React.ReactNode; children: React.ReactNode }) { + return ( + <div className={s.stackedRow}> + <span className={s.rowLabel}>{label}</span> + {children} + </div> + ); +} + +function ClearableInput({ + value, + placeholder, + onChange, + onClear, + ariaLabel, +}: { + value: string; + placeholder?: string; + onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; + onClear: () => void; + ariaLabel: string; +}) { + return ( + <div className={s.inputWrapper}> + <input + className={s.input} + type="text" + value={value} + placeholder={placeholder} + onChange={onChange} + spellCheck={false} + aria-label={ariaLabel} + /> + {value ? ( + <button className={s.clearBtn} onClick={onClear} tabIndex={-1} aria-label="clear"> + × + </button> + ) : null} + </div> + ); +} + export default function Settings({ appConfig }: Props) { const { app: { updateAppConfig }, } = useStoreActions(); + const { t } = useTranslation(); - const handleProxySortByOnChange = useCallback( - (e) => { - updateAppConfig('proxySortBy', e.target.value); - }, + const handleLatencyUrlChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => updateAppConfig('latencyTestUrl', e.target.value), [updateAppConfig], ); - - const handleHideUnavailablesSwitchOnChange = useCallback( - (v) => { - updateAppConfig('hideUnavailableProxies', v); - }, + const handleLatencyUrlClear = useCallback( + () => updateAppConfig('latencyTestUrl', ''), [updateAppConfig], ); - const handleLatencyUrlChange = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => { - updateAppConfig('latencyTestUrl', e.target.value); - }, + const handleExpectedStatusChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => + updateAppConfig('latencyTestExpectedStatus', e.target.value.trim()), [updateAppConfig], ); - - const handleLatencyUrlClear = useCallback(() => { - updateAppConfig('latencyTestUrl', ''); - }, [updateAppConfig]); - - const handleLatencyTimeoutChange = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => { - const v = parseInt(e.target.value, 10); - if (!isNaN(v) && v > 0) updateAppConfig('latencyTestTimeout', v); - }, + const handleExpectedStatusClear = useCallback( + () => updateAppConfig('latencyTestExpectedStatus', ''), [updateAppConfig], ); - const handleProviderHealthcheckTimeoutChange = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => { - const v = parseInt(e.target.value, 10); - if (!isNaN(v) && v > 0) updateAppConfig('providerHealthcheckTimeout', v); - }, - [updateAppConfig], + const secondsOptions = useCallback( + (presets: number[], current: number) => + withCurrentTimeout(presets, current).map((ms) => ({ + value: ms, + label: t('secs', { n: Math.round(ms / 100) / 10 }), + })), + [t], ); - const handleExpectedStatusChange = useCallback( - (e: React.ChangeEvent<HTMLInputElement>) => { - updateAppConfig('latencyTestExpectedStatus', e.target.value.trim()); - }, - [updateAppConfig], + const latencyTimeoutOptions = useMemo( + () => secondsOptions(LATENCY_TIMEOUT_PRESETS, appConfig.latencyTestTimeout), + [secondsOptions, appConfig.latencyTestTimeout], + ); + const healthcheckTimeoutOptions = useMemo( + () => secondsOptions(HEALTHCHECK_TIMEOUT_PRESETS, appConfig.providerHealthcheckTimeout), + [secondsOptions, appConfig.providerHealthcheckTimeout], ); - const handleExpectedStatusClear = useCallback(() => { - updateAppConfig('latencyTestExpectedStatus', ''); - }, [updateAppConfig]); + const sortKey = getProxySortKey(appConfig.proxySortBy); + const sortDirection = getProxySortDirection(appConfig.proxySortBy); + const sortOptions = useMemo(() => { + const arrow = sortDirection === 'Desc' ? ' ↓' : ' ↑'; + const withArrow = (key: ProxySortKey, text: string) => + key === sortKey && key !== 'Natural' ? `${text}${arrow}` : text; + return [ + { value: 'Natural' as const, label: t('sort_natural'), title: t('order_natural') }, + { value: 'Latency' as const, label: withArrow('Latency', t('sort_latency')) }, + { value: 'Name' as const, label: withArrow('Name', t('sort_name')) }, + ]; + }, [sortKey, sortDirection, t]); + + const handleSortChange = useCallback( + (key: ProxySortKey) => + updateAppConfig('proxySortBy', nextProxySortBy(appConfig.proxySortBy, key)), + [appConfig.proxySortBy, updateAppConfig], + ); - const { t } = useTranslation(); return ( - <> - <div className={s.labeledInput}> - <span>{t('latency_test_url')}</span> - <div className={s.urlInputWrapper}> - <input - className={s.urlInput} - type="text" - value={appConfig.latencyTestUrl} - onChange={handleLatencyUrlChange} - spellCheck={false} - /> - {appConfig.latencyTestUrl && ( - <button className={s.urlClearBtn} onClick={handleLatencyUrlClear} tabIndex={-1}> - × - </button> - )} - </div> - </div> - <div className={s.labeledInput}> - <span>{t('latency_test_timeout')}</span> - <div className={s.timeoutInputWrapper}> - <input - className={s.timeoutInput} - type="number" - min={100} - max={30000} - step={100} - value={appConfig.latencyTestTimeout} - onChange={handleLatencyTimeoutChange} - /> - <span className={s.timeoutUnit}>ms</span> - </div> - </div> - <div className={s.labeledInput}> - <span>{t('provider_healthcheck_timeout')}</span> - <div className={s.timeoutInputWrapper}> - <input - className={s.timeoutInput} - type="number" - min={1000} - max={60000} - step={500} - value={appConfig.providerHealthcheckTimeout} - onChange={handleProviderHealthcheckTimeoutChange} - /> - <span className={s.timeoutUnit}>ms</span> - </div> - </div> - <div className={s.labeledInput}> - <span>{t('latency_test_expected_status')}</span> - <div className={s.urlInputWrapper}> - <input - className={s.urlInput} - type="text" - placeholder="200/204" - value={appConfig.latencyTestExpectedStatus} - onChange={handleExpectedStatusChange} - spellCheck={false} - /> - {appConfig.latencyTestExpectedStatus && ( - <button className={s.urlClearBtn} onClick={handleExpectedStatusClear} tabIndex={-1}> - × - </button> - )} - </div> - </div> - <div className={s.labeledInput}> - <span>{t('prefer_backend_test_url')}</span> - <div> - <Switch - name="preferBackendLatencyTestUrl" - checked={appConfig.preferBackendLatencyTestUrl} - onChange={(v) => updateAppConfig('preferBackendLatencyTestUrl', v)} - /> - </div> - </div> - <hr /> - <div className={s.labeledInput}> - <span>{t('sort_in_grp')}</span> - <div> - <Select - options={PROXY_SORT_OPTIONS.map((o) => { - return [o[0], t(o[1])]; - })} - selected={appConfig.proxySortBy} - onChange={handleProxySortByOnChange} - /> - </div> - </div> - <hr /> - <div className={s.labeledInput}> - <span>{t('hide_unavail_proxies')}</span> - <div> - <Switch - name="hideUnavailableProxies" - checked={appConfig.hideUnavailableProxies} - onChange={handleHideUnavailablesSwitchOnChange} - /> - </div> - </div> - <div className={s.labeledInput}> - <span>{t('auto_close_conns')}</span> - <div> - <Switch - name="autoCloseOldConns" - checked={appConfig.autoCloseOldConns} - onChange={(v) => updateAppConfig('autoCloseOldConns', v)} - /> - </div> - </div> - <div className={s.labeledInput}> - <span>{t('double_column_layout')}</span> - <div> - <Switch - name="proxiesLayout" - checked={appConfig.proxiesLayout === 'double'} - onChange={(v) => updateAppConfig('proxiesLayout', v ? 'double' : 'single')} - /> - </div> - </div> - <div className={s.labeledInput}> - <span>{t('group_by_provider')}</span> - <div> - <Switch - name="proxyGroupByProvider" - checked={appConfig.proxyGroupByProvider} - onChange={(v) => updateAppConfig('proxyGroupByProvider', v)} - /> - </div> - </div> - </> + <div className={s.panel}> + <p className={s.sectionTitle}>{t('settings_latency')}</p> + + <StackedRow label={t('latency_test_url')}> + <ClearableInput + value={appConfig.latencyTestUrl} + onChange={handleLatencyUrlChange} + onClear={handleLatencyUrlClear} + ariaLabel={t('latency_test_url')} + /> + </StackedRow> + + <StackedRow label={t('latency_test_timeout')}> + <SegmentedControl + options={latencyTimeoutOptions} + value={appConfig.latencyTestTimeout} + onChange={(v) => updateAppConfig('latencyTestTimeout', v)} + label={t('latency_test_timeout')} + /> + </StackedRow> + + <StackedRow label={t('provider_healthcheck_timeout')}> + <SegmentedControl + options={healthcheckTimeoutOptions} + value={appConfig.providerHealthcheckTimeout} + onChange={(v) => updateAppConfig('providerHealthcheckTimeout', v)} + label={t('provider_healthcheck_timeout')} + /> + </StackedRow> + + <StackedRow label={t('latency_test_expected_status')}> + <ClearableInput + value={appConfig.latencyTestExpectedStatus} + placeholder="200/204" + onChange={handleExpectedStatusChange} + onClear={handleExpectedStatusClear} + ariaLabel={t('latency_test_expected_status')} + /> + </StackedRow> + + <Row label={t('prefer_backend_test_url')}> + <Switch + name="preferBackendLatencyTestUrl" + checked={appConfig.preferBackendLatencyTestUrl} + onChange={(v: boolean) => updateAppConfig('preferBackendLatencyTestUrl', v)} + /> + </Row> + + <hr className={s.divider} /> + <p className={s.sectionTitle}>{t('settings_display')}</p> + + <StackedRow label={t('sort_in_grp')}> + <SegmentedControl + options={sortOptions} + value={sortKey} + onChange={handleSortChange} + label={t('sort_in_grp')} + /> + </StackedRow> + + <Row label={t('hide_unavail_proxies')}> + <Switch + name="hideUnavailableProxies" + checked={appConfig.hideUnavailableProxies} + onChange={(v: boolean) => updateAppConfig('hideUnavailableProxies', v)} + /> + </Row> + + <Row label={t('double_column_layout')}> + <Switch + name="proxiesLayout" + checked={appConfig.proxiesLayout === 'double'} + onChange={(v: boolean) => updateAppConfig('proxiesLayout', v ? 'double' : 'single')} + /> + </Row> + + <Row label={t('group_by_provider')}> + <Switch + name="proxyGroupByProvider" + checked={appConfig.proxyGroupByProvider} + onChange={(v: boolean) => updateAppConfig('proxyGroupByProvider', v)} + /> + </Row> + + <hr className={s.divider} /> + <p className={s.sectionTitle}>{t('settings_behavior')}</p> + + <Row label={t('auto_close_conns')}> + <Switch + name="autoCloseOldConns" + checked={appConfig.autoCloseOldConns} + onChange={(v: boolean) => updateAppConfig('autoCloseOldConns', v)} + /> + </Row> + </div> ); } diff --git a/src/components/proxies/index.tsx b/src/components/proxies/index.tsx deleted file mode 100644 index 4ec01f9..0000000 --- a/src/components/proxies/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProxyList } from './ProxyList'; diff --git a/src/components/rules/Rule.module.scss b/src/components/rules/Rule.module.scss index 48a22e7..69b6f3b 100644 --- a/src/components/rules/Rule.module.scss +++ b/src/components/rules/Rule.module.scss @@ -1,81 +1,135 @@ -@use '~/styles/utils/custom-media' as *; - .rule { display: flex; align-items: center; - padding: 12px 20px; - transition: background-color 0.2s ease; - border-bottom: 1px solid var(--color-separator); + height: 100%; + padding: 10px 18px; + transition: background-color 0.15s ease; &:hover { - background-color: var(--bg-near-transparent); + background-color: var(--color-hover-soft); + } + + &.disabled { + opacity: 0.45; + } + + @media (max-width: 768px) { + padding: 10px 12px; } } -.left { +.index { width: 40px; flex-shrink: 0; color: var(--color-text-secondary); - font-size: 11px; - opacity: 0.4; + font-size: 0.68rem; + opacity: 0.5; font-family: var(--font-mono); + + @media (max-width: 768px) { + width: 28px; + } } -.right { +.main { flex: 1; min-width: 0; - margin-left: 12px; + display: flex; + flex-direction: column; + gap: 5px; } .payloadRow { display: flex; align-items: baseline; - margin-bottom: 4px; + gap: 10px; + min-width: 0; } .payload { font-family: var(--font-mono); - font-size: 13px; + font-size: 0.8rem; color: var(--color-text); - word-break: break-all; line-height: 1.4; + word-break: break-all; + // 定高行装不下第三行,超出的部分省略掉 + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } -.size { - margin-left: 12px; - font-size: 10px; - color: var(--color-text-secondary); - background: var(--bg-near-transparent); +.entryCount { + flex-shrink: 0; + font-size: 0.65rem; + color: var(--color-badge-fg); + background: var(--color-badge-bg); padding: 1px 6px; - border-radius: 3px; + border-radius: 5px; white-space: nowrap; - text-transform: uppercase; } .metaRow { display: flex; align-items: center; - gap: 12px; - font-size: 11px; + gap: 10px; + font-size: 0.7rem; + min-width: 0; } .typeTag { - display: flex; + display: inline-flex; align-items: center; gap: 4px; - color: var(--color-text-secondary); - background: var(--bg-near-transparent); - padding: 2px 8px; - border-radius: 4px; + flex-shrink: 0; + color: var(--color-badge-fg); + background: var(--color-badge-bg); + padding: 2px 7px; + border-radius: 5px; span { font-weight: 500; text-transform: uppercase; - font-size: 10px; + font-size: 0.62rem; + letter-spacing: 0.02em; } } -.proxyTag { +.proxy { font-weight: 600; - letter-spacing: 0.02em; + letter-spacing: 0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hitInfo { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-left: auto; + color: var(--color-text-secondary); + cursor: default; +} + +// DOM 里排在最前,桌面端就在行首;用 mini 尺寸的开关, +// 布局盒子和看到的一样大,不需要再拿 scale + transform-origin 去凑 +.switch { + display: flex; + align-items: center; + flex-shrink: 0; + margin-right: 12px; + + &.pending { + opacity: 0.6; + pointer-events: none; + } + + // 窄屏维持原样:拨到行尾 + @media (max-width: 768px) { + order: 1; + margin-right: 0; + margin-left: 12px; + } } diff --git a/src/components/rules/Rule.tsx b/src/components/rules/Rule.tsx index e1fd047..0cb896a 100644 --- a/src/components/rules/Rule.tsx +++ b/src/components/rules/Rule.tsx @@ -1,74 +1,147 @@ -import React from 'react'; +import cx from 'clsx'; +import { formatDistanceToNow } from 'date-fns'; +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; -import { FileText, Globe, Hash, Link, Shield, Zap } from '~/components/shared/FeatherIcons'; +import type { RuleExtra } from '~/api/rules'; +import { + Activity, + FileText, + Globe, + Hash, + Link, + Shield, + Zap, +} from '~/components/shared/FeatherIcons'; +import SwitchThemed from '~/components/shared/SwitchThemed'; +import { useToggleRuleDisabled } from '~/modules/rules/hooks'; +import type { RuleProviderIndex } from '~/modules/rules/utils'; +import { ClashAPIConfig } from '~/types'; -import s0 from './Rule.module.scss'; +import s from './Rule.module.scss'; -const colorMap = { - _default: 'var(--color-focus-blue)', +const proxyColor: Record<string, string> = { DIRECT: '#f5bc41', REJECT: '#cb3166', }; -function getStyleFor({ proxy }) { - let color = colorMap._default; - if (colorMap[proxy]) { - color = colorMap[proxy]; - } - return { color }; -} - function getIconFor(type: string) { switch (type) { case 'Domain': case 'DomainSuffix': case 'DomainKeyword': - return <Link size={14} />; + return <Link size={12} />; case 'IPCIDR': case 'IPCIDR6': - return <Hash size={14} />; + return <Hash size={12} />; case 'GeoSite': case 'GeoIP': - return <Globe size={14} />; + return <Globe size={12} />; case 'REJECT': - return <Shield size={14} />; + return <Shield size={12} />; case 'DIRECT': - return <Zap size={14} />; + return <Zap size={12} />; default: - return <FileText size={14} />; + return <FileText size={12} />; } } type Props = { - id?: number; - type?: string; - payload?: string; - proxy?: string; - size?: number; + id: number; + type: string; + payload: string; + proxy: string; + size: number; + extra?: RuleExtra; + apiConfig: ClashAPIConfig; + provider?: RuleProviderIndex; }; -function Rule({ type, payload, proxy, id, size }: Props) { - const styleProxy = getStyleFor({ proxy }); +/** GeoSite/GeoIP 的条目数后端直接给,RuleSet 的要去提供商表里查 */ +function getEntryCount({ + type, + payload, + size, + provider, +}: { + type: string; + payload: string; + size: number; + provider?: RuleProviderIndex; +}): number | undefined { + if ((type === 'GeoSite' || type === 'GeoIP') && size >= 0) { + return size; + } + if (type === 'RuleSet') { + return provider?.byName?.[payload]?.ruleCount; + } + return undefined; +} + +function Rule({ type, payload, proxy, id, size, extra, apiConfig, provider }: Props) { + const { t } = useTranslation(); + const { toggleRule, isPending } = useToggleRuleDisabled(apiConfig); + const disabled = extra?.disabled ?? false; + const entryCount = getEntryCount({ type, payload, size, provider }); + + const hitTitle = extra + ? extra.hitCount > 0 + ? t('rule_hit_tip', { + count: extra.hitCount, + time: formatDistanceToNow(new Date(extra.hitAt), { addSuffix: true }), + }) + : t('rule_never_hit') + : undefined; + return ( - <div className={s0.rule}> - <div className={s0.left}>{id}</div> - <div className={s0.right}> - <div className={s0.payloadRow}> - <div className={s0.payload}>{payload}</div> - {(type === 'GeoSite' || type === 'GeoIP') && <div className={s0.size}>size: {size}</div>} + <div className={cx(s.rule, { [s.disabled]: disabled })}> + {/* 桌面端在最左边,窄屏靠 .switch 的 order 拨到行尾 */} + {extra ? ( + <div + className={cx(s.switch, { [s.pending]: isPending })} + title={disabled ? t('rule_enable') : t('rule_disable')} + > + <SwitchThemed + size="mini" + name={`rule-${id}`} + checked={!disabled} + onChange={(checked: boolean) => toggleRule(id, !checked)} + /> + </div> + ) : null} + + <div className={s.index}>{id}</div> + + <div className={s.main}> + <div className={s.payloadRow}> + <div className={s.payload}>{payload}</div> + {typeof entryCount === 'number' ? ( + <div className={s.entryCount}>{t('rule_entry_count', { count: entryCount })}</div> + ) : null} </div> - <div className={s0.metaRow}> - <div className={s0.typeTag}> + + <div className={s.metaRow}> + <span className={s.typeTag}> {getIconFor(type)} <span>{type}</span> - </div> - <div className={s0.proxyTag} style={styleProxy}> + </span> + <span + className={s.proxy} + style={{ color: proxyColor[proxy] ?? 'var(--color-focus-blue)' }} + > {proxy} - </div> + </span> + {extra ? ( + <span className={s.hitInfo} title={hitTitle}> + <Activity size={12} /> + {extra.hitCount} + </span> + ) : null} </div> </div> </div> ); } -export default Rule; +// 上千行的虚拟列表,滚动时不该因为父组件重渲染而整屏重算 +export default React.memo(Rule); diff --git a/src/components/rules/RuleProviderItem.module.scss b/src/components/rules/RuleProviderItem.module.scss index 933fa08..df321ca 100644 --- a/src/components/rules/RuleProviderItem.module.scss +++ b/src/components/rules/RuleProviderItem.module.scss @@ -1,29 +1,37 @@ -.RuleProviderItem { +.item { display: flex; align-items: center; height: 100%; - padding: 16px 20px; - transition: all 0.2s ease; + padding: 12px 18px; + transition: background-color 0.15s ease; &:hover { - background: var(--bg-near-transparent); + background: var(--color-hover-soft); - .refreshButton { + .refreshBtn { opacity: 1; } } + + @media (max-width: 768px) { + padding: 12px; + } } -.left { - width: 32px; +.index { + width: 40px; flex-shrink: 0; color: var(--color-text-secondary); - font-size: 11px; - opacity: 0.4; + font-size: 0.68rem; + opacity: 0.5; font-family: var(--font-mono); + + @media (max-width: 768px) { + width: 28px; + } } -.middle { +.main { flex: 1; min-width: 0; display: flex; @@ -34,30 +42,29 @@ .nameRow { display: flex; align-items: center; - flex-wrap: wrap; gap: 8px; + min-width: 0; } .name { - font-size: 14px; + font-size: 0.85rem; font-weight: 600; color: var(--color-text-highlight); -} - -.badgeGroup { - display: flex; - gap: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .badge { - display: flex; + display: inline-flex; align-items: center; gap: 4px; - font-size: 10px; - color: var(--color-text-secondary); - background: var(--bg-near-transparent); - padding: 2px 6px; - border-radius: 4px; + flex-shrink: 0; + font-size: 0.62rem; + color: var(--color-badge-fg); + background: var(--color-badge-bg); + padding: 2px 7px; + border-radius: 5px; text-transform: uppercase; letter-spacing: 0.02em; } @@ -66,41 +73,52 @@ display: flex; align-items: center; gap: 8px; - font-size: 11px; + font-size: 0.7rem; color: var(--color-text-secondary); } .dot { - opacity: 0.5; + opacity: 0.4; } -.right { +.refreshBtn { + display: inline-flex; + align-items: center; + justify-content: center; + appearance: none; + flex-shrink: 0; margin-left: 12px; -} - -.refreshButton { - opacity: 0.4; - transition: all 0.2s ease; - padding: 8px !important; - border-radius: 50% !important; + width: 32px; + height: 32px; + border: none; + border-radius: 8px; + background: none; color: var(--color-text-secondary); + cursor: pointer; + // 悬停在整行上才完全显形,静态时不抢眼 + opacity: 0.45; + transition: + opacity 0.15s ease, + background-color 0.15s ease, + color 0.15s ease; - &:hover { - opacity: 1; + &:hover:not(:disabled) { color: var(--color-focus-blue); - background: var(--bg-near-transparent) !important; + background: var(--color-hover-soft); } -} -.rotating { - animation: rotate 1s linear infinite; -} + &:focus-visible { + opacity: 1; + outline: 2px solid var(--color-focus-blue); + outline-offset: 2px; + } -@keyframes rotate { - from { - transform: rotate(0deg); + &:disabled { + cursor: default; } - to { - transform: rotate(360deg); + + // 触屏没有 hover,直接常显 + @media (hover: none) { + opacity: 1; } } diff --git a/src/components/rules/RuleProviderItem.tsx b/src/components/rules/RuleProviderItem.tsx index 4cd5773..ec9732b 100644 --- a/src/components/rules/RuleProviderItem.tsx +++ b/src/components/rules/RuleProviderItem.tsx @@ -1,13 +1,18 @@ import { formatDistance } from 'date-fns'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; -import Button from '~/components/Button'; -import { Activity, Database, RefreshCw } from '~/components/shared/FeatherIcons'; +import type { RuleProvider } from '~/api/rule-provider'; +import { Activity, Database } from '~/components/shared/FeatherIcons'; +import { RotateIcon } from '~/components/shared/RotateIcon'; import { useUpdateRuleProviderItem } from '~/modules/rules/hooks'; +import { ClashAPIConfig } from '~/types'; import s from './RuleProviderItem.module.scss'; -export function RuleProviderItem({ +type Props = RuleProvider & { apiConfig: ClashAPIConfig }; + +function RuleProviderItemInner({ idx, name, vehicleType, @@ -15,42 +20,46 @@ export function RuleProviderItem({ updatedAt, ruleCount, apiConfig, -}) { - const [onClickRefreshButton, isRefreshing] = useUpdateRuleProviderItem(name, apiConfig); +}: Props) { + const { t } = useTranslation(); + const [refresh, isRefreshing] = useUpdateRuleProviderItem(name, apiConfig); const timeAgo = formatDistance(new Date(updatedAt), new Date()); + return ( - <div className={s.RuleProviderItem}> - <div className={s.left}>{idx}</div> - <div className={s.middle}> + <div className={s.item}> + <div className={s.index}>{idx}</div> + + <div className={s.main}> <div className={s.nameRow}> <span className={s.name}>{name}</span> - <div className={s.badgeGroup}> - <span className={s.badge}> - <Database size={12} /> - {vehicleType} - </span> - <span className={s.badge}> - <Activity size={12} /> - {behavior} - </span> - </div> + <span className={s.badge}> + <Database size={11} /> + {vehicleType} + </span> + <span className={s.badge}> + <Activity size={11} /> + {behavior} + </span> </div> <div className={s.infoRow}> - <span className={s.count}>{ruleCount} rules</span> + <span>{t('rule_entry_count', { count: ruleCount })}</span> <span className={s.dot}>•</span> - <span className={s.time}>Updated {timeAgo} ago</span> + <span>{t('updated_ago', { time: timeAgo })}</span> </div> </div> - <div className={s.right}> - <Button - kind="minimal" - onClick={onClickRefreshButton} - disabled={isRefreshing} - className={s.refreshButton} - > - <RefreshCw size={18} className={isRefreshing ? s.rotating : ''} /> - </Button> - </div> + + <button + type="button" + className={s.refreshBtn} + onClick={refresh} + disabled={isRefreshing} + aria-label={t('update_rule_provider')} + title={t('update_rule_provider')} + > + <RotateIcon isRotating={isRefreshing} /> + </button> </div> ); } + +export const RuleProviderItem = React.memo(RuleProviderItemInner); diff --git a/src/components/rules/Rules.module.scss b/src/components/rules/Rules.module.scss index f764d09..701fe14 100644 --- a/src/components/rules/Rules.module.scss +++ b/src/components/rules/Rules.module.scss @@ -1,97 +1,90 @@ -@use '~/styles/utils/custom-media' as *; - -.container { +.page { display: flex; flex-direction: column; height: 100%; + min-height: 0; } -.filterWrapper { - width: 100%; - max-width: 300px; +.listArea { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + margin: 12px 32px 24px; + + @media (max-width: 1024px) { + margin: 12px 16px 16px; + } @media (max-width: 768px) { - order: 3; - flex: 1 1 100%; - max-width: none; - margin-top: 8px; + margin: 8px 12px 10px; } } -.listWrapper { - margin: 10px 45px 20px; - background-color: var(--bg-log-info-card); - border-radius: 12px; +// 与连接页表格同一张卡:窄屏下退掉卡片边界,直接铺在页面底色上 +.card { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + border-radius: 16px; + background: var(--color-card); + border: 1px solid var(--color-card-border); box-shadow: var(--shadow-card); - border: 1px solid var(--color-separator); overflow: hidden; @media (max-width: 768px) { - margin: 10px 15px 15px; + background: transparent; + border: none; + box-shadow: none; + border-radius: 0; } } -.RuleProviderItemWrapper { - border-bottom: 1px solid var(--color-separator); +.listWrap { + flex: 1; + min-height: 0; + min-width: 0; } -.tabsContainer { - display: flex; - align-items: center; - background-color: var(--color-bg-sidebar); - border-radius: 12px; - padding: 4px; - - @media (max-width: 768px) { - order: 1; - flex: 1 1 100%; - width: 100%; - } +.row { + border-bottom: 1px solid var(--color-card-border); } -.tab { +/* ---------- 空态 ---------- */ + +.empty { display: flex; + flex-direction: column; align-items: center; - padding: 8px 16px; - border-radius: 8px; - cursor: pointer; - font-size: 1em; - font-weight: 500; - color: var(--color-text-secondary); - transition: all 0.2s ease; - user-select: none; - - @media (max-width: 768px) { - padding: 6px 10px; - font-size: 0.85em; - flex: 1; - justify-content: center; - min-width: 0; - white-space: nowrap; - } + justify-content: center; + gap: 6px; + height: 100%; +} - &:hover { - color: var(--color-focus-blue); - background: rgba(176, 206, 255, 0.221); - } +.emptyTitle { + font-size: 0.88rem; + color: var(--color-text); +} - &.active { - background-color: var(--color-focus-blue); - color: #fff; - } +.emptyHint { + font-size: 0.78rem; + color: var(--color-text-secondary); } -.tabCount { - font-family: var(--font-normal); - font-size: 0.7em; - margin-left: 6px; - padding: 2px 8px; - display: inline-flex; - justify-content: center; +/* ---------- 页脚 ---------- */ + +.footer { + display: flex; align-items: center; - background-color: rgba(255, 255, 255, 0.15); - border-radius: 10px; - font-weight: 600; - min-width: 20px; + gap: 14px; + height: 36px; flex-shrink: 0; + padding: 0 18px; + border-top: 1px solid var(--color-card-border); + background: var(--color-track); + font-size: 0.72rem; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; } diff --git a/src/components/rules/Rules.tsx b/src/components/rules/Rules.tsx index c0b02ae..5bf00a5 100644 --- a/src/components/rules/Rules.tsx +++ b/src/components/rules/Rules.tsx @@ -1,42 +1,29 @@ -import cx from 'clsx'; -import React from 'react'; +import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { List as VirtualList, RowComponentProps } from 'react-window'; +import { RowComponentProps, List as VirtualList } from 'react-window'; -import ContentHeader from '~/components/ContentHeader'; -import { TextFilter } from '~/components/shared/TextFitler'; -import useRemainingViewPortHeight from '~/hooks/useRemainingViewPortHeight'; -import { useRulesPage } from '~/modules/rules/hooks'; -import { formatQty, getItemSizeFactory, RulesListItemData } from '~/modules/rules/utils'; -import { ruleFilterText } from '~/store/rules'; +import { useRulesPage, useUpdateAllRuleProviderItems } from '~/modules/rules/hooks'; +import { PROVIDER_ROW_HEIGHT, RULE_ROW_HEIGHT, type RulesRowProps } from '~/modules/rules/utils'; import { ClashAPIConfig } from '~/types'; import Rule from './Rule'; import { RuleProviderItem } from './RuleProviderItem'; import s from './Rules.module.scss'; -import { RulesPageFab } from './RulesPageFab'; - -type RulesRowProps = { - data: RulesListItemData; -}; - -function Row({ index, style, data }: RowComponentProps<RulesRowProps>) { - const { rules, provider, apiConfig } = data; +import { RulesHeader } from './RulesHeader'; +function Row({ index, style, rules, provider, apiConfig }: RowComponentProps<RulesRowProps>) { if (!rules) { - const name = provider.names[index]; - const item = provider.byName[name]; + const item = provider.byName[provider.names[index]]; return ( - <div style={style} className={s.RuleProviderItemWrapper}> + <div style={style} className={s.row}> <RuleProviderItem apiConfig={apiConfig} {...item} /> </div> ); } - const r = rules[index]; return ( - <div style={style}> - <Rule {...r} /> + <div style={style} className={s.row}> + <Rule {...rules[index]} apiConfig={apiConfig} provider={provider} /> </div> ); } @@ -46,59 +33,69 @@ type RulesProps = { }; export default function Rules({ apiConfig }: RulesProps) { - const [refRulesContainer, containerHeight] = useRemainingViewPortHeight(); - const { rules, provider, activeTab, setActiveTab, isRulesTab, handleTabKeyDown } = + const { t } = useTranslation(); + const { rules, provider, providerCount, activeTab, setActiveTab, isRulesTab } = useRulesPage(apiConfig); - const getItemSize = getItemSizeFactory({ isRulesTab }); + const [updateAllProviders, isUpdatingProviders] = useUpdateAllRuleProviderItems(apiConfig); - const { t } = useTranslation(); + const rowCount = isRulesTab ? rules.length : provider.names.length; + + // rowProps 每次渲染新建对象会让所有行跟着重渲染,虚拟列表就白做了 + const rowProps = React.useMemo<RulesRowProps>( + () => ({ rules: isRulesTab ? rules : null, provider, apiConfig }), + [isRulesTab, rules, provider, apiConfig], + ); + + const rowHeight = React.useCallback( + () => (isRulesTab ? RULE_ROW_HEIGHT : PROVIDER_ROW_HEIGHT), + [isRulesTab], + ); + + const rowKey = React.useCallback( + (index: number, { rules, provider }: RulesRowProps) => + rules ? rules[index].id : provider.names[index], + [], + ); return ( - <div className={s.container}> - <ContentHeader> - <div className={s.tabsContainer}> - <div - className={cx(s.tab, { [s.active]: activeTab === 'rules' })} - onClick={() => setActiveTab('rules')} - onKeyDown={handleTabKeyDown('rules')} - role="button" - tabIndex={0} - > - {t('Rules')} - <span className={s.tabCount}>{formatQty(rules.length)}</span> + <div className={s.page}> + <RulesHeader + activeTab={activeTab} + setActiveTab={setActiveTab} + ruleCount={rules.length} + providerCount={providerCount} + visibleProviderCount={provider.names.length} + onUpdateAllProviders={updateAllProviders} + isUpdatingProviders={isUpdatingProviders} + /> + + <div className={s.listArea}> + <div className={s.card}> + <div className={s.listWrap}> + {rowCount === 0 ? ( + <div className={s.empty}> + <span className={s.emptyTitle}> + {t(isRulesTab ? 'rules_empty_title' : 'rule_providers_empty_title')} + </span> + <span className={s.emptyHint}>{t('rules_empty_hint')}</span> + </div> + ) : ( + <VirtualList + style={{ height: '100%', width: '100%' }} + rowCount={rowCount} + rowHeight={rowHeight} + rowComponent={Row} + rowKey={rowKey} + rowProps={rowProps} + /> + )} + </div> + + <div className={s.footer}> + <span>{t('rules_shown', { count: rowCount })}</span> </div> - {provider.names.length > 0 && ( - <div - className={cx(s.tab, { [s.active]: activeTab === 'providers' })} - onClick={() => setActiveTab('providers')} - onKeyDown={handleTabKeyDown('providers')} - role="button" - tabIndex={0} - > - {t('rule_provider')} - <span className={s.tabCount}>{formatQty(provider.names.length)}</span> - </div> - )} - </div> - <div style={{ flex: 1 }} /> - <div className={s.filterWrapper}> - <TextFilter textAtom={ruleFilterText} placeholder={t('Search')} /> </div> - </ContentHeader> - <div ref={refRulesContainer} className={s.listWrapper}> - <VirtualList - style={{ height: containerHeight, width: '100%' }} - rowCount={isRulesTab ? rules.length : provider.names.length} - rowHeight={getItemSize} - rowComponent={Row} - rowProps={{ - data: { rules: isRulesTab ? rules : null, provider, apiConfig } as RulesListItemData, - }} - /> </div> - {provider && provider.names && provider.names.length > 0 ? ( - <RulesPageFab apiConfig={apiConfig} /> - ) : null} </div> ); } diff --git a/src/components/rules/RulesHeader.tsx b/src/components/rules/RulesHeader.tsx new file mode 100644 index 0000000..f15a863 --- /dev/null +++ b/src/components/rules/RulesHeader.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from 'react-i18next'; + +import { + HeaderActions, + HeaderButton, + HeaderSearch, + HeaderTab, + HeaderTabs, + HeaderTitle, + PageHeader, +} from '~/components/shared/PageHeader'; +import { RotateIcon } from '~/components/shared/RotateIcon'; +import { TextFilter } from '~/components/shared/TextFilter'; +import type { RulesTabKey } from '~/modules/rules/utils'; +import { ruleFilterText } from '~/store/rules'; + +type Props = { + activeTab: RulesTabKey; + setActiveTab: (tab: RulesTabKey) => void; + /** 搜索后可见的规则数 */ + ruleCount: number; + /** 提供商总数,决定标签是否出现 */ + providerCount: number; + /** 搜索后可见的提供商数,只影响标签上的计数 */ + visibleProviderCount: number; + onUpdateAllProviders: () => void; + isUpdatingProviders: boolean; +}; + +export function RulesHeader({ + activeTab, + setActiveTab, + ruleCount, + providerCount, + visibleProviderCount, + onUpdateAllProviders, + isUpdatingProviders, +}: Props) { + const { t } = useTranslation(); + + return ( + <PageHeader> + <HeaderTitle>{t('Rules')}</HeaderTitle> + + <HeaderTabs label={t('Rules')}> + <HeaderTab + active={activeTab === 'rules'} + label={t('Rules')} + count={ruleCount} + onClick={() => setActiveTab('rules')} + /> + {providerCount > 0 ? ( + <HeaderTab + active={activeTab === 'providers'} + label={t('rule_provider')} + count={visibleProviderCount} + onClick={() => setActiveTab('providers')} + /> + ) : null} + </HeaderTabs> + + <HeaderSearch> + <TextFilter textAtom={ruleFilterText} placeholder={t('search_rules_placeholder')} /> + </HeaderSearch> + + <HeaderActions> + {/* 和代理页一致:更新全部只在提供商标签页出现 */} + {activeTab === 'providers' && providerCount > 0 ? ( + <HeaderButton + variant="primary" + icon={<RotateIcon isRotating={isUpdatingProviders} />} + label={t('update_all_rule_provider')} + busy={isUpdatingProviders} + onClick={onUpdateAllProviders} + /> + ) : null} + </HeaderActions> + </PageHeader> + ); +} diff --git a/src/components/rules/RulesPageFab.tsx b/src/components/rules/RulesPageFab.tsx deleted file mode 100644 index f9dee23..0000000 --- a/src/components/rules/RulesPageFab.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { Fab, position as fabPosition } from '~/components/shared/Fab'; -import { RotateIcon } from '~/components/shared/RotateIcon'; -import { useUpdateAllRuleProviderItems } from '~/modules/rules/hooks'; -import { ClashAPIConfig } from '~/types'; - -type RulesPageFabProps = { - apiConfig: ClashAPIConfig; -}; - -export function RulesPageFab({ apiConfig }: RulesPageFabProps) { - const [update, isLoading] = useUpdateAllRuleProviderItems(apiConfig); - const { t } = useTranslation(); - return ( - <Fab - icon={<RotateIcon isRotating={isLoading} />} - text={t('update_all_rule_provider')} - style={fabPosition} - onClick={update} - /> - ); -} diff --git a/src/components/shared/BaseModal.module.scss b/src/components/shared/BaseModal.module.scss index 229b920..0d24a74 100644 --- a/src/components/shared/BaseModal.module.scss +++ b/src/components/shared/BaseModal.module.scss @@ -2,16 +2,10 @@ background-color: rgba(0, 0, 0, 0.6); } .cnt { - position: absolute; background-color: var(--bg-modal); color: var(--color-text); line-height: 1.4; - opacity: 0.6; - transition: all 0.3s ease; - // transform: scale(1.2); - box-shadow: rgba(0, 0, 0, 0.12) 0px 4px 4px, rgba(0, 0, 0, 0.24) 0px 16px 32px; -} -.afterOpen { - opacity: 1; - // transform: scale(1); + box-shadow: + rgba(0, 0, 0, 0.12) 0px 4px 4px, + rgba(0, 0, 0, 0.24) 0px 16px 32px; } diff --git a/src/components/shared/BaseModal.tsx b/src/components/shared/BaseModal.tsx index 4c166db..c9c28b1 100644 --- a/src/components/shared/BaseModal.tsx +++ b/src/components/shared/BaseModal.tsx @@ -1,34 +1,24 @@ import cx from 'clsx'; import * as React from 'react'; -import Modal from '../Modal'; -import modalStyle from '../Modal.module.scss'; - import s from './BaseModal.module.scss'; - -const { useMemo } = React; +import Modal from './Modal'; type BaseModalProps = { isOpen: boolean; onRequestClose: (...args: any[]) => unknown; + title?: string; children: React.ReactNode; }; -export default function BaseModal({ isOpen, onRequestClose, children }: BaseModalProps) { - const className = useMemo( - () => ({ - base: cx(modalStyle.content, s.cnt), - afterOpen: s.afterOpen, - beforeClose: '', - }), - [] - ); +export default function BaseModal({ isOpen, onRequestClose, title, children }: BaseModalProps) { return ( <Modal isOpen={isOpen} onRequestClose={onRequestClose} - className={className} - overlayClassName={cx(modalStyle.overlay, s.overlay)} + title={title} + className={s.cnt} + overlayClassName={cx(s.overlay)} > {children} </Modal> diff --git a/src/components/shared/Basic.module.scss b/src/components/shared/Basic.module.scss index df412e5..cb50df0 100644 --- a/src/components/shared/Basic.module.scss +++ b/src/components/shared/Basic.module.scss @@ -1,20 +1,5 @@ @use '~/styles/utils/custom-media' as *; -h2.sectionNameType { - margin: 0; - font-size: 1em; - @media (--breakpoint-not-small) { - font-size: 1.3em; - } - - span:nth-child(2) { - font-size: 12px; - color: #777; - font-weight: normal; - margin: 0 0.3em; - } -} - @mixin light { --loading-dot-1-1: rgba(0, 0, 0, 0.1); --loading-dot-1-2: rgba(0, 0, 0, 0.5); diff --git a/src/components/shared/Basic.tsx b/src/components/shared/Basic.tsx index 7071938..588fd12 100644 --- a/src/components/shared/Basic.tsx +++ b/src/components/shared/Basic.tsx @@ -2,15 +2,6 @@ import React from 'react'; import s from './Basic.module.scss'; -export function SectionNameType({ name, type }) { - return ( - <h2 className={s.sectionNameType}> - <span style={{ marginRight: 5 }}>{name}</span> - <span>{type}</span> - </h2> - ); -} - export function LoadingDot() { return <span className={s.loadingDot} />; } diff --git a/src/components/Button.module.scss b/src/components/shared/Button.module.scss index 4f89d4c..8d2ca46 100644 --- a/src/components/Button.module.scss +++ b/src/components/shared/Button.module.scss @@ -9,22 +9,25 @@ display: inline-flex; align-items: center; justify-content: center; - color: var(--color-btn-fg); - background: var(--color-btn-bg); - border: 1px solid var(--color-input-border); + // 与顶栏的 .btnGhost 同一套:卡片色底 + 卡片边框色描边, + // 悬停是蓝描边而不是整块填蓝 + color: var(--color-text); + background: var(--color-card); + border: 1px solid var(--color-card-border); border-radius: 100px; - transition: all 0.2s ease; + transition: + color 0.2s ease, + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease; - &:focus { + &:focus-visible { border-color: var(--color-focus-blue); - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3); + box-shadow: 0 0 0 3px var(--color-accent-soft-bg); } - &:hover { - color: #fff; - background: var(--color-focus-blue); + &:hover:not(:disabled) { + color: var(--color-focus-blue); border-color: var(--color-focus-blue); - transform: translateY(-1px); - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); } font-size: 0.75em; @@ -37,13 +40,10 @@ &.minimal { border-color: transparent; background: none; - &:focus { - border-color: var(--color-focus-blue); - } - &:hover { - color: #fff; - background: var(--color-focus-blue); - border: 1px solid var(--color-focus-blue); + &:hover:not(:disabled) { + color: var(--color-focus-blue); + background: var(--color-hover-soft); + border-color: transparent; } } } diff --git a/src/components/Button.tsx b/src/components/shared/Button.tsx index 9bd0d61..5ecf029 100644 --- a/src/components/Button.tsx +++ b/src/components/shared/Button.tsx @@ -1,8 +1,8 @@ import cx from 'clsx'; import * as React from 'react'; +import { LoadingDot } from './Basic'; import s0 from './Button.module.scss'; -import { LoadingDot } from './shared/Basic'; const { forwardRef, useCallback } = React; @@ -37,11 +37,11 @@ function Button(props: ButtonProps, ref: React.Ref<HTMLButtonElement>) { } = props; const internalProps = { children, label, text, start }; const internalOnClick = useCallback( - (e) => { + (e: React.MouseEvent<HTMLButtonElement>) => { if (isLoading) return; onClick && onClick(e); }, - [isLoading, onClick] + [isLoading, onClick], ); const btnClassName = cx(s0.btn, { [s0.minimal]: kind === 'minimal' }, className); return ( diff --git a/src/components/Collapsible.tsx b/src/components/shared/Collapsible.tsx index 6948cef..bc723a0 100644 --- a/src/components/Collapsible.tsx +++ b/src/components/shared/Collapsible.tsx @@ -1,4 +1,4 @@ -import { LazyMotion, domAnimation, m } from 'framer-motion'; +import { domAnimation, LazyMotion, m } from 'framer-motion'; import React from 'react'; const { memo } = React; diff --git a/src/components/shared/Fab.module.scss b/src/components/shared/Fab.module.scss deleted file mode 100644 index 61aaecb..0000000 --- a/src/components/shared/Fab.module.scss +++ /dev/null @@ -1,33 +0,0 @@ -.spining { - position: relative; - border-radius: 50%; - background: linear-gradient(60deg, #e66465, #9198e5); - - width: 48px; - height: 48px; - display: flex; - justify-content: center; - align-items: center; -} - -.spining:before { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - border: 2px solid transparent; - border-top-color: currentColor; - border-radius: 50%; - animation: spining_keyframes 1s linear infinite; -} - -@keyframes spining_keyframes { - 0% { - transform: rotate(0); - } - 100% { - transform: rotate(360deg); - } -} diff --git a/src/components/shared/Fab.tsx b/src/components/shared/Fab.tsx deleted file mode 100644 index 49c9a89..0000000 --- a/src/components/shared/Fab.tsx +++ /dev/null @@ -1,155 +0,0 @@ -// adapted from https://github.com/dericgw/react-tiny-fab/blob/master/src/index.tsx -import './rtf.css'; - -import * as React from 'react'; - -import s from './Fab.module.scss'; - -const { useState } = React; - -export function IsFetching({ children }: { children: React.ReactNode }) { - return <span className={s.spining}>{children}</span>; -} - -export const position = { - right: 10, - bottom: 10, -}; - -interface ABProps extends React.HTMLAttributes<HTMLButtonElement> { - text?: string; - onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown; - 'data-testid'?: string; -} - -const AB: React.FC<ABProps> = ({ children, ...p }) => ( - <button type="button" {...p} className="rtf--ab"> - {children} - </button> -); - -interface MBProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, 'tabIndex'> { - tabIndex?: number; -} - -export const MB: React.FC<MBProps> = ({ children, ...p }) => ( - <button type="button" className="rtf--mb" {...p}> - {children} - </button> -); - -const defaultStyles: React.CSSProperties = { bottom: 24, right: 24 }; - -interface FabProps { - event?: 'hover' | 'click'; - style?: React.CSSProperties; - alwaysShowTitle?: boolean; - icon?: React.ReactNode; - mainButtonStyles?: React.CSSProperties; - onClick?: (e: React.MouseEvent<HTMLButtonElement>) => unknown; - text?: string; - children?: React.ReactNode; -} - -const Fab: React.FC<FabProps> = ({ - event = 'hover', - style = defaultStyles, - alwaysShowTitle = false, - children, - icon, - mainButtonStyles, - onClick, - text, - ...p -}) => { - const [isOpen, setIsOpen] = useState(false); - const ariaHidden = alwaysShowTitle || !isOpen; - const open = () => setIsOpen(true); - const close = () => setIsOpen(false); - const enter = () => event === 'hover' && open(); - const leave = () => event === 'hover' && close(); - const toggle = (e: React.MouseEvent<HTMLButtonElement>) => { - if (onClick) { - return onClick(e); - } - e.persist(); - return event === 'click' ? (isOpen ? close() : open()) : null; - }; - - const actionOnClick = ( - e: React.MouseEvent<HTMLButtonElement>, - userFunc: (e: React.MouseEvent<HTMLButtonElement>) => unknown - ) => { - e.persist(); - setIsOpen(false); - setTimeout(() => { - userFunc(e); - }, 1); - }; - - const rc = () => - React.Children.map(children, (ch, i) => { - if (React.isValidElement<ABProps>(ch)) { - return ( - <li className={`rtf--ab__c ${'top' in style ? 'top' : ''}`}> - {React.cloneElement(ch, { - 'data-testid': `action-button-${i}`, - 'aria-label': ch.props.text || `Menu button ${i + 1}`, - 'aria-hidden': ariaHidden, - tabIndex: isOpen ? 0 : -1, - ...ch.props, - onClick: (e: React.MouseEvent<HTMLButtonElement>) => { - if (ch.props.onClick) actionOnClick(e, ch.props.onClick); - }, - })} - {ch.props.text && ( - <span - className={`${'right' in style ? 'right' : ''} ${ - alwaysShowTitle ? 'always-show' : '' - }`} - aria-hidden={ariaHidden} - > - {ch.props.text} - </span> - )} - </li> - ); - } - return null; - }); - - return ( - <ul - onMouseEnter={enter} - onMouseLeave={leave} - className={`rtf ${isOpen ? 'open' : 'closed'}`} - data-testid="fab" - style={style} - {...p} - > - <li className="rtf--mb__c"> - <MB - onClick={toggle} - style={mainButtonStyles} - data-testid="main-button" - role="button" - aria-label="Floating menu" - tabIndex={0} - > - {icon} - </MB> - {text && ( - <span - className={`${'right' in style ? 'right' : ''} ${alwaysShowTitle ? 'always-show' : ''}`} - aria-hidden={ariaHidden} - > - {text} - </span> - )} - <ul>{rc()}</ul> - </li> - </ul> - ); -}; - -export { Fab, AB as Action }; diff --git a/src/components/shared/FeatherIcons.ts b/src/components/shared/FeatherIcons.ts index ca9c359..b821ecb 100644 --- a/src/components/shared/FeatherIcons.ts +++ b/src/components/shared/FeatherIcons.ts @@ -1,14 +1,17 @@ export { Activity, + AlertCircle, ArrowDown, ArrowDownCircle, ArrowUp, + CheckCircle, ChevronDown, ChevronUp, Cpu, Database, Download, DownloadCloud, + Edit3, Eye, EyeOff, FileText, @@ -26,6 +29,7 @@ export { RefreshCcw, RefreshCw, RotateCw, + Search, Settings, Shield, Sliders, diff --git a/src/components/shared/Head.tsx b/src/components/shared/Head.tsx index 85783c1..aa92443 100644 --- a/src/components/shared/Head.tsx +++ b/src/components/shared/Head.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; -import { connect } from '~/components/StateProvider'; import { getClashAPIConfig, getClashAPIConfigs } from '~/store/app'; +import { connect } from '~/store/StateProvider'; +import type { State } from '~/store/types'; -const mapState = (s) => ({ +const mapState = (s: State) => ({ apiConfig: getClashAPIConfig(s), apiConfigs: getClashAPIConfigs(s), }); diff --git a/src/components/shared/Input.module.scss b/src/components/shared/Input.module.scss new file mode 100644 index 0000000..dfa55b5 --- /dev/null +++ b/src/components/shared/Input.module.scss @@ -0,0 +1,36 @@ +.input { + -webkit-appearance: none; + // 和顶栏搜索框一套:凹槽色垫底 + 卡片边框色描边,垫在卡片上才看得出是输入区 + background-color: var(--color-track); + background-image: none; + border-radius: 8px; + border: 1px solid var(--color-card-border); + box-sizing: border-box; + color: var(--color-text); + display: inline-block; + height: 35px; + outline: none; + padding: 0 15px; + width: 100%; + font-size: small; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; + + &::placeholder { + color: var(--color-text-secondary); + opacity: 0.75; + } +} + +.input:focus { + border-color: var(--color-focus-blue); + // 与开关的聚焦圈同一个 token,别再写死一个对不上的蓝 + box-shadow: 0 0 0 3px var(--color-accent-soft-bg); +} + +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} diff --git a/src/components/Input.tsx b/src/components/shared/Input.tsx index 4fb17f2..901aeb5 100644 --- a/src/components/Input.tsx +++ b/src/components/shared/Input.tsx @@ -12,7 +12,11 @@ export default function Input({ return <input className={cx(s0.input, className)} {...props} />; } -export function SelfControlledInput({ value, className, ...restProps }) { +export function SelfControlledInput({ + value, + className, + ...restProps +}: React.InputHTMLAttributes<HTMLInputElement>) { const [internalValue, setInternalValue] = useState(value); const refValue = useRef(value); useEffect(() => { @@ -22,7 +26,10 @@ export function SelfControlledInput({ value, className, ...restProps }) { } refValue.current = value; }, [value]); - const onChange = useCallback((e) => setInternalValue(e.target.value), [setInternalValue]); + const onChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => setInternalValue(e.target.value), + [setInternalValue], + ); return ( <input diff --git a/src/components/Loading.module.scss b/src/components/shared/Loading.module.scss index c3f1d16..c3f1d16 100644 --- a/src/components/Loading.module.scss +++ b/src/components/shared/Loading.module.scss diff --git a/src/components/Loading.tsx b/src/components/shared/Loading.tsx index 12ced75..12ced75 100644 --- a/src/components/Loading.tsx +++ b/src/components/shared/Loading.tsx diff --git a/src/components/shared/Modal.module.scss b/src/components/shared/Modal.module.scss new file mode 100644 index 0000000..e4e6af5 --- /dev/null +++ b/src/components/shared/Modal.module.scss @@ -0,0 +1,52 @@ +@keyframes overlayShow { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes contentShow { + from { + opacity: 0; + scale: 0.96; + } + to { + opacity: 1; + scale: 1; + } +} + +.overlay { + position: fixed; + inset: 0; + background: #444; + z-index: 1024; + + &[data-state='open'] { + animation: overlayShow 0.2s ease; + } +} + +// Radix 的 Content 是 Overlay 的兄弟节点,需自己定位。 +// 这里用独立的 translate / scale 属性而非 transform,避免与各 modal 自带的 transform 冲突。 +.content { + position: fixed; + top: 50%; + left: 50%; + translate: -50% -50%; + z-index: 1025; + max-height: 100vh; + overflow: auto; + outline: none; + color: var(--color-text); + background: var(--bg-modal); + padding: 20px; + border-radius: var(--border-radius); + box-shadow: var(--shadow-card); + + &[data-state='open'] { + animation: contentShow 0.2s ease; + } +} diff --git a/src/components/shared/Modal.tsx b/src/components/shared/Modal.tsx new file mode 100644 index 0000000..b208356 --- /dev/null +++ b/src/components/shared/Modal.tsx @@ -0,0 +1,81 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import cx from 'clsx'; +import * as React from 'react'; + +import s0 from './Modal.module.scss'; + +// react-modal 的 className 支持对象形式;迁移到 Radix 后过渡由 [data-state] 驱动, +// 这里只取 base,afterOpen/beforeClose 已无意义但保留类型以兼容调用方。 +type ClassNameObj = { base?: string; afterOpen?: string; beforeClose?: string }; + +type Props = { + isOpen: boolean; + onRequestClose?: (...args: any[]) => any; + onAfterOpen?: () => void; + className?: string | ClassNameObj; + overlayClassName?: string; + shouldCloseOnOverlayClick?: boolean; + shouldCloseOnEsc?: boolean; + /** 无障碍标题。不传时用视觉隐藏的默认值,避免 Radix 缺少 Title 的告警。 */ + title?: string; + children: React.ReactNode; +}; + +function resolveClassName(className: Props['className']): string | undefined { + if (!className) return undefined; + return typeof className === 'string' ? className : className.base; +} + +function ModalBase({ + isOpen, + onRequestClose, + onAfterOpen, + className, + overlayClassName, + shouldCloseOnOverlayClick = true, + shouldCloseOnEsc = true, + title = 'Dialog', + children, +}: Props) { + const onOpenChange = React.useCallback( + (open: boolean) => { + if (!open) onRequestClose && onRequestClose(); + }, + [onRequestClose], + ); + + // react-modal 的 onAfterOpen 对应 Radix 打开后的自动聚焦时机 + const onOpenAutoFocus = React.useCallback( + (e: Event) => { + if (onAfterOpen) { + e.preventDefault(); + onAfterOpen(); + } + }, + [onAfterOpen], + ); + + return ( + <Dialog.Root open={isOpen} onOpenChange={onOpenChange}> + <Dialog.Portal> + <Dialog.Overlay className={cx(s0.overlay, overlayClassName)} /> + <Dialog.Content + className={cx(s0.content, resolveClassName(className))} + onOpenAutoFocus={onOpenAutoFocus} + onInteractOutside={(e) => { + if (!shouldCloseOnOverlayClick) e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + if (!shouldCloseOnEsc) e.preventDefault(); + }} + aria-describedby={undefined} + > + <Dialog.Title className="visually-hidden">{title}</Dialog.Title> + {children} + </Dialog.Content> + </Dialog.Portal> + </Dialog.Root> + ); +} + +export default React.memo(ModalBase); diff --git a/src/components/shared/PageHeader.module.scss b/src/components/shared/PageHeader.module.scss new file mode 100644 index 0000000..bf2fe3e --- /dev/null +++ b/src/components/shared/PageHeader.module.scss @@ -0,0 +1,400 @@ +/** + * 页面顶栏的统一样式,代理 / 连接 / 规则 / 日志四个页面共用(见 PageHeader.tsx)。 + * + * 断点约定: + * >1024 一行排下:标题 · 分段标签 · 搜索 · 操作钮 + * ≤1024 搜索换到第二行,操作钮靠 margin 顶到第一行右端 + * ≤768 顶栏拉平到页面底色(窄屏顶部已经有一条不透明的导航条, + * 顶栏再铺一层外壳色会夹出第三条色带),按钮退化成正方形图标钮, + * 凹陷的控件(分段标签轨道、输入框)翻成浮起 + */ + +.header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + gap: 12px; + padding: 20px 32px 15px; + // 半透明外壳色 + 毛玻璃 + 下边框:内容从底下滚过去时能透出一点, + // 同时比纯白的卡片灰一档。弹层都 portal 到 body, + // 不受 backdrop-filter 生成包含块的影响 + background: var(--color-chrome); + backdrop-filter: saturate(180%) blur(20px); + border-bottom: 1px solid var(--color-chrome-border); + + @media (max-width: 1024px) { + flex-wrap: wrap; + padding: 16px 16px 12px; + } + + @media (max-width: 768px) { + gap: 8px; + padding: 10px 12px; + background: var(--color-background); + backdrop-filter: none; + } +} + +.title { + margin: 0; + font-size: 1.6rem; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.01em; + color: var(--color-text-highlight); + white-space: nowrap; + + @media (max-width: 1024px) { + font-size: 1.35rem; + } + + @media (max-width: 768px) { + font-size: 1.1rem; + } +} + +/** 窄屏下强制换行的占位,让它后面的东西独占下一行 */ +.rowBreak { + display: none; + + @media (max-width: 768px) { + display: block; + flex-basis: 100%; + height: 0; + } +} + +/* ---------- 分段标签 ---------- */ + +.tabs { + display: flex; + align-items: center; + gap: 2px; + padding: 4px; + border-radius: 12px; + background: var(--color-track); + flex-shrink: 0; + + // 顶栏在窄屏拉平到页面底色后,--color-track 就是底色本身,凹槽会整个消失。 + // 这里去掉凹槽,改成「选中项浮起来」的样式,和导航胶囊、卡片同一套语言 + @media (max-width: 768px) { + padding: 0; + gap: 4px; + background: none; + } +} + +.tab { + display: inline-flex; + align-items: center; + gap: 7px; + appearance: none; + border: none; + background: transparent; + font-family: inherit; + font-size: 0.88rem; + font-weight: 500; + color: var(--color-text-secondary); + padding: 7px 14px; + border-radius: 9px; + cursor: pointer; + white-space: nowrap; + transition: + background-color 0.15s ease, + color 0.15s ease, + box-shadow 0.15s ease; + + &:hover:not(.tabActive) { + color: var(--color-text); + background: var(--color-hover-soft); + } + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: -1px; + } + + @media (max-width: 768px) { + gap: 5px; + padding: 5px 10px; + font-size: 0.8rem; + } +} + +.tabActive { + background: var(--color-card); + color: var(--color-text-highlight); + box-shadow: var(--shadow-segment); + + @media (max-width: 768px) { + border: 1px solid var(--color-card-border); + // 补掉边框占掉的 1px,选中和未选中的高度才对得齐 + padding: 4px 9px; + } +} + +.tabCount { + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 500; + color: var(--color-text-secondary); + + // 小屏上标题 + 标签 + 几个操作钮刚好挤满一行,计数是这里最先能舍的东西 + @media (max-width: 400px) { + display: none; + } +} + +/* ---------- 搜索 ---------- */ + +.search { + position: relative; + display: flex; + align-items: center; + flex: 1 1 240px; + min-width: 160px; + max-width: 340px; + margin-left: auto; + + // 同时覆盖裸 <input> 和 TextFilter 自带的样式,两种用法长得一样 + input { + width: 100%; + height: 38px; + padding: 0 12px 0 38px; + border-radius: 10px; + border: 1px solid var(--color-card-border); + background: var(--color-track); + box-shadow: none; + color: var(--color-text); + font: inherit; + font-size: 0.85rem; + outline: none; + appearance: none; + + &::placeholder { + color: var(--color-text-secondary); + opacity: 0.75; + } + + &:focus { + border-color: var(--color-focus-blue); + } + } + + @media (max-width: 1024px) { + flex-basis: 100%; + min-width: 0; + max-width: none; + margin-left: 0; + } + + @media (max-width: 768px) { + input { + height: 34px; + padding-left: 34px; + font-size: 0.8rem; + // 同分段标签:底色拉平后凹陷的输入框会看不见,改成和卡片同层浮起 + background: var(--color-card); + } + } +} + +/** + * 搜索旁边还要并排放别的控件(下拉筛选)时叠这个类: + * 窄屏下别独占整行,把剩下的宽度让出来 + */ +.searchInline { + @media (max-width: 768px) { + flex: 1 1 auto; + flex-basis: auto; + min-width: 0; + } +} + +.searchIcon { + position: absolute; + left: 13px; + color: var(--color-text-secondary); + pointer-events: none; + + @media (max-width: 768px) { + left: 11px; + } +} + +/* ---------- 下拉筛选 ---------- */ + +// 叠在 Select 组件自带样式之上,把它拉齐到顶栏这套尺寸。 +// 类名写两遍是为了翻倍特异性——Select 自己的 .select 也是单类, +// 靠打包顺序决定胜负太脆 +.select.select { + height: 38px; + width: auto; + flex-shrink: 0; + border-radius: 10px; + border-color: var(--color-card-border); + background-color: var(--color-track); + box-shadow: none; + font-size: 0.85rem; + + @media (max-width: 768px) { + height: 34px; + font-size: 0.8rem; + background-color: var(--color-card); + } +} + +/* ---------- 操作按钮 ---------- */ + +.actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + + // 搜索换到第二行后,靠 margin 把操作钮顶到第一行右端 + @media (max-width: 1024px) { + margin-left: auto; + } + + @media (max-width: 768px) { + gap: 6px; + } +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + appearance: none; + height: 38px; + padding: 0 15px; + border-radius: 10px; + border: 1px solid transparent; + font-family: inherit; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + color 0.15s ease, + opacity 0.15s ease; + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: 2px; + } + + // 窄屏下文字全部收起,按钮退化成正方形图标钮 + @media (max-width: 768px) { + gap: 0; + width: 34px; + height: 34px; + padding: 0; + justify-content: center; + } +} + +.btnGhost { + background: var(--color-card); + border-color: var(--color-card-border); + color: var(--color-text); + + &:hover { + border-color: var(--color-focus-blue); + color: var(--color-focus-blue); + } +} + +.btnPrimary { + background: var(--color-focus-blue); + color: #fff; + + &:hover:not(:disabled) { + filter: brightness(1.08); + } + + &:disabled { + cursor: default; + } +} + +.btnDanger { + background: var(--color-danger); + color: #fff; + + &:hover { + filter: brightness(1.08); + } +} + +.btnPaused { + background: var(--color-warn-soft-bg); + color: var(--color-warn); + border-color: transparent; +} + +.btnBusy { + opacity: 0.75; +} + +/** 中等宽度就收起的文字,让给更重要的按钮 */ +.btnText { + @media (max-width: 1200px) { + display: none; + } +} + +/** 比 .btnText 保留得久一些:只有窄屏才收起 */ +.btnTextSm { + @media (max-width: 768px) { + display: none; + } +} + +.iconBtn { + display: inline-flex; + align-items: center; + justify-content: center; + appearance: none; + width: 38px; + height: 38px; + border-radius: 10px; + border: 1px solid var(--color-card-border); + background: var(--color-card); + color: var(--color-text-secondary); + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; + + &:hover { + border-color: var(--color-focus-blue); + color: var(--color-focus-blue); + } + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: 2px; + } + + @media (max-width: 768px) { + width: 34px; + height: 34px; + } +} + +.iconBtnActive { + background: var(--color-focus-blue); + border-color: var(--color-focus-blue); + color: #fff; + + &:hover { + color: #fff; + } +} diff --git a/src/components/shared/PageHeader.tsx b/src/components/shared/PageHeader.tsx new file mode 100644 index 0000000..69cb7ab --- /dev/null +++ b/src/components/shared/PageHeader.tsx @@ -0,0 +1,206 @@ +import cx from 'clsx'; +import * as React from 'react'; + +import { Search } from '~/components/shared/FeatherIcons'; + +import s from './PageHeader.module.scss'; + +/** + * 页面顶栏的一套零件,代理 / 连接 / 规则 / 日志四个页面共用, + * 样式和断点行为全在 PageHeader.module.scss 里。 + * + * 每个零件都接 className,页面自己的模块可以往上叠特有的东西 + * (比如连接页窄屏下要重排 order)。 + */ + +export function PageHeader({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return <header className={cx(s.header, className)}>{children}</header>; +} + +export function HeaderTitle({ children }: { children: React.ReactNode }) { + return <h1 className={s.title}>{children}</h1>; +} + +/** 窄屏下强制换行,让它后面的东西独占下一行 */ +export function HeaderRowBreak({ className }: { className?: string }) { + return <span className={cx(s.rowBreak, className)} aria-hidden />; +} + +export function HeaderTabs({ + label, + children, + className, +}: { + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( + <div className={cx(s.tabs, className)} role="tablist" aria-label={label}> + {children} + </div> + ); +} + +/** 计数超过三位就收成 999+,否则标签宽度会跟着数字跳 */ +function formatQty(n: number) { + return n < 1000 ? String(n) : '999+'; +} + +export function HeaderTab({ + active, + label, + count, + onClick, +}: { + active: boolean; + label: string; + /** 省略则不显示计数 */ + count?: number; + onClick: () => void; +}) { + return ( + <button + type="button" + role="tab" + aria-selected={active} + className={cx(s.tab, { [s.tabActive]: active })} + onClick={onClick} + > + {label} + {typeof count === 'number' ? <span className={s.tabCount}>{formatQty(count)}</span> : null} + </button> + ); +} + +/** + * 搜索框。传 value/onChange 就是受控输入,传 children 则由调用方 + * 自己塞输入组件(比如挂在 jotai atom 上的 TextFilter)。 + */ +export function HeaderSearch({ + placeholder, + value, + onChange, + children, + className, +}: { + placeholder?: string; + value?: string; + onChange?: (value: string) => void; + children?: React.ReactNode; + className?: string; +}) { + return ( + <div className={cx(s.search, className)}> + <Search size={15} className={s.searchIcon} aria-hidden /> + {children ?? ( + <input + type="text" + name="filter" + autoComplete="off" + value={value} + placeholder={placeholder} + onChange={(e) => onChange?.(e.target.value)} + /> + )} + </div> + ); +} + +export function HeaderActions({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return <div className={cx(s.actions, className)}>{children}</div>; +} + +type ButtonVariant = 'ghost' | 'primary' | 'danger' | 'paused'; + +const variantClass: Record<ButtonVariant, string> = { + ghost: s.btnGhost, + primary: s.btnPrimary, + danger: s.btnDanger, + paused: s.btnPaused, +}; + +export function HeaderButton({ + variant = 'ghost', + icon, + label, + /** 悬停提示,省略则用 label。展开说明按钮作用时才需要单独给 */ + title, + /** + * 文字收起的宽度门槛。'sm' 只在窄屏收(默认,适合主要操作), + * 'md' 中等宽度就收(次要操作,给主要操作腾地方) + */ + hideLabelAt = 'sm', + busy, + disabled, + onClick, +}: { + variant?: ButtonVariant; + icon: React.ReactNode; + label: string; + title?: string; + hideLabelAt?: 'sm' | 'md'; + busy?: boolean; + disabled?: boolean; + onClick: () => void; +}) { + return ( + <button + type="button" + className={cx(s.btn, variantClass[variant], { [s.btnBusy]: busy })} + onClick={onClick} + disabled={disabled} + // 文字会在窄屏被藏掉,读屏和 tooltip 都得靠这两个属性兜住 + aria-label={label} + title={title ?? label} + > + {icon} + <span className={hideLabelAt === 'md' ? s.btnText : s.btnTextSm}>{label}</span> + </button> + ); +} + +export function HeaderIconButton({ + icon, + label, + active, + expanded, + onClick, +}: { + icon: React.ReactNode; + label: string; + active?: boolean; + expanded?: boolean; + onClick: () => void; +}) { + return ( + <button + type="button" + className={cx(s.iconBtn, { [s.iconBtnActive]: active })} + onClick={onClick} + aria-label={label} + aria-expanded={expanded} + title={label} + > + {icon} + </button> + ); +} + +/** 顶栏里的 <Select> 要叠的类名,把它拉齐到按钮那套尺寸 */ +export const headerSelectClass = s.select; + +/** 搜索旁边还并排放了别的控件时,叠在 HeaderSearch 上 */ +export const headerSearchInlineClass = s.searchInline; diff --git a/src/components/shared/Popover.module.scss b/src/components/shared/Popover.module.scss new file mode 100644 index 0000000..78d02ed --- /dev/null +++ b/src/components/shared/Popover.module.scss @@ -0,0 +1,43 @@ +.anchor { + position: relative; + display: inline-flex; +} + +.panel { + position: fixed; + z-index: 30; + background: var(--color-card); + border: 1px solid var(--color-card-border); + border-radius: 14px; + box-shadow: var(--shadow-popover); + padding: 14px 16px; + overflow-y: auto; + // 预留滚动条宽度,否则纵向滚动条出现时会挤掉内容宽度、逼出一条横向滚动条 + scrollbar-gutter: stable; + overscroll-behavior: contain; + animation: popoverIn 0.14s ease-out; +} + +/* 首帧先渲染出来量宽高,定位算完之前不要让用户看到 */ +.measuring { + top: 0; + left: 0; + visibility: hidden; +} + +@keyframes popoverIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .panel { + animation: none; + } +} diff --git a/src/components/shared/Popover.tsx b/src/components/shared/Popover.tsx new file mode 100644 index 0000000..f21bd90 --- /dev/null +++ b/src/components/shared/Popover.tsx @@ -0,0 +1,127 @@ +import cx from 'clsx'; +import * as React from 'react'; +import { createPortal } from 'react-dom'; + +import s from './Popover.module.scss'; + +const { useCallback, useEffect, useLayoutEffect, useRef, useState } = React; + +// 弹层与视口边缘的最小间距 +const VIEWPORT_MARGIN = 12; +// 弹层与触发器的间距 +const ANCHOR_GAP = 8; + +type Position = { top: number; left: number; maxWidth: number; maxHeight: number }; + +type Props = { + isOpen: boolean; + onClose: () => void; + /** 触发器,点击它不会触发「点击外部关闭」 */ + trigger: React.ReactNode; + children: React.ReactNode; + /** 面板与触发器的对齐边 */ + align?: 'left' | 'right'; + label?: string; +}; + +/** + * 锚定弹层,点击外部或按 Esc 关闭。 + * + * 面板通过 portal 挂到 body 上而不是留在触发器里:页面滚动容器 `.content` 是 + * `overflow-x: auto`,绝对定位的面板会被它裁掉;而顶栏的 `backdrop-filter` 又会 + * 成为 fixed 定位的包含块,所以 fixed 也救不了。挂到 body 上再按触发器的位置 + * 算坐标,顺便把面板夹在视口内,窄屏下就不会溢出到屏幕外。 + */ +export function Popover({ isOpen, onClose, trigger, children, align = 'right', label }: Props) { + const anchorRef = useRef<HTMLDivElement>(null); + const panelRef = useRef<HTMLDivElement>(null); + const [position, setPosition] = useState<Position | null>(null); + + const updatePosition = useCallback(() => { + const anchor = anchorRef.current?.getBoundingClientRect(); + const panel = panelRef.current; + if (!anchor || !panel) return; + + const vw = document.documentElement.clientWidth; + const vh = document.documentElement.clientHeight; + const maxWidth = vw - VIEWPORT_MARGIN * 2; + const width = Math.min(panel.offsetWidth, maxWidth); + + const preferredLeft = align === 'right' ? anchor.right - width : anchor.left; + // 夹在视口内,窄屏上触发器靠中间时也不会溢出到屏幕外 + const left = Math.min(Math.max(VIEWPORT_MARGIN, preferredLeft), vw - VIEWPORT_MARGIN - width); + const top = anchor.bottom + ANCHOR_GAP; + + setPosition({ + top, + left, + maxWidth, + maxHeight: Math.max(160, vh - top - VIEWPORT_MARGIN), + }); + }, [align]); + + // 定位要在绘制前完成,否则会看到面板从左上角跳过来 + useLayoutEffect(() => { + if (!isOpen) { + setPosition(null); + return; + } + updatePosition(); + }, [isOpen, updatePosition]); + + useEffect(() => { + if (!isOpen) return; + + const onPointerDown = (e: MouseEvent | TouchEvent) => { + const target = e.target as Node; + if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) return; + onClose(); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('touchstart', onPointerDown); + document.addEventListener('keydown', onKeyDown); + window.addEventListener('resize', updatePosition); + // 捕获阶段,这样内层滚动容器(.content)滚动时也能跟着动 + window.addEventListener('scroll', updatePosition, true); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('touchstart', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [isOpen, onClose, updatePosition]); + + return ( + <div className={s.anchor} ref={anchorRef}> + {trigger} + {isOpen + ? createPortal( + <div + ref={panelRef} + className={cx(s.panel, { [s.measuring]: position === null })} + role="dialog" + aria-label={label} + style={ + position + ? { + top: position.top, + left: position.left, + maxWidth: position.maxWidth, + maxHeight: position.maxHeight, + } + : undefined + } + > + {children} + </div>, + document.body, + ) + : null} + </div> + ); +} diff --git a/src/components/shared/SegmentedControl.module.scss b/src/components/shared/SegmentedControl.module.scss new file mode 100644 index 0000000..522a592 --- /dev/null +++ b/src/components/shared/SegmentedControl.module.scss @@ -0,0 +1,50 @@ +.track { + display: flex; + align-items: center; + gap: 2px; + padding: 3px; + min-width: 0; + border-radius: 10px; + background: var(--color-track); + border: 1px solid var(--color-card-border); +} + +.segment { + flex: 1; + min-width: 0; + appearance: none; + border: none; + background: transparent; + color: var(--color-text-secondary); + font-family: inherit; + font-size: 0.8rem; + font-weight: 500; + line-height: 1; + padding: 7px 10px; + border-radius: 8px; + cursor: pointer; + // 档位多时宁可截断文字,也不要把弹层顶出横向滚动条 + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: + background-color 0.15s ease, + color 0.15s ease, + box-shadow 0.15s ease; + + &:hover:not(.selected) { + color: var(--color-text); + background: var(--color-hover-soft); + } + + &:focus-visible { + outline: 2px solid var(--color-focus-blue); + outline-offset: -1px; + } +} + +.selected { + background: var(--color-card); + color: var(--color-text-highlight); + box-shadow: var(--shadow-segment); +} diff --git a/src/components/shared/SegmentedControl.tsx b/src/components/shared/SegmentedControl.tsx new file mode 100644 index 0000000..93ff64e --- /dev/null +++ b/src/components/shared/SegmentedControl.tsx @@ -0,0 +1,48 @@ +import cx from 'clsx'; +import * as React from 'react'; + +import s from './SegmentedControl.module.scss'; + +export type SegmentedOption<T extends string | number> = { + value: T; + label: React.ReactNode; + title?: string; +}; + +type Props<T extends string | number> = { + options: SegmentedOption<T>[]; + value: T; + onChange: (value: T) => void; + label?: string; + className?: string; +}; + +/** 分段选择器:一条轨道内若干选项,选中项以白色药丸高亮 */ +export function SegmentedControl<T extends string | number>({ + options, + value, + onChange, + label, + className, +}: Props<T>) { + return ( + <div className={cx(s.track, className)} role="radiogroup" aria-label={label}> + {options.map((o) => { + const selected = o.value === value; + return ( + <button + key={o.value} + type="button" + role="radio" + aria-checked={selected} + title={o.title} + className={cx(s.segment, { [s.selected]: selected })} + onClick={() => onChange(o.value)} + > + {o.label} + </button> + ); + })} + </div> + ); +} diff --git a/src/components/shared/Select.module.scss b/src/components/shared/Select.module.scss index 1c42c60..ac54bd6 100644 --- a/src/components/shared/Select.module.scss +++ b/src/components/shared/Select.module.scss @@ -5,12 +5,13 @@ font-size: 0.95em; padding-left: 14px; appearance: none; - background-color: var(--color-input-bg); + // 和输入框一套凹槽色 + 卡片边框色,别再用只有自己在用的 --color-input-bg + background-color: var(--color-track); color: var(--color-text); padding-right: 34px; border-radius: 8px; - border: 1px solid transparent; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); + border: 1px solid var(--color-card-border); + box-shadow: none; background-image: url(data:image/svg+xml,%0A%20%20%20%20%3Csvg%20width%3D%228%22%20height%3D%2224%22%20viewBox%3D%220%200%208%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%207L7%2011H1L4%207Z%22%20fill%3D%22%23999999%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%2017L1%2013L7%2013L4%2017Z%22%20fill%3D%22%23999999%22%20%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E%0A%20%20); background-position: right 12px center; background-repeat: no-repeat; @@ -22,11 +23,10 @@ border-color: var(--color-focus-blue); outline: none !important; color: var(--color-text-highlight); - transform: translateY(-1px); } .select:focus { - transform: translateY(0); - box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.15); + // 与输入框、开关的聚焦圈同一个 token + box-shadow: 0 0 0 3px var(--color-accent-soft-bg); } .select option { diff --git a/src/components/shared/Select.tsx b/src/components/shared/Select.tsx index 70e1924..8e67a01 100644 --- a/src/components/shared/Select.tsx +++ b/src/components/shared/Select.tsx @@ -5,13 +5,17 @@ import s from './Select.module.scss'; type Props = { options: Array<string[]>; - selected: string; + selected: string | undefined; } & React.SelectHTMLAttributes<HTMLSelectElement>; export default function Select({ options, selected, onChange, className, ...props }: Props) { return ( - - <select className={cx(s.select, className)} value={selected} onChange={onChange} {...props}> + <select + className={cx(s.select, className)} + value={selected ?? ''} + onChange={onChange} + {...props} + > {options.map(([value, name]) => ( <option key={value} value={value}> {name} diff --git a/src/components/Selection.module.scss b/src/components/shared/Selection.module.scss index 44cf4d8..44cf4d8 100644 --- a/src/components/Selection.module.scss +++ b/src/components/shared/Selection.module.scss diff --git a/src/components/Selection.tsx b/src/components/shared/Selection.tsx index 1b1f50e..9cabb6a 100644 --- a/src/components/Selection.tsx +++ b/src/components/shared/Selection.tsx @@ -4,10 +4,10 @@ import React from 'react'; import s from './Selection.module.scss'; type SelectionProps = { - OptionComponent?: (...args: any[]) => any; - optionPropsList?: any[]; - selectedIndex?: number; - onChange?: (...args: any[]) => any; + OptionComponent: (...args: any[]) => any; + optionPropsList: any[]; + selectedIndex: number; + onChange: (value: string) => void; }; export function Selection2({ diff --git a/src/components/shared/Sparkline.module.scss b/src/components/shared/Sparkline.module.scss deleted file mode 100644 index bc60060..0000000 --- a/src/components/shared/Sparkline.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -.sparkline { - width: 100%; - height: 10vh; - margin-top: auto; -} diff --git a/src/components/shared/Sparkline.tsx b/src/components/shared/Sparkline.tsx deleted file mode 100644 index bcb96f8..0000000 --- a/src/components/shared/Sparkline.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import * as React from 'react'; -import { Line } from 'react-chartjs-2'; - -import { chartJSResource, chartStyles, commonDataSetProps } from '~/misc/chart'; -import prettyBytes from '~/misc/pretty-bytes'; - -import s from './Sparkline.module.scss'; - -const { useMemo } = React; - -const extraChartOptions: any = { - responsive: true, - maintainAspectRatio: false, - parsing: false, - animation: { - duration: 1000, - easing: 'linear', - }, - animations: { - y: { - duration: 0, - }, - x: { - duration: 0, - }, - }, - transitions: { - active: { - animation: { - duration: 0, - }, - }, - }, - plugins: { - legend: { display: false }, - tooltip: { - enabled: true, - intersect: false, - mode: 'index', - }, - }, - scales: { - x: { - type: 'time', - display: false, - }, - y: { - display: false, - beginAtZero: true, - }, - }, - elements: { - line: { - borderWidth: 1, - tension: 0.4, - }, - point: { - radius: 0, - }, - }, -}; - -export default function Sparkline({ data: dataArray, labels, type, styleIndex = 0 }) { - chartJSResource.read(); - - const isMemory = type === 'inuse'; - - const options = useMemo(() => { - return { - ...extraChartOptions, - scales: { - ...extraChartOptions.scales, - y: { - display: false, - // 内存值稳定,不从零开始,让 Y 轴自动适应数据范围以显示波动 - beginAtZero: !isMemory, - }, - }, - plugins: { - ...extraChartOptions.plugins, - tooltip: { - ...extraChartOptions.plugins.tooltip, - displayColors: false, - callbacks: { - title: () => '', - label(context) { - if (context.parsed.y !== null) { - const suffix = isMemory ? '' : '/s'; - const raw = isMemory ? context.parsed.y : Math.expm1(context.parsed.y); - return prettyBytes(raw) + suffix; - } - return ''; - }, - }, - }, - }, - }; - }, [type, isMemory]); - - const data = useMemo( - () => ({ - datasets: [ - { - ...commonDataSetProps, - ...chartStyles[styleIndex][type], - // 内存用原始值(变化幅度小,不需要压缩);流量用 log1p 压缩尖刺 - data: dataArray.map((v, i) => ({ x: labels[i], y: isMemory ? v : Math.log1p(v) })), - fill: true, - }, - ], - }), - [dataArray, labels, type, styleIndex, isMemory], - ); - - return ( - <div className={s.sparkline}> - <Line data={data} options={options} redraw={false} /> - </div> - ); -} diff --git a/src/components/SvgGithub.tsx b/src/components/shared/SvgGithub.tsx index 45828c2..45828c2 100644 --- a/src/components/SvgGithub.tsx +++ b/src/components/shared/SvgGithub.tsx diff --git a/src/components/SvgYacd.module.scss b/src/components/shared/SvgYacd.module.scss index f668137..f668137 100644 --- a/src/components/SvgYacd.module.scss +++ b/src/components/shared/SvgYacd.module.scss diff --git a/src/components/SvgYacd.tsx b/src/components/shared/SvgYacd.tsx index d7c1b2f..d7c1b2f 100644 --- a/src/components/SvgYacd.tsx +++ b/src/components/shared/SvgYacd.tsx diff --git a/src/components/shared/SwitchThemed.module.scss b/src/components/shared/SwitchThemed.module.scss new file mode 100644 index 0000000..26edc6c --- /dev/null +++ b/src/components/shared/SwitchThemed.module.scss @@ -0,0 +1,56 @@ +// 尺寸与迁移前的 react-switch 保持一致:轨道 44x28,滑块 24 +.root { + --switch-w: 44px; + --switch-h: 28px; + --switch-thumb: 24px; + + position: relative; + flex-shrink: 0; + width: var(--switch-w); + height: var(--switch-h); + padding: 0; + border: none; + border-radius: calc(var(--switch-h) / 2); + background-color: var(--color-toggle-bg); + cursor: pointer; + transition: background-color 0.2s ease; + -webkit-tap-highlight-color: transparent; + + &[data-state='checked'] { + background-color: var(--color-focus-blue); + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--color-accent-soft-bg); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.mini { + --switch-w: 34px; + --switch-h: 20px; + --switch-thumb: 16px; +} + +.thumb { + display: block; + width: var(--switch-thumb); + height: var(--switch-thumb); + border-radius: 50%; + background-color: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + transition: transform 0.2s ease; + transform: translateX(calc((var(--switch-h) - var(--switch-thumb)) / 2)); + will-change: transform; + + &[data-state='checked'] { + transform: translateX( + calc(var(--switch-w) - var(--switch-thumb) - (var(--switch-h) - var(--switch-thumb)) / 2) + ); + } +} diff --git a/src/components/shared/SwitchThemed.tsx b/src/components/shared/SwitchThemed.tsx new file mode 100644 index 0000000..61e91e9 --- /dev/null +++ b/src/components/shared/SwitchThemed.tsx @@ -0,0 +1,33 @@ +import * as Switch from '@radix-ui/react-switch'; +import cx from 'clsx'; +import * as React from 'react'; + +import s from './SwitchThemed.module.scss'; + +type Props = { + checked?: boolean; + onChange?: (checked: boolean) => void; + name?: string; + disabled?: boolean; + size?: 'default' | 'mini'; +}; + +export default function SwitchThemed({ + checked = false, + onChange, + name, + disabled, + size = 'default', +}: Props) { + return ( + <Switch.Root + className={cx(s.root, { [s.mini]: size === 'mini' })} + checked={checked} + onCheckedChange={onChange} + name={name} + disabled={disabled} + > + <Switch.Thumb className={s.thumb} /> + </Switch.Root> + ); +} diff --git a/src/components/shared/TextFitler.module.scss b/src/components/shared/TextFilter.module.scss index 3977aad..3977aad 100644 --- a/src/components/shared/TextFitler.module.scss +++ b/src/components/shared/TextFilter.module.scss diff --git a/src/components/shared/TextFitler.tsx b/src/components/shared/TextFilter.tsx index 567efbc..79c2cfb 100644 --- a/src/components/shared/TextFitler.tsx +++ b/src/components/shared/TextFilter.tsx @@ -1,14 +1,12 @@ +import type { PrimitiveAtom } from 'jotai'; import * as React from 'react'; +import { useTextInput } from '~/hooks/useTextInput'; -import { useTextInut } from '~/hooks/useTextInput'; - -import s from './TextFitler.module.scss'; - -import type { PrimitiveAtom } from 'jotai'; +import s from './TextFilter.module.scss'; export function TextFilter(props: { textAtom: PrimitiveAtom<string>; placeholder?: string }) { - const [onChange, text] = useTextInut(props.textAtom); + const [onChange, text] = useTextInput(props.textAtom); return ( <input className={s.input} diff --git a/src/components/shared/ThemeSwitcher.tsx b/src/components/shared/ThemeSwitcher.tsx index 59c4c3a..4a510d5 100644 --- a/src/components/shared/ThemeSwitcher.tsx +++ b/src/components/shared/ThemeSwitcher.tsx @@ -1,15 +1,15 @@ -import { LazyMotion, domAnimation, m } from 'framer-motion'; +import { domAnimation, LazyMotion, m } from 'framer-motion'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { Tooltip } from '~/components/shared/Tooltip'; -import { connect } from '~/components/StateProvider'; import { getTheme, switchTheme } from '~/store/app'; -import { State } from '~/store/types'; +import { connect } from '~/store/StateProvider'; +import { DispatchFn, State } from '~/store/types'; import s from './ThemeSwitcher.module.scss'; -export function ThemeSwitcherImpl({ theme, dispatch }) { +export function ThemeSwitcherImpl({ theme, dispatch }: { theme: string; dispatch: DispatchFn }) { const { t } = useTranslation(); const themeIcon = React.useMemo(() => { diff --git a/src/components/shared/Toast.module.scss b/src/components/shared/Toast.module.scss new file mode 100644 index 0000000..d5ba905 --- /dev/null +++ b/src/components/shared/Toast.module.scss @@ -0,0 +1,87 @@ +.container { + position: fixed; + z-index: 1000; + right: 16px; + bottom: 16px; + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-end; + pointer-events: none; + + @media (max-width: 768px) { + right: 10px; + left: 10px; + bottom: 10px; + align-items: stretch; + } +} + +.toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: 10px; + max-width: 420px; + padding: 12px 14px; + border-radius: 10px; + background: var(--color-card); + border: 1px solid var(--color-card-border); + box-shadow: var(--shadow-popover); + color: var(--color-text); + font-size: 0.9rem; + line-height: 1.45; + animation: slide-in 0.18s ease-out; + + @media (max-width: 768px) { + max-width: none; + } +} + +.icon { + flex-shrink: 0; + display: flex; + padding-top: 1px; +} + +.success .icon { + color: var(--color-success); +} + +.error .icon { + color: var(--color-danger); +} + +.info .icon { + color: var(--color-focus-blue); +} + +.message { + flex: 1; + word-break: break-word; +} + +.close { + flex-shrink: 0; + display: flex; + padding: 0; + border: 0; + background: none; + cursor: pointer; + color: var(--color-text-secondary); + + &:hover { + color: var(--color-text-highlight); + } +} + +@keyframes slide-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/components/shared/Toast.tsx b/src/components/shared/Toast.tsx new file mode 100644 index 0000000..4d0140e --- /dev/null +++ b/src/components/shared/Toast.tsx @@ -0,0 +1,40 @@ +import cx from 'clsx'; +import { useAtomValue } from 'jotai'; +import { createPortal } from 'react-dom'; + +import { AlertCircle, CheckCircle, Info, X } from '~/components/shared/FeatherIcons'; +import { dismissToast, toastsAtom, type ToastKind } from '~/store/toast'; + +import s from './Toast.module.scss'; + +const ICONS: Record<ToastKind, typeof Info> = { + success: CheckCircle, + error: AlertCircle, + info: Info, +}; + +export function Toaster() { + const toasts = useAtomValue(toastsAtom); + + if (toasts.length === 0) return null; + + return createPortal( + <div className={s.container} role="region" aria-live="polite"> + {toasts.map(({ id, kind, message }) => { + const Icon = ICONS[kind]; + return ( + <div key={id} className={cx(s.toast, s[kind])}> + <span className={s.icon}> + <Icon size={18} /> + </span> + <span className={s.message}>{message}</span> + <button className={s.close} onClick={() => dismissToast(id)} aria-label="Close"> + <X size={16} /> + </button> + </div> + ); + })} + </div>, + document.body, + ); +} diff --git a/src/components/ToggleSwitch.module.scss b/src/components/shared/ToggleSwitch.module.scss index 4b1388c..4b1388c 100644 --- a/src/components/ToggleSwitch.module.scss +++ b/src/components/shared/ToggleSwitch.module.scss diff --git a/src/components/ToggleSwitch.tsx b/src/components/shared/ToggleSwitch.tsx index 58400c9..56d7ffe 100644 --- a/src/components/ToggleSwitch.tsx +++ b/src/components/shared/ToggleSwitch.tsx @@ -3,10 +3,10 @@ import React, { useCallback, useMemo } from 'react'; import s0 from './ToggleSwitch.module.scss'; type Props = { - options?: any[]; - value?: string; - name?: string; - onChange?: (...args: any[]) => any; + options: Array<{ label: string; value: string }>; + value: string; + name: string; + onChange: React.ChangeEventHandler<HTMLInputElement>; }; function ToggleSwitch({ options, value, name, onChange }: Props) { @@ -20,8 +20,10 @@ function ToggleSwitch({ options, value, name, onChange }: Props) { } else if (idx > -1) { return w; } + // value 对不上任何一项时 idxSelected 是 -1,滑块宽度按 0 算 + return 0; }, - [options] + [options], ); const sliderStyle = useMemo(() => { diff --git a/src/components/shared/Tooltip.tsx b/src/components/shared/Tooltip.tsx index 070dd52..2f00a54 100644 --- a/src/components/shared/Tooltip.tsx +++ b/src/components/shared/Tooltip.tsx @@ -16,11 +16,7 @@ export function Tooltip({ <RadixTooltip.Root> <RadixTooltip.Trigger asChild>{children}</RadixTooltip.Trigger> <RadixTooltip.Portal> - <RadixTooltip.Content - className="tooltip-content" - sideOffset={5} - aria-label={ariaLabel} - > + <RadixTooltip.Content className="tooltip-content" sideOffset={5} aria-label={ariaLabel}> {label} </RadixTooltip.Content> </RadixTooltip.Portal> diff --git a/src/components/shared/TrafficChartSample.tsx b/src/components/shared/TrafficChartSample.tsx index 516c20b..352af8a 100644 --- a/src/components/shared/TrafficChartSample.tsx +++ b/src/components/shared/TrafficChartSample.tsx @@ -23,7 +23,7 @@ const data1 = [23e3, 35e3, 46e3, 33e3, 90e3, 68e3, 23e3, 45e3]; const data2 = [184e3, 183e3, 196e3, 182e3, 190e3, 186e3, 182e3, 189e3]; const labels = data1.map((_, i) => i); -export default function TrafficChart({ id }) { +export default function TrafficChart({ id }: { id: number }) { chartJSResource.read(); const data = useMemo( @@ -42,7 +42,7 @@ export default function TrafficChart({ id }) { }, ], }), - [id] + [id], ); return ( diff --git a/src/components/StyleGuide.tsx b/src/components/styleguide/StyleGuide.tsx index 4b13f19..2c054cb 100644 --- a/src/components/StyleGuide.tsx +++ b/src/components/styleguide/StyleGuide.tsx @@ -1,13 +1,11 @@ import React from 'react'; -import Loading from '~/components/Loading'; +import Button from '~/components/shared/Button'; import { Zap } from '~/components/shared/FeatherIcons'; - - -import Button from './Button'; -import Input from './Input'; -import SwitchThemed from './SwitchThemed'; -import ToggleSwitch from './ToggleSwitch'; +import Input from '~/components/shared/Input'; +import Loading from '~/components/shared/Loading'; +import SwitchThemed from '~/components/shared/SwitchThemed'; +import ToggleSwitch from '~/components/shared/ToggleSwitch'; const noop = () => { /* empty */ diff --git a/src/components/svg/Equalizer.tsx b/src/components/svg/Equalizer.tsx deleted file mode 100644 index ae3c858..0000000 --- a/src/components/svg/Equalizer.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from 'react'; - -type Props = { - size?: number; - color?: string; -}; - -export default function Equalizer({ color = 'currentColor', size = 24 }: Props) { - return ( - <svg - fill="none" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - width={size} - height={size} - stroke={color} - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - > - <path d="M2 6h9M18.5 6H22" /> - <circle cx="16" cy="6" r="2" /> - <path d="M22 18h-9M6 18H2" /> - <circle r="2" transform="matrix(-1 0 0 1 8 18)" /> - </svg> - ); -} diff --git a/src/hooks/useMemory.ts b/src/hooks/useMemory.ts deleted file mode 100644 index b6e3b4c..0000000 --- a/src/hooks/useMemory.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { fetchData } from '~/api/memory'; -import { ClashAPIConfig } from '~/types'; - -export default function useMemory(apiConfig: ClashAPIConfig) { - const memory = fetchData(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/hooks/useRemainingViewPortHeight.ts b/src/hooks/useRemainingViewPortHeight.ts deleted file mode 100644 index 2c920c2..0000000 --- a/src/hooks/useRemainingViewPortHeight.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from 'react'; - -const { useState, useRef, useCallback, useLayoutEffect } = React; - -/** - * cosnt [ref, remainingHeight] = useRemainingViewPortHeight(); - * - * return a reference, and the remaining height of the referenced dom node - * to the bottom of the view port - * - */ -export default function useRemainingViewPortHeight<ElementType extends HTMLDivElement>(): [ - React.MutableRefObject<ElementType>, - number -] { - const ref = useRef<ElementType>(null); - const [containerHeight, setContainerHeight] = useState(200); - const updateContainerHeight = useCallback(() => { - const { top } = ref.current.getBoundingClientRect(); - setContainerHeight(window.innerHeight - top); - }, []); - - useLayoutEffect(() => { - updateContainerHeight(); - window.addEventListener('resize', updateContainerHeight); - return () => { - window.removeEventListener('resize', updateContainerHeight); - }; - }, [updateContainerHeight]); - - return [ref, containerHeight]; -} diff --git a/src/hooks/useTextInput.ts b/src/hooks/useTextInput.ts index 8098b42..35256cd 100644 --- a/src/hooks/useTextInput.ts +++ b/src/hooks/useTextInput.ts @@ -4,8 +4,8 @@ import * as React from 'react'; const { useCallback, useState, useMemo } = React; -export function useTextInut( - x: PrimitiveAtom<string> +export function useTextInput( + x: PrimitiveAtom<string>, ): [(e: React.ChangeEvent<HTMLInputElement>) => void, string] { const [, setTextGlobal] = useAtom(x); const [text, setText] = useState(''); @@ -15,7 +15,7 @@ export function useTextInut( setText(e.target.value); setTextDebounced(e.target.value); }, - [setTextDebounced] + [setTextDebounced], ); return [onChange, text]; } diff --git a/src/hooks/useTraffic.ts b/src/hooks/useTraffic.ts deleted file mode 100644 index 66cf4bf..0000000 --- a/src/hooks/useTraffic.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { fetchData } from '~/api/traffic'; -import { ClashAPIConfig } from '~/types'; - -export default function useTraffic(apiConfig: ClashAPIConfig) { - const traffic = fetchData(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; -} diff --git a/src/hooks/useVersion.ts b/src/hooks/useVersion.ts new file mode 100644 index 0000000..d6a4f19 --- /dev/null +++ b/src/hooks/useVersion.ts @@ -0,0 +1,13 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { fetchVersion } from '~/api/version'; +import { ClashAPIConfig } from '~/types'; + +/** 内核版本信息。侧边栏和代理组都要用它区分 meta / premium,共用同一份缓存 */ +export function useVersion(apiConfig: ClashAPIConfig) { + const { data } = useSuspenseQuery({ + queryKey: ['/version', apiConfig], + queryFn: () => fetchVersion('/version', apiConfig), + }); + return data; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 534869c..2dcd296 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -12,6 +12,14 @@ export const data = { 'Download Total': 'Download Total', 'Active Connections': 'Active Connections', 'Memory Usage': 'Memory Usage', + overview_eyebrow: 'Dashboard', + since_core_start: 'Since core start', + conn_unit: 'conns', + realtime_traffic: 'Realtime Traffic', + last_n_seconds: 'Last {{seconds}} seconds', + current_process_memory: 'Current process', + memory_peak: 'Peak', + rule_count: 'Rules', 'Pause Refresh': 'Pause Refresh', 'Resume Refresh': 'Resume Refresh', close_all_connections: 'Close All Connections', @@ -25,6 +33,26 @@ export const data = { general: 'General', management: 'Management', dashboard: 'Dashboard', + proxy_groups: 'Groups', + providers: 'Providers', + search_proxies_placeholder: 'Search groups or nodes', + collapse_all: 'Collapse all', + expand_all: 'Expand all', + test_all: 'Test all', + testing: 'Testing…', + update_all: 'Update all', + update_proxy_provider: 'Update this provider', + node_qty: '{{n}} nodes', + sort_natural: 'Default', + sort_latency: 'Latency', + sort_name: 'Name', + secs: '{{n}}s', + settings_latency: 'Latency test', + settings_display: 'Display', + settings_behavior: 'Behavior', + group_fixed: 'Fixed', + expire_at: 'Expires {{date}}', + updated_ago: 'Updated {{time}} ago', sort_in_grp: 'Sorting in group', hide_unavail_proxies: 'Hide unavailable proxies', auto_close_conns: 'Automatically close old connections', @@ -58,8 +86,14 @@ export const data = { reload_config_file: 'Reload config file', restart_core: 'Restart core', upgrade_core: 'Upgrade core', + upgrade_core_release: 'Stable', + upgrade_core_alpha: 'Alpha', + upgrade_core_success: 'Core upgraded, it is restarting now', + upgrade_core_failed: 'Core upgrade failed: {{message}}', upgrade_geo: 'Upgrade GEO Databases', upgrade_ui: 'Upgrade Dashboard UI', + upgrade_ui_success: 'Dashboard UI upgraded, reloading the page…', + upgrade_ui_failed: 'Dashboard UI upgrade failed: {{message}}', update_geo_databases_file: 'Update GEO Databases ', flush_fake_ip_pool: 'Flush fake-ip data', enable_tun_device: 'Enable TUN Device', @@ -79,6 +113,39 @@ export const data = { c_destination_ip: 'Destination IP', c_type: 'Type', c_ctrl: 'Close', + c_node: 'Outbound', + c_source_port: 'Source Port', + c_network: 'Network', + c_outbound_type: 'Outbound Type', + c_full_chain: 'Full chain', + c_destination: 'Destination', + c_conn_id: 'Connection ID', + search_conns_placeholder: 'Search host, rule or node', + close_all: 'Close all', + close_filtered: 'Close filtered', + conn_settings: 'Connection settings', + total_traffic: 'Total traffic', + conn_empty_title: 'No matching connections', + conn_empty_hint: 'Try another keyword, or check the hide rule in settings', + conn_shown: 'Showing {{shown}} / {{total}}', + conn_sorted_by: 'By {{column}} {{dir}}', + sort_asc: 'ascending', + sort_desc: 'descending', + conn_note_active: 'Click any row for connection details', + conn_note_closed: 'Only the 100 most recent closed connections are kept', + hide_conn_regex: 'Hide connections regex', + hide_conn: 'Hide connections', + hide_conn_hint: 'Match nodes or rules with the regex above', + full_chain: 'Show full proxy chain', + full_chain_hint: 'Show every hop instead of only the final node', + custom_columns: 'Custom columns', + columns_enabled: 'Enabled', + columns_available: 'Available', + columns_all_enabled: 'All columns are enabled', + reset_default_columns: 'Reset columns', + done: 'Done', + drag_to_reorder: 'Drag to reorder', + remove: 'Remove', close_all_confirm: 'Are you sure you want to close all connections?', close_all_confirm_yes: "I'm sure", close_all_confirm_no: 'No', @@ -90,7 +157,48 @@ export const data = { client_tag: 'Client tags', sourceip_tip: "Prefix with / for regular expressions, otherwise it's a complete match", disconnect: 'Close Connection', + conn_details: 'Connection Details', + close: 'Close', internel: 'Internal Connection', Clear: 'Clear', + switch_proxy_failed: 'Failed to switch {{group}}: {{message}}', group_fixed_tip: 'This group has a manually fixed selection; run a latency test to release it', + rule_entry_count: '{{count}} entries', + rule_hit_tip: 'Hit {{count}} times, last {{time}}', + rule_never_hit: 'Never hit', + rule_enable: 'Enable rule', + rule_disable: 'Disable rule', + search_rules_placeholder: 'Search rules or providers', + rules_empty_title: 'No matching rules', + rule_providers_empty_title: 'No matching rule providers', + rules_empty_hint: 'Try another keyword', + rules_shown: '{{count}} shown', + update_rule_provider: 'Update this provider', + search_logs_placeholder: 'Search log messages', + log_level: 'Log level', + logs_empty_hint: 'New entries stream in as traffic flows', + logs_shown: '{{count}} lines', + logs_paused: 'Streaming paused', + logs_scroll_to_bottom: 'Jump to latest', + logs_no_match: 'No matching log entries', + backend_form_title: 'Connect to a backend', + backend_form_title_edit: 'Edit backend', + backend_form_desc: 'Fill in the external-controller address of your Clash / Mihomo core', + protocol: 'Protocol', + host: 'Host', + port: 'Port', + secret: 'Secret', + optional: 'optional', + add_backend: 'Add', + save_backend: 'Save', + cancel_edit: 'Cancel', + edit_backend: 'Edit this backend', + saved_backends: 'Saved backends', + no_saved_backends: 'No backend saved yet', + backend_in_use: 'In use', + backend_no_secret: 'No secret', + use_this_backend: 'Switch to this backend', + remove_backend: 'Remove this backend', + show_secret: 'Show secret', + hide_secret: 'Hide secret', }; diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index 7239363..855279f 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -25,6 +25,29 @@ export const data = { general: 'Основные', management: 'Управление', dashboard: 'Панель управления', + proxy_groups: 'Группы', + providers: 'Провайдеры', + search_proxies_placeholder: 'Поиск групп или узлов', + collapse_all: 'Свернуть все', + expand_all: 'Развернуть все', + test_all: 'Проверить все', + testing: 'Проверка…', + update_all: 'Обновить все', + update_proxy_provider: 'Обновить этот провайдер', + node_qty: '{{n}} узл.', + sort_natural: 'По умолчанию', + sort_latency: 'Задержка', + sort_name: 'Имя', + secs: '{{n}} с', + settings_latency: 'Проверка задержки', + settings_display: 'Отображение', + settings_behavior: 'Поведение', + group_fixed: 'Закреплено', + switch_proxy_failed: 'Не удалось переключить {{group}}: {{message}}', + group_fixed_tip: + 'В этой группе выбор закреплён вручную; запустите проверку задержки, чтобы снять закрепление', + expire_at: 'Истекает {{date}}', + updated_ago: 'Обновлено {{time}} назад', sort_in_grp: 'Сортировка в группе', hide_unavail_proxies: 'Скрыть недоступные прокси', auto_close_conns: 'Автоматически закрывать старые подключения', @@ -56,8 +79,14 @@ export const data = { reload_config_file: 'Перезагрузить конфигурацию', restart_core: 'Перезапустить ядро', upgrade_core: 'Обновить ядро', + upgrade_core_release: 'Стабильная', + upgrade_core_alpha: 'Alpha', + upgrade_core_success: 'Ядро обновлено, выполняется перезапуск', + upgrade_core_failed: 'Не удалось обновить ядро: {{message}}', upgrade_geo: 'Обновить GEO базы данных', upgrade_ui: 'Обновить интерфейс', + upgrade_ui_success: 'Интерфейс обновлён, страница перезагружается…', + upgrade_ui_failed: 'Не удалось обновить интерфейс: {{message}}', update_geo_databases_file: 'Обновить файлы GEO баз данных', flush_fake_ip_pool: 'Очистить пул fake-ip', enable_tun_device: 'Включить TUN устройство', @@ -88,6 +117,8 @@ export const data = { client_tag: 'Метки клиентов', sourceip_tip: 'Префикс / для регулярных выражений, иначе — точное совпадение', disconnect: 'Закрыть подключение', + conn_details: 'Детали подключения', + close: 'Закрыть', internel: 'Внутреннее подключение', Clear: 'Очистить', }; diff --git a/src/i18n/vi.ts b/src/i18n/vi.ts index a136868..1136893 100644 --- a/src/i18n/vi.ts +++ b/src/i18n/vi.ts @@ -21,6 +21,28 @@ export const data = { Down: 'Xuống', 'Test Latency': 'Kiểm tra độ trễ', settings: 'Cài đặt', + proxy_groups: 'Nhóm', + providers: 'Nhà cung cấp', + search_proxies_placeholder: 'Tìm nhóm hoặc nút', + collapse_all: 'Thu gọn tất cả', + expand_all: 'Mở rộng tất cả', + test_all: 'Kiểm tra tất cả', + testing: 'Đang kiểm tra…', + update_all: 'Cập nhật tất cả', + update_proxy_provider: 'Cập nhật nhà cung cấp này', + node_qty: '{{n}} nút', + sort_natural: 'Mặc định', + sort_latency: 'Độ trễ', + sort_name: 'Tên', + secs: '{{n}} giây', + settings_latency: 'Kiểm tra độ trễ', + settings_display: 'Hiển thị', + settings_behavior: 'Hành vi', + group_fixed: 'Đã ghim', + switch_proxy_failed: 'Không thể chuyển {{group}}: {{message}}', + group_fixed_tip: 'Nhóm này đang ghim lựa chọn thủ công; chạy kiểm tra độ trễ để bỏ ghim', + expire_at: 'Hết hạn {{date}}', + updated_ago: 'Cập nhật {{time}} trước', sort_in_grp: 'Sắp xếp trong nhóm', hide_unavail_proxies: 'Ẩn proxy không khả dụng', auto_close_conns: 'Tự động đóng kết nối cũ', @@ -50,6 +72,12 @@ export const data = { reload_config_file: 'Tải lại tệp cấu hình', restart_core: 'Khởi động lõi lại Clash', upgrade_core: 'Nâng cấp lõi Clash', + upgrade_core_release: 'Bản ổn định', + upgrade_core_alpha: 'Bản Alpha', + upgrade_core_success: 'Đã nâng cấp lõi, đang khởi động lại', + upgrade_ui_success: 'Đã nâng cấp giao diện, đang tải lại trang…', + upgrade_ui_failed: 'Nâng cấp giao diện thất bại: {{message}}', + upgrade_core_failed: 'Nâng cấp lõi thất bại: {{message}}', update_geo_databases_file: 'Cập nhật tệp cơ sở dữ liệu GEO', flush_fake_ip_pool: 'Xóa bộ nhớ đệm fake-ip', enable_tun_device: 'Bật thiết bị TUN', @@ -81,6 +109,8 @@ export const data = { sourceip_tip: 'Thêm / vào đầu để sử dụng biểu thức chính quy, nếu không sẽ là kết quả khớp chính xác(By Ohoang7)', disconnect: 'Đóng kết nối', + conn_details: 'Chi tiết kết nối', + close: 'Đóng', internel: 'Kết nối nội bộ', Clear: 'Dọn dẹp', }; diff --git a/src/i18n/zh-cn.ts b/src/i18n/zh-cn.ts index f394279..cd5ae25 100644 --- a/src/i18n/zh-cn.ts +++ b/src/i18n/zh-cn.ts @@ -12,6 +12,14 @@ export const data = { 'Download Total': '下载总量', 'Active Connections': '活动连接', 'Memory Usage': '内存使用情况', + overview_eyebrow: '仪表盘', + since_core_start: '自启动累计', + conn_unit: '条', + realtime_traffic: '实时流量', + last_n_seconds: '过去 {{seconds}} 秒', + current_process_memory: '当前进程占用', + memory_peak: '内存峰值', + rule_count: '规则数量', Memory: '内存', 'Pause Refresh': '暂停刷新', 'Resume Refresh': '继续刷新', @@ -26,6 +34,26 @@ export const data = { general: '常规', management: '管理', dashboard: '面板', + proxy_groups: '代理组', + providers: '提供商', + search_proxies_placeholder: '搜索代理组或节点', + collapse_all: '全部收起', + expand_all: '全部展开', + test_all: '全部测速', + testing: '测速中…', + update_all: '全部更新', + update_proxy_provider: '更新该提供商', + node_qty: '{{n}} 个', + sort_natural: '默认', + sort_latency: '延迟', + sort_name: '名称', + secs: '{{n}} 秒', + settings_latency: '测速', + settings_display: '显示', + settings_behavior: '行为', + group_fixed: '已固定', + expire_at: '到期 {{date}}', + updated_ago: '{{time}}前更新', sort_in_grp: '代理组条目排序', hide_unavail_proxies: '隐藏不可用代理', auto_close_conns: '切换代理时自动断开旧连接', @@ -53,7 +81,7 @@ export const data = { prefer_backend_test_url: '策略组优先使用后端测速 URL', lang: '语言', proxy_provider: '代理提供商', - rule_provider: '规则提供商', + rule_provider: '提供商', update_all_rule_provider: '更新所有规则提供商', update_all_proxy_provider: '更新所有代理提供商', reload_config_file: '重载配置文件', @@ -76,10 +104,49 @@ export const data = { c_destination_ip: '目标IP', c_type: '类型', c_ctrl: '关闭', + c_node: '出站节点', + c_source_port: '源端口', + c_network: '协议', + c_outbound_type: '出站类型', + c_full_chain: '完整链路', + c_destination: '目标地址', + c_conn_id: '连接 ID', + search_conns_placeholder: '搜索域名、规则或节点', + close_all: '全部关闭', + close_filtered: '关闭筛选结果', + conn_settings: '连接设置', + total_traffic: '累计流量', + conn_empty_title: '没有匹配的连接', + conn_empty_hint: '换个关键词,或在设置里检查隐藏规则', + conn_shown: '显示 {{shown}} / {{total}} 条', + conn_sorted_by: '按{{column}}{{dir}}', + sort_asc: '升序', + sort_desc: '降序', + conn_note_active: '点击任意行查看连接详情', + conn_note_closed: '已断开的连接最多保留 100 条', + hide_conn_regex: '隐藏连接正则', + hide_conn: '隐藏连接', + hide_conn_hint: '按上方正则匹配节点或规则', + full_chain: '完整显示代理链', + full_chain_hint: '显示每一跳而非末端节点', + custom_columns: '自定义表格列', + columns_enabled: '已启用', + columns_available: '可用', + columns_all_enabled: '全部列都已启用', + reset_default_columns: '恢复默认列', + done: '完成', + drag_to_reorder: '拖动排序', + remove: '移除', restart_core: '重启核心', upgrade_core: '更新核心', + upgrade_core_release: '稳定版', + upgrade_core_alpha: 'Alpha 版', + upgrade_core_success: '核心更新成功,正在重启', + upgrade_core_failed: '核心更新失败:{{message}}', upgrade_geo: '更新 GEO 数据库', upgrade_ui: '更新面板 UI', + upgrade_ui_success: '面板 UI 更新成功,正在刷新页面', + upgrade_ui_failed: '面板 UI 更新失败:{{message}}', close_all_confirm: '确定关闭所有连接?', close_all_confirm_yes: '确定', close_all_confirm_no: '取消', @@ -91,7 +158,48 @@ export const data = { client_tag: '客户端标签', sourceip_tip: '/开头为正则,否则为全匹配', disconnect: '断开连接', + conn_details: '连接详情', + close: '关闭', internel: '内部链接', Clear: '清空', + switch_proxy_failed: '切换 {{group}} 失败:{{message}}', group_fixed_tip: '该组已手动固定选择,点击测速可解除固定', + rule_entry_count: '{{count}} 条规则', + rule_hit_tip: '已命中 {{count}} 次,最近一次{{time}}', + rule_never_hit: '从未命中', + rule_enable: '启用规则', + rule_disable: '禁用规则', + search_rules_placeholder: '搜索规则或提供商', + rules_empty_title: '没有匹配的规则', + rule_providers_empty_title: '没有匹配的规则提供商', + rules_empty_hint: '换个关键词试试', + rules_shown: '共 {{count}} 条', + update_rule_provider: '更新此提供商', + search_logs_placeholder: '搜索日志内容', + log_level: '日志级别', + logs_empty_hint: '有流量经过时会实时刷出新日志', + logs_shown: '共 {{count}} 行', + logs_paused: '已暂停刷新', + logs_scroll_to_bottom: '回到最新', + logs_no_match: '没有匹配的日志', + backend_form_title: '连接到后端', + backend_form_title_edit: '修改后端', + backend_form_desc: '填写 Clash / Mihomo 内核的 external-controller 地址', + protocol: '协议', + host: '主机', + port: '端口', + secret: '密钥', + optional: '可选', + add_backend: '添加', + save_backend: '保存', + cancel_edit: '取消', + edit_backend: '修改此后端', + saved_backends: '已保存的后端', + no_saved_backends: '还没有添加任何后端', + backend_in_use: '使用中', + backend_no_secret: '未设置密钥', + use_this_backend: '切换到此后端', + remove_backend: '删除此后端', + show_secret: '显示密钥', + hide_secret: '隐藏密钥', }; diff --git a/src/i18n/zh-tw.ts b/src/i18n/zh-tw.ts index 61a91ec..7062db5 100644 --- a/src/i18n/zh-tw.ts +++ b/src/i18n/zh-tw.ts @@ -12,6 +12,14 @@ export const data = { 'Download Total': '總下載', 'Active Connections': '活動中連線', 'Memory Usage': '記憶體使用狀況', + overview_eyebrow: '儀表板', + since_core_start: '自啟動累計', + conn_unit: '條', + realtime_traffic: '即時流量', + last_n_seconds: '過去 {{seconds}} 秒', + current_process_memory: '目前行程佔用', + memory_peak: '記憶體峰值', + rule_count: '規則數量', Memory: '記憶體', 'Pause Refresh': '暫停重整', 'Resume Refresh': '繼續重整', @@ -22,6 +30,26 @@ export const data = { Down: '下載', 'Test Latency': '測試延遲速度', settings: '設定', + proxy_groups: '代理群組', + providers: '提供者', + search_proxies_placeholder: '搜尋代理群組或節點', + collapse_all: '全部收合', + expand_all: '全部展開', + test_all: '全部測速', + testing: '測速中…', + update_all: '全部更新', + update_proxy_provider: '更新該提供者', + node_qty: '{{n}} 個', + sort_natural: '預設', + sort_latency: '延遲', + sort_name: '名稱', + secs: '{{n}} 秒', + settings_latency: '測速', + settings_display: '顯示', + settings_behavior: '行為', + group_fixed: '已固定', + expire_at: '到期 {{date}}', + updated_ago: '{{time}}前更新', sort_in_grp: '依代理群組排序', hide_unavail_proxies: '隱藏不可用的代理伺服器', auto_close_conns: '切換代理伺服器時自動斷開舊連線', @@ -71,8 +99,47 @@ export const data = { c_destination_ip: '目標 IP', c_type: '類型', c_ctrl: '關閉', + c_node: '出站節點', + c_source_port: '來源埠', + c_network: '協定', + c_outbound_type: '出站類型', + c_full_chain: '完整鏈路', + c_destination: '目標位址', + c_conn_id: '連線 ID', + search_conns_placeholder: '搜尋網域、規則或節點', + close_all: '全部關閉', + close_filtered: '關閉篩選結果', + conn_settings: '連線設定', + total_traffic: '累計流量', + conn_empty_title: '沒有符合的連線', + conn_empty_hint: '換個關鍵字,或到設定裡檢查隱藏規則', + conn_shown: '顯示 {{shown}} / {{total}} 條', + conn_sorted_by: '按{{column}}{{dir}}', + sort_asc: '升冪', + sort_desc: '降冪', + conn_note_active: '點擊任一列查看連線詳情', + conn_note_closed: '已斷線的連線最多保留 100 條', + hide_conn_regex: '隱藏連線正規表達式', + hide_conn: '隱藏連線', + hide_conn_hint: '以上方正規表達式比對節點或規則', + full_chain: '完整顯示代理鏈', + full_chain_hint: '顯示每一跳而非末端節點', + custom_columns: '自訂表格列', + columns_enabled: '已啟用', + columns_available: '可用', + columns_all_enabled: '全部列都已啟用', + reset_default_columns: '恢復預設列', + done: '完成', + drag_to_reorder: '拖曳排序', + remove: '移除', restart_core: '重啟核心', upgrade_core: '更新核心', + upgrade_core_release: '穩定版', + upgrade_core_alpha: 'Alpha 版', + upgrade_core_success: '核心更新成功,正在重啟', + upgrade_ui_success: '面板 UI 更新成功,正在重新整理頁面', + upgrade_ui_failed: '面板 UI 更新失敗:{{message}}', + upgrade_core_failed: '核心更新失敗:{{message}}', close_all_confirm: '確定關閉所有連接?', close_all_confirm_yes: '確定', close_all_confirm_no: '取消', @@ -84,7 +151,43 @@ export const data = { client_tag: '客戶端標籤', sourceip_tip: '/開頭為正規表達式,否則為全面配對', disconnect: '斷開連線', + conn_details: '連線詳情', + close: '關閉', internel: '內部連線', Clear: '清空', + switch_proxy_failed: '切換 {{group}} 失敗:{{message}}', group_fixed_tip: '該組已手動固定選擇,點擊測速可解除固定', + search_rules_placeholder: '搜尋規則或提供商', + rules_empty_title: '沒有符合的規則', + rule_providers_empty_title: '沒有符合的規則提供商', + rules_empty_hint: '換個關鍵字試試', + rules_shown: '共 {{count}} 條', + update_rule_provider: '更新此提供商', + search_logs_placeholder: '搜尋日誌內容', + log_level: '日誌等級', + logs_empty_hint: '有流量經過時會即時刷出新日誌', + logs_shown: '共 {{count}} 行', + logs_paused: '已暫停重新整理', + logs_scroll_to_bottom: '回到最新', + logs_no_match: '沒有符合的日誌', + backend_form_title: '連線到後端', + backend_form_title_edit: '修改後端', + backend_form_desc: '填寫 Clash / Mihomo 核心的 external-controller 位址', + protocol: '通訊協定', + host: '主機', + port: '連接埠', + secret: '金鑰', + optional: '選填', + add_backend: '新增', + save_backend: '儲存', + cancel_edit: '取消', + edit_backend: '修改此後端', + saved_backends: '已儲存的後端', + no_saved_backends: '尚未新增任何後端', + backend_in_use: '使用中', + backend_no_secret: '未設定金鑰', + use_this_backend: '切換到此後端', + remove_backend: '刪除此後端', + show_secret: '顯示金鑰', + hide_secret: '隱藏金鑰', }; diff --git a/src/main.tsx b/src/main.tsx index cf24512..16e3a1b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,7 +3,6 @@ import './misc/i18n'; import React from 'react'; import { createRoot } from 'react-dom/client'; -import Modal from 'react-modal'; import App from './App'; import { registerAppBootstrap } from './app/bootstrap'; @@ -11,13 +10,11 @@ import * as swRegistration from './swRegistration'; const rootEl = document.getElementById('app'); if (!rootEl) { - throw new Error('Cannot find #app root element'); + throw new Error('Cannot find #app root element'); } const root = createRoot(rootEl); -Modal.setAppElement(rootEl); - root.render(<App />); swRegistration.register(); diff --git a/src/misc/chart-lib.ts b/src/misc/chart-lib.ts index b704309..fb5298d 100644 --- a/src/misc/chart-lib.ts +++ b/src/misc/chart-lib.ts @@ -23,7 +23,7 @@ Chart.register( TimeScale, Filler, Legend, - Tooltip + Tooltip, ); export { Chart }; diff --git a/src/misc/chart-memory.ts b/src/misc/chart-memory.ts deleted file mode 100644 index aca9982..0000000 --- a/src/misc/chart-memory.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { createAsset } from 'use-asset'; - -import prettyBytes from './pretty-bytes'; -export const chartJSResource = createAsset(() => { - return import('~/misc/chart-lib'); -}); - -export const commonDataSetProps = { - borderWidth: 1.5, - pointRadius: 0, - tension: 0.4, - fill: true, - pointHitRadius: 10, - pointHoverRadius: 4, -}; - -export const memoryChartOptions: any = { - responsive: true, - maintainAspectRatio: false, - parsing: false, - animation: { - duration: 1000, - easing: 'linear', - }, - animations: { - y: { - duration: 0, - }, - x: { - duration: 0, - }, - }, - transitions: { - active: { - animation: { - duration: 0, - }, - }, - }, - elements: { - line: { - tension: 0.4, - }, - }, - interaction: { - mode: 'index', - intersect: false, - }, - plugins: { - legend: { - display: true, - position: 'top', - align: 'end', - labels: { - boxWidth: 12, - usePointStyle: true, - pointStyle: 'circle', - padding: 15, - }, - }, - tooltip: { - enabled: true, - mode: 'index', - intersect: false, - padding: 10, - backgroundColor: 'rgba(0, 0, 0, 0.7)', - titleColor: '#fff', - bodyColor: '#fff', - borderColor: 'rgba(255, 255, 255, 0.1)', - borderWidth: 1, - callbacks: { - label(context) { - let label = context.dataset.label || ''; - if (label) { - label += ': '; - } - if (context.parsed.y !== null) { - label += prettyBytes(context.parsed.y); - } - return label; - }, - }, - }, - }, - scales: { - x: { - type: 'time', - display: false, - }, - y: { - type: 'linear', - display: true, - beginAtZero: true, - grace: '5%', - grid: { - display: true, - color: 'rgba(128, 128, 128, 0.1)', - drawTicks: false, - }, - border: { - display: false, - dash: [5, 5], - }, - ticks: { - maxTicksLimit: 3, - callback(value: number) { - return prettyBytes(value); - }, - }, - }, - }, -}; - -export const chartStyles = [ - { - inuse: { - backgroundColor: 'rgba(81, 168, 221, 0.5)', - borderColor: 'rgb(81, 168, 221)', - }, - }, - { - inuse: { - backgroundColor: 'rgba(245,78,162,0.6)', - borderColor: 'rgba(245,78,162,1)', - }, - }, - { - inuse: { - backgroundColor: 'rgba(94, 175, 223, 0.3)', - borderColor: 'rgb(94, 175, 223)', - }, - }, - { - inuse: { - backgroundColor: 'rgba(242, 174, 62, 0.3)', - borderColor: 'rgb(242, 174, 62)', - }, - }, -]; diff --git a/src/misc/chart.ts b/src/misc/chart.ts index 167caa7..234cabb 100644 --- a/src/misc/chart.ts +++ b/src/misc/chart.ts @@ -1,6 +1,8 @@ +import type { TooltipItem } from 'chart.js'; import { createAsset } from 'use-asset'; import prettyBytes from './pretty-bytes'; + export const chartJSResource = createAsset(() => { return import('~/misc/chart-lib'); }); @@ -69,7 +71,7 @@ export const commonChartOptions: any = { borderColor: 'rgba(255, 255, 255, 0.1)', borderWidth: 1, callbacks: { - label(context) { + label(context: TooltipItem<'line'>) { let label = context.dataset.label || ''; if (label) { label += ': '; diff --git a/src/misc/createResource.ts b/src/misc/createResource.ts deleted file mode 100644 index 9ff57e5..0000000 --- a/src/misc/createResource.ts +++ /dev/null @@ -1,45 +0,0 @@ -// from https://gist.github.com/ryanflorence/e10cc9dbc0e259759ec942ba82e5b57c -export function createResource(getPromise: (key: string) => Promise<any>) { - let cache = {}; - const inflight = {}; - const errors = {}; - - function load(key = 'default') { - inflight[key] = getPromise(key) - .then((val) => { - delete inflight[key]; - cache[key] = val; - }) - .catch((error) => { - errors[key] = error; - }); - return inflight[key]; - } - - function preload(key = 'default') { - if (cache[key] !== undefined || inflight[key]) return; - load(key); - } - - function read(key = 'default') { - if (cache[key] !== undefined) { - return cache[key]; - } else if (errors[key]) { - throw errors[key]; - } else if (inflight[key]) { - throw inflight[key]; - } else { - throw load(key); - } - } - - function clear(key: 'default') { - if (key) { - delete cache[key]; - } else { - cache = {}; - } - } - - return { preload, read, clear }; -} diff --git a/src/misc/errors.ts b/src/misc/errors.ts index 18480f5..2eba9ba 100644 --- a/src/misc/errors.ts +++ b/src/misc/errors.ts @@ -1,6 +1,8 @@ export const DOES_NOT_SUPPORT_FETCH = 0; -export const errors = { +type ErrorInfo = { message: string; detail?: string }; + +export const errors: { default: ErrorInfo; [code: number]: ErrorInfo } = { [DOES_NOT_SUPPORT_FETCH]: { message: 'Browser not supported!', detail: 'This browser does not support "fetch", please choose another one.', diff --git a/src/misc/i18n.ts b/src/misc/i18n.ts index c416e6c..11ab3b6 100644 --- a/src/misc/i18n.ts +++ b/src/misc/i18n.ts @@ -26,7 +26,7 @@ i18next _options: any, url: string, _payload: any, - callback: BackendRequestCallback + callback: BackendRequestCallback, ) { let p: PromiseLike<{ data: any }>; diff --git a/src/misc/motion.ts b/src/misc/motion.ts deleted file mode 100644 index 7fac864..0000000 --- a/src/misc/motion.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createResource } from './createResource'; - -export const framerMotionResouce = createResource(() => import('framer-motion')); diff --git a/src/misc/request-helper.ts b/src/misc/request-helper.ts index db28cde..2fbfecc 100644 --- a/src/misc/request-helper.ts +++ b/src/misc/request-helper.ts @@ -4,7 +4,7 @@ import { ClashAPIConfig, LogsAPIConfig } from '~/types'; const headersCommon = { 'Content-Type': 'application/json' }; function genCommonHeaders({ secret }: { secret?: string }) { - const h = { ...headersCommon }; + const h: Record<string, string> = { ...headersCommon }; if (secret) { h['Authorization'] = `Bearer ${secret}`; } @@ -25,21 +25,30 @@ export function getURLAndInit({ baseURL, secret }: ClashAPIConfig) { }; } +// mihomo 出错时返回 { "message": "..." } +export async function readErrorMessage(res: Response) { + try { + const payload = await res.json(); + if (payload && typeof payload.message === 'string') return payload.message; + } catch { + // 不是 JSON,退回状态行 + } + return res.statusText || String(res.status); +} + export function buildWebSocketURL(apiConfig: ClashAPIConfig, endpoint: string) { const { baseURL, secret } = apiConfig; - const params = new URLSearchParams({ - token: secret, - }); + // 没有 secret 时不能带 token,URLSearchParams 会把 undefined 变成字面量 "undefined" + const params = new URLSearchParams(secret ? { token: secret } : {}); return buildWebSocketURLBase(baseURL, params, endpoint); } export function buildLogsWebSocketURL(apiConfig: LogsAPIConfig, endpoint: string) { const { baseURL, secret, logLevel } = apiConfig; - const params = new URLSearchParams({ - token: secret, - level: logLevel, - }); + const params = new URLSearchParams( + secret ? { token: secret, level: logLevel } : { level: logLevel }, + ); return buildWebSocketURLBase(baseURL, params, endpoint); } diff --git a/src/misc/shallowEqual.ts b/src/misc/shallowEqual.ts deleted file mode 100644 index 6a4fa96..0000000 --- a/src/misc/shallowEqual.ts +++ /dev/null @@ -1,31 +0,0 @@ -const hasOwn = Object.prototype.hasOwnProperty; - -function is(x, y) { - if (x === y) { - return x !== 0 || y !== 0 || 1 / x === 1 / y; - } else { - - return x !== x && y !== y; - } -} - -export default function shallowEqual(objA, objB) { - if (is(objA, objB)) return true; - - if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) { - return false; - } - - const keysA = Object.keys(objA); - const keysB = Object.keys(objB); - - if (keysA.length !== keysB.length) return false; - - for (let i = 0; i < keysA.length; i++) { - if (!hasOwn.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) { - return false; - } - } - - return true; -} diff --git a/src/misc/storage.ts b/src/misc/storage.ts index 15d85d3..56dcdd6 100644 --- a/src/misc/storage.ts +++ b/src/misc/storage.ts @@ -12,7 +12,7 @@ function loadState() { } } -function saveState(state) { +function saveState(state: unknown) { try { const serialized = JSON.stringify(state); localStorage.setItem(StorageKey, serialized); 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; diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx index a721fc9..9fc88ea 100644 --- a/src/pages/AboutPage.tsx +++ b/src/pages/AboutPage.tsx @@ -1,6 +1,6 @@ import { About } from '~/components/about/About'; -import { connect } from '~/components/StateProvider'; import { getClashAPIConfig } from '~/store/app'; +import { connect } from '~/store/StateProvider'; import { State } from '~/store/types'; const mapState = (state: State) => ({ diff --git a/src/pages/BackendPage.tsx b/src/pages/BackendPage.tsx index 7f0cb5e..ddb1db5 100644 --- a/src/pages/BackendPage.tsx +++ b/src/pages/BackendPage.tsx @@ -1,14 +1,15 @@ import * as React from 'react'; import APIConfig from '~/components/backend/APIConfig'; -import { connect } from '~/components/StateProvider'; import { addClashAPIConfig, getClashAPIConfigs, getSelectedClashAPIConfigIndex, removeClashAPIConfig, selectClashAPIConfig, + updateClashAPIConfig, } from '~/store/app'; +import { connect } from '~/store/StateProvider'; import type { ClashAPIConfigWithAddedAt, DispatchFn, State } from '~/store/types'; import type { ClashAPIConfig } from '~/types'; @@ -25,21 +26,28 @@ function BackendPage({ dispatch, apiConfigs, selectedClashAPIConfigIndex }: Prop (config: ClashAPIConfig) => { dispatch(addClashAPIConfig(config)); }, - [dispatch] + [dispatch], ); const handleRemoveConfig = useCallback( (config: ClashAPIConfig) => { dispatch(removeClashAPIConfig(config)); }, - [dispatch] + [dispatch], ); const handleSelectConfig = useCallback( (config: ClashAPIConfig) => { dispatch(selectClashAPIConfig(config)); }, - [dispatch] + [dispatch], + ); + + const handleUpdateConfig = useCallback( + (prev: ClashAPIConfig, next: ClashAPIConfig) => { + dispatch(updateClashAPIConfig(prev, next)); + }, + [dispatch], ); return ( @@ -49,6 +57,7 @@ function BackendPage({ dispatch, apiConfigs, selectedClashAPIConfigIndex }: Prop onAddConfig={handleAddConfig} onRemoveConfig={handleRemoveConfig} onSelectConfig={handleSelectConfig} + onUpdateConfig={handleUpdateConfig} /> ); } diff --git a/src/pages/ConfigPage.tsx b/src/pages/ConfigPage.tsx index 692abac..ecc1a6c 100644 --- a/src/pages/ConfigPage.tsx +++ b/src/pages/ConfigPage.tsx @@ -1,7 +1,7 @@ import Config from '~/components/config/Config'; -import { connect } from '~/components/StateProvider'; import { getClashAPIConfig, getSelectedChartStyleIndex } from '~/store/app'; import { getConfigs } from '~/store/configs'; +import { connect } from '~/store/StateProvider'; import { State } from '~/store/types'; const mapState = (state: State) => ({ diff --git a/src/pages/ConnectionsPage.tsx b/src/pages/ConnectionsPage.tsx index cb7aa96..9313da6 100644 --- a/src/pages/ConnectionsPage.tsx +++ b/src/pages/ConnectionsPage.tsx @@ -1,6 +1,6 @@ import Connections from '~/components/connections/Connections'; -import { connect } from '~/components/StateProvider'; import { getClashAPIConfig } from '~/store/app'; +import { connect } from '~/store/StateProvider'; import { State } from '~/store/types'; const mapState = (state: State) => ({ diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 52d5a25..1a3fffa 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,6 +1,6 @@ import Home from '~/components/home/Home'; -import { connect } from '~/components/StateProvider'; import { getClashAPIConfig, getSelectedChartStyleIndex } from '~/store/app'; +import { connect } from '~/store/StateProvider'; import { State } from '~/store/types'; const mapState = (state: State) => ({ diff --git a/src/pages/LogsPage.tsx b/src/pages/LogsPage.tsx index efb546a..07a9cee 100644 --- a/src/pages/LogsPage.tsx +++ b/src/pages/LogsPage.tsx @@ -1,12 +1,10 @@ import Logs from '~/components/logs/Logs'; -import { connect } from '~/components/StateProvider'; import { getClashAPIConfig, getLogStreamingPaused } from '~/store/app'; import { getLogLevel } from '~/store/configs'; -import { getLogsForDisplay } from '~/store/logs'; +import { connect } from '~/store/StateProvider'; import { State } from '~/store/types'; const mapState = (state: State) => ({ - logs: getLogsForDisplay(state), logLevel: getLogLevel(state), apiConfig: getClashAPIConfig(state), logStreamingPaused: getLogStreamingPaused(state), diff --git a/src/pages/ProxiesPage.tsx b/src/pages/ProxiesPage.tsx index 750adac..1114345 100644 --- a/src/pages/ProxiesPage.tsx +++ b/src/pages/ProxiesPage.tsx @@ -1,28 +1,21 @@ import { createSelector } from 'reselect'; import Proxies from '~/components/proxies/Proxies'; -import { connect } from '~/components/StateProvider'; import { getAutoCloseOldConns, getClashAPIConfig, getCollapsibleIsOpen, getHideUnavailableProxies, - getLatencyTestUrl, - getLatencyTestTimeout, getLatencyTestExpectedStatus, + getLatencyTestTimeout, + getLatencyTestUrl, getPreferBackendLatencyTestUrl, getProviderHealthcheckTimeout, getProxiesLayout, - getProxySortBy, getProxyGroupByProvider, + getProxySortBy, } from '~/store/app'; -import { - getDelay, - getProxies, - getProxyGroupNames, - getProxyProviders, - getShowModalClosePrevConns, -} from '~/store/proxies'; +import { connect } from '~/store/StateProvider'; import { State } from '~/store/types'; const getAppConfig = createSelector( @@ -61,14 +54,11 @@ const getAppConfig = createSelector( }), ); +// 代理数据本身走 TanStack Query(modules/proxies/hooks),这里只映射旧 store 里 +// 那部分持久化的 UI 偏好 const mapState = (state: State) => ({ apiConfig: getClashAPIConfig(state), - groupNames: getProxyGroupNames(state), - proxies: getProxies(state), - proxyProviders: getProxyProviders(state), - delay: getDelay(state), collapsibleIsOpen: getCollapsibleIsOpen(state), - showModalClosePrevConns: getShowModalClosePrevConns(state), appConfig: getAppConfig(state), }); diff --git a/src/pages/RulesPage.tsx b/src/pages/RulesPage.tsx index de5d411..70d9c09 100644 --- a/src/pages/RulesPage.tsx +++ b/src/pages/RulesPage.tsx @@ -1,6 +1,6 @@ import Rules from '~/components/rules/Rules'; -import { connect } from '~/components/StateProvider'; import { getClashAPIConfig } from '~/store/app'; +import { connect } from '~/store/StateProvider'; import { State } from '~/store/types'; const mapState = (state: State) => ({ diff --git a/src/pages/StyleGuidePage.tsx b/src/pages/StyleGuidePage.tsx index 029e070..cd8edfb 100644 --- a/src/pages/StyleGuidePage.tsx +++ b/src/pages/StyleGuidePage.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import StyleGuide from '~/components/StyleGuide'; +import StyleGuide from '~/components/styleguide/StyleGuide'; export default function StyleGuidePage() { return <StyleGuide />; diff --git a/src/components/StateProvider.tsx b/src/store/StateProvider.tsx index 1476b49..ec977bc 100644 --- a/src/components/StateProvider.tsx +++ b/src/store/StateProvider.tsx @@ -1,19 +1,22 @@ -import { produce, setAutoFreeze } from 'immer'; +import { produce } from 'immer'; import React from 'react'; -// in logs store we update logs in place -// outside of immer produce -// this is just workaround -setAutoFreeze(false); +import type { DispatchFn, State } from './types'; const { createContext, memo, useMemo, useRef, useEffect, useCallback, useContext, useState } = React; -export const immer = { produce, setAutoFreeze }; +/** + * 绑定后的 action 树,形状由 store/index.ts 的 actions 决定,深度不定。 + * 这套自研 store 正在被 jotai + TanStack Query 取代(见 TODO.md),不为它补精确类型。 + */ +type BoundActions = Record<string, any>; -const StateContext = createContext(null); -const DispatchContext = createContext(null); -const ActionsContext = createContext(null); +// AppProviders 保证 Provider 一定在树上,这三个默认值取不到, +// 用 null! 断言掉,免得每个消费点都要判一次空 +const StateContext = createContext<State>(null!); +const DispatchContext = createContext<DispatchFn>(null!); +const ActionsContext = createContext<BoundActions>(null!); export function useStoreState() { return useContext(StateContext); @@ -27,8 +30,14 @@ export function useStoreActions() { return useContext(ActionsContext); } +type ProviderProps = { + initialState: State; + actions?: Record<string, unknown>; + children: React.ReactNode; +}; + // boundActionCreators -export default function Provider({ initialState, actions = {}, children }) { +export default function Provider({ initialState, actions = {}, children }: ProviderProps) { const stateRef = useRef(initialState); const [state, setState] = useState(initialState); const getState = useCallback(() => stateRef.current, []); @@ -38,20 +47,20 @@ export default function Provider({ initialState, actions = {}, children }) { } }, [getState]); const dispatch = useCallback( - (actionId: string | ((a: any, b: any) => any), fn: (s: any) => void) => { + (actionId: string | ((a: any, b: any) => any), fn?: (s: any) => void) => { if (typeof actionId === 'function') return actionId(dispatch, getState); + if (!fn) return; const stateNext = produce(getState(), fn); if (stateNext !== stateRef.current) { if (import.meta.env.DEV) { - console.log(actionId, stateNext); } stateRef.current = stateNext; setState(stateNext); } }, - [getState] + [getState], ); const boundActions = useMemo(() => bindActions(actions, dispatch), [actions, dispatch]); @@ -86,7 +95,7 @@ function bindAction(action: any, dispatch: any) { } function bindActions(actions: any, dispatch: any) { - const boundActions = {}; + const boundActions: Record<string, unknown> = {}; for (const key in actions) { const action = actions[key]; if (typeof action === 'function') { diff --git a/src/store/app.ts b/src/store/app.ts index bed48d9..7d992b0 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -5,9 +5,6 @@ import { DEFAULT_LATENCY_TEST_URL, PROVIDER_HEALTHCHECK_TIMEOUT } from '../misc/ import { loadState, saveState } from '../misc/storage'; import { debounce, trimTrailingSlash } from '../misc/utils'; -import { fetchConfigs } from './configs'; -import { closeModal } from './modals'; - export const getClashAPIConfig = (s: State) => { const idx = s.app.selectedClashAPIConfigIndex; return s.app.clashAPIConfigs[idx]; @@ -60,6 +57,10 @@ export function removeClashAPIConfig({ baseURL, secret }: ClashAPIConfig) { if (idx === undefined) return; dispatch('removeClashAPIConfig', (s) => { s.app.clashAPIConfigs.splice(idx, 1); + // 删掉的是选中项前面的条目时,选中项会左移一位,索引得跟着挪 + if (s.app.selectedClashAPIConfigIndex > idx) { + s.app.selectedClashAPIConfigIndex -= 1; + } }); // side effect saveState(getState().app); @@ -89,21 +90,55 @@ export function selectClashAPIConfig({ baseURL, secret }: ClashAPIConfig) { }; } -// unused -export function updateClashAPIConfig({ baseURL, secret }: ClashAPIConfig) { +/** + * 就地修改一条已保存的后端。改的是当前选中的那条时整页 reload —— 和 + * selectClashAPIConfig 同理由:地址或密钥变了,在途的请求和 WebSocket + * 还连着旧后端,手动清理这些状态太复杂。 + */ +export function updateClashAPIConfig(prev: ClashAPIConfig, next: ClashAPIConfig) { return async (dispatch: DispatchFn, getState: GetStateFn) => { - const clashAPIConfig = { baseURL, secret }; - dispatch('appUpdateClashAPIConfig', (s) => { - s.app.clashAPIConfigs[0] = clashAPIConfig; - }); + const idx = findClashAPIConfigIndex(getState, prev); + if (idx === undefined) return; + + const wasSelected = getSelectedClashAPIConfigIndex(getState()) === idx; + const duplicateIdx = findClashAPIConfigIndex(getState, next); + + if (duplicateIdx !== undefined) { + // 改回了原样,什么都不用做 + if (duplicateIdx === idx) return; + // 改成了另一条已存在的配置,去重:删掉正在编辑的这条,选中留下的那条 + dispatch('mergeClashAPIConfig', (s) => { + s.app.clashAPIConfigs.splice(idx, 1); + const survivor = duplicateIdx > idx ? duplicateIdx - 1 : duplicateIdx; + if (wasSelected) { + s.app.selectedClashAPIConfigIndex = survivor; + } else if (s.app.selectedClashAPIConfigIndex > idx) { + s.app.selectedClashAPIConfigIndex -= 1; + } + }); + } else { + dispatch('appUpdateClashAPIConfig', (s) => { + s.app.clashAPIConfigs[idx] = { + baseURL: next.baseURL, + secret: next.secret, + addedAt: s.app.clashAPIConfigs[idx].addedAt, + }; + }); + } + // side effect saveState(getState().app); - dispatch(closeModal('apiConfig')); - dispatch(fetchConfigs(clashAPIConfig)); + + if (!wasSelected) return; + try { + window.location.reload(); + } catch (err) { + // ignore + } }; } -const rootEl = document.querySelector('html'); +const rootEl = document.documentElement; type ThemeType = 'dark' | 'light' | 'auto'; function setTheme(theme: ThemeType = 'light') { @@ -140,7 +175,7 @@ export function selectChartStyleIndex(selectedChartStyleIndex: number | string) }; } -export function updateAppConfig(name: string, value: unknown) { +export function updateAppConfig<K extends keyof StateApp>(name: K, value: StateApp[K]) { return (dispatch: DispatchFn, getState: GetStateFn) => { dispatch('appUpdateAppConfig', (s) => { s.app[name] = value; @@ -160,6 +195,19 @@ export function updateCollapsibleIsOpen(prefix: string, name: string, v: boolean }; } +/** 一次性展开/收起同一前缀下的多个分组(代理页的「全部收起 / 全部展开」) */ +export function updateCollapsibleIsOpenBulk(prefix: string, names: string[], v: boolean) { + return (dispatch: DispatchFn, getState: GetStateFn) => { + dispatch('updateCollapsibleIsOpenBulk', (s: State) => { + for (const name of names) { + s.app.collapsibleIsOpen[`${prefix}:${name}`] = v; + } + }); + // side effect + saveStateDebounced(getState().app); + }; +} + const defaultClashAPIConfig = { baseURL: document.getElementById('app')?.getAttribute('data-base-url') ?? 'http://127.0.0.1:9090', secret: '', @@ -185,7 +233,7 @@ const defaultState: StateApp = { hideUnavailableProxies: false, autoCloseOldConns: true, logStreamingPaused: false, - proxiesLayout: 'single', + proxiesLayout: 'double', proxyGroupByProvider: false, }; @@ -201,32 +249,81 @@ function parseConfigQueryString() { return collector; } -export function initialState() { - let s = loadState(); - s = { ...defaultState, ...s }; - const query = parseConfigQueryString(); +const backendQueryKeys = ['hostname', 'port', 'secret']; - const conf = s.clashAPIConfigs[s.selectedClashAPIConfigIndex]; - if (conf) { - const url = new URL(conf.baseURL); - if (query.hostname) { - if (query.hostname.indexOf('http') === 0) { - url.href = decodeURIComponent(query.hostname); - } else { - url.hostname = query.hostname; - } - } - if (query.port) { - url.port = query.port; +/** + * 后端相关的 URL 参数只在本次加载生效一次,用完就从地址栏抹掉。 + * 切换后端是靠整页 reload 实现的(见 selectClashAPIConfig),参数留在地址栏 + * 会让每次 reload 都把刚选中的后端改写回参数指定的地址。 + */ +function consumeBackendQueryString() { + try { + const url = new URL(window.location.href); + for (const key of backendQueryKeys) url.searchParams.delete(key); + window.history.replaceState(null, '', url.href); + } catch (err) { + // ignore + } +} + +/** + * 把 URL 参数指定的后端选中:已存在就选它,不存在就新增一条。 + * 不能就地改写当前选中的那条 —— 那会把用户存下来的后端地址覆盖掉。 + */ +function applyBackendQueryString(s: StateApp, query: Record<string, string>, isFirstRun: boolean) { + const curr = s.clashAPIConfigs[s.selectedClashAPIConfigIndex] ?? defaultClashAPIConfig; + + let url: URL; + try { + url = new URL(curr.baseURL); + } catch (err) { + url = new URL(defaultClashAPIConfig.baseURL); + } + + if (query.hostname) { + if (query.hostname.indexOf('http') === 0) { + url.href = query.hostname; + } else { + url.hostname = query.hostname; } - // url.href is a stringifier and it appends a trailing slash - // that is not we want - conf.baseURL = trimTrailingSlash(url.href); - if (query.secret) { - conf.secret = query.secret; + } + if (query.port) { + url.port = query.port; + } + + // url.href is a stringifier and it appends a trailing slash + // that is not we want + const baseURL = trimTrailingSlash(url.href); + // 没给 secret 时只有地址没变才沿用当前的,别把 A 后端的密钥带到 B 后端上 + const secret = query.secret ?? (baseURL === curr.baseURL ? curr.secret : ''); + + // 首次访问时列表里只有一条占位的默认后端,直接顶掉,不要留个连不上的空壳 + if (isFirstRun) { + s.clashAPIConfigs = [{ baseURL, secret, addedAt: Date.now() }]; + s.selectedClashAPIConfigIndex = 0; + } else { + const idx = s.clashAPIConfigs.findIndex((x) => x.baseURL === baseURL && x.secret === secret); + if (idx >= 0) { + s.selectedClashAPIConfigIndex = idx; + } else { + s.clashAPIConfigs.push({ baseURL, secret, addedAt: Date.now() }); + s.selectedClashAPIConfigIndex = s.clashAPIConfigs.length - 1; } } + saveState(s); + consumeBackendQueryString(); +} + +export function initialState() { + const persisted = loadState(); + const s: StateApp = { ...defaultState, ...persisted }; + const query = parseConfigQueryString(); + + if (backendQueryKeys.some((key) => query[key])) { + applyBackendQueryString(s, query, !persisted); + } + if (query.theme === 'dark' || query.theme === 'light') { s.theme = query.theme; } @@ -234,6 +331,6 @@ export function initialState() { document.title = decodeURIComponent(query.title); } // set initial theme - setTheme(s.theme); + setTheme(s.theme as ThemeType); return s; } diff --git a/src/store/configs.ts b/src/store/configs.ts index dd54dea..61d4111 100644 --- a/src/store/configs.ts +++ b/src/store/configs.ts @@ -1,3 +1,4 @@ +import { readErrorMessage } from '~/misc/request-helper'; import { ClashGeneralConfig, DispatchFn, @@ -24,9 +25,7 @@ export function fetchConfigs(apiConfig: ClashAPIConfig) { let res: Response; const haveFetched = getHaveFetched(getState()); const controller = new AbortController(); - const timeoutId = haveFetched - ? null - : setTimeout(() => controller.abort(), STARTUP_TIMEOUT_MS); + const timeoutId = haveFetched ? null : setTimeout(() => controller.abort(), STARTUP_TIMEOUT_MS); try { res = await configsAPI.fetchConfigs(apiConfig, haveFetched ? undefined : controller.signal); } catch (err) { @@ -74,7 +73,7 @@ type generalConfig = Omit<ClashGeneralConfig, 'tun'>; export function updateConfigs( apiConfig: ClashAPIConfig, - partialConfg: TunPartial<ClashGeneralConfig> + partialConfg: TunPartial<ClashGeneralConfig>, ) { return async (dispatch: DispatchFn) => { configsAPI @@ -82,15 +81,13 @@ export function updateConfigs( .then( (res) => { if (res.ok === false) { - console.log('Error update configs', res.statusText); } }, (err) => { - console.log('Error update configs', err); throw err; - } + }, ) .then(() => { dispatch(fetchConfigs(apiConfig)); @@ -109,15 +106,13 @@ export function reloadConfigFile(apiConfig: ClashAPIConfig) { .then( (res) => { if (res.ok === false) { - console.log('Error reload config file', res.statusText); } }, (err) => { - console.log('Error reload config file', err); throw err; - } + }, ) .then(() => { dispatch(fetchConfigs(apiConfig)); @@ -132,15 +127,13 @@ export function restartCore(apiConfig: ClashAPIConfig) { .then( (res) => { if (res.ok === false) { - console.log('Error restart core', res.statusText); } }, (err) => { - console.log('Error restart core', err); throw err; - } + }, ) .then(() => { dispatch(fetchConfigs(apiConfig)); @@ -148,27 +141,29 @@ export function restartCore(apiConfig: ClashAPIConfig) { }; } -export function upgradeCore(apiConfig: ClashAPIConfig) { - return async (dispatch: DispatchFn) => { - configsAPI - .upgradeCore(apiConfig) - .then( - (res) => { - if (res.ok === false) { - - console.log('Error upgrade core', res.statusText); - } - }, - (err) => { - - console.log('Error upgrade core', err); - throw err; - } - ) - .then(() => { - dispatch(fetchConfigs(apiConfig)); - }); - }; +export type UpgradeResult = { ok: boolean; message?: string }; + +// 把 upgrade 类接口的响应收敛成 { ok, message },交给调用方决定怎么提示 +async function toUpgradeResult(request: Promise<Response>, logLabel: string) { + let res: Response; + try { + res = await request; + } catch (err) { + console.log(logLabel, err); + return { ok: false, message: err instanceof Error ? err.message : String(err) }; + } + if (!res.ok) { + const message = await readErrorMessage(res); + console.log(logLabel, message); + return { ok: false, message }; + } + return { ok: true }; +} + +export function upgradeCore(apiConfig: ClashAPIConfig, channel?: configsAPI.UpgradeChannel) { + // 内核更新成功后会自行重启,这里不再立刻拉配置,否则大概率打在重启窗口上 + return async (): Promise<UpgradeResult> => + toUpgradeResult(configsAPI.upgradeCore(apiConfig, channel), 'Error upgrade core'); } export function upgradeGeo(apiConfig: ClashAPIConfig) { @@ -178,15 +173,13 @@ export function upgradeGeo(apiConfig: ClashAPIConfig) { .then( (res) => { if (res.ok === false) { - console.log('Error upgrade geo', res.statusText); } }, (err) => { - console.log('Error upgrade geo', err); throw err; - } + }, ) .then(() => { dispatch(fetchConfigs(apiConfig)); @@ -195,26 +188,9 @@ export function upgradeGeo(apiConfig: ClashAPIConfig) { } export function upgradeUI(apiConfig: ClashAPIConfig) { - return async (dispatch: DispatchFn) => { - configsAPI - .upgradeUI(apiConfig) - .then( - (res) => { - if (res.ok === false) { - - console.log('Error upgrade ui', res.statusText); - } - }, - (err) => { - - console.log('Error upgrade ui', err); - throw err; - } - ) - .then(() => { - dispatch(fetchConfigs(apiConfig)); - }); - }; + // 只是把面板静态文件换掉,内核配置没变,不需要回头拉 configs + return async (): Promise<UpgradeResult> => + toUpgradeResult(configsAPI.upgradeUI(apiConfig), 'Error upgrade ui'); } export function flushFakeIPPool(apiConfig: ClashAPIConfig) { @@ -224,15 +200,13 @@ export function flushFakeIPPool(apiConfig: ClashAPIConfig) { .then( (res) => { if (res.ok === false) { - console.log('Error flush FakeIP pool', res.statusText); } }, (err) => { - console.log('Error flush FakeIP pool', err); throw err; - } + }, ) .then(() => { dispatch(fetchConfigs(apiConfig)); diff --git a/src/store/connections.ts b/src/store/connections.ts index e797b52..1576e64 100644 --- a/src/store/connections.ts +++ b/src/store/connections.ts @@ -5,8 +5,17 @@ export type FormattedConn = { upload: number; download: number; start: number; - startTime?: number; + startTime: number; + /** 简写代理链:「策略组 -> 末端节点」 */ chains: string; + /** 末端出站节点 */ + chainNode: string; + /** 最外层策略组,单跳时为空 */ + chainGroup: string; + /** 完整代理链,由外向内每一跳 */ + chainsFull: string; + /** Direct / Proxy / Reject */ + outboundType: string; rule: string; destinationPort: string; destinationIP: string; @@ -19,8 +28,8 @@ export type FormattedConn = { type: string; network: string; process?: string; - downloadSpeedCurr?: number; - uploadSpeedCurr?: number; + downloadSpeedCurr: number; + uploadSpeedCurr: number; }; // 当前活跃连接 @@ -32,5 +41,11 @@ export const closedConnectionsState = atom<FormattedConn[]>([]); // 连接刷新暂停状态 export const isRefreshPausedState = atom<boolean>(false); +// 核心启动以来的累计流量,由 /connections 的 WebSocket 消息直接给出 +export const connectionsTotalState = atom<{ download: number; upload: number }>({ + download: 0, + upload: 0, +}); + // 最大已关闭连接数量限制 export const MAX_CLOSED_CONNECTIONS = 100; diff --git a/src/store/index.ts b/src/store/index.ts index 4fc8e4c..27f39dd 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -5,18 +5,15 @@ import { selectClashAPIConfig, updateAppConfig, updateCollapsibleIsOpen, + updateCollapsibleIsOpenBulk, } from './app'; import { initialState as configs } from './configs'; -import { initialState as logs } from './logs'; import { initialState as modals } from './modals'; -import { actions as proxiesActions, initialState as proxies } from './proxies'; export const initialState = { app: app(), modals, configs, - proxies, - logs, }; export const actions = { @@ -25,9 +22,9 @@ export const actions = { app: { updateCollapsibleIsOpen, + updateCollapsibleIsOpenBulk, updateAppConfig, removeClashAPIConfig, selectClashAPIConfig, }, - proxies: proxiesActions, }; diff --git a/src/store/logs.ts b/src/store/logs.ts index 10023fd..db04506 100644 --- a/src/store/logs.ts +++ b/src/store/logs.ts @@ -1,67 +1,41 @@ -import { createSelector } from 'reselect'; +import { atom } from 'jotai'; -import { DispatchFn, GetStateFn, Log, State } from '~/store/types'; +import type { Log } from '~/types'; const LogSize = 300; -const getLogs = (s: State) => s.logs.logs; -const getTail = (s: State) => s.logs.tail; -export const getSearchText = (s: State) => s.logs.searchText; -export const getLogsForDisplay = createSelector( - getLogs, - getTail, - getSearchText, - (logs, tail, searchText) => { - const x = []; - if (logs.length === LogSize) { - for (let i = tail + 1; i < LogSize; i++) { - x.push(logs[i]); - } - } - for (let i = 0; i <= tail; i++) { +/** 日志搜索词。过滤在组件里做,见 modules/logs/hooks 的 useFilteredLogs */ +export const logFilterText = atom(''); + +/** + * 环形缓冲区。数组本身就地改、每次只换外层对象,免得每条日志复制一份 300 长的数组; + * 靠外层对象的 identity 变化通知 logsForDisplayAtom 重算。 + */ +const logsBufferAtom = atom<{ logs: Log[]; tail: number }>({ logs: [], tail: -1 }); + +/** 把环形缓冲区按时间顺序摊平 */ +export const logsForDisplayAtom = atom((get) => { + const { logs, tail } = get(logsBufferAtom); + const x: Log[] = []; + if (logs.length === LogSize) { + for (let i = tail + 1; i < LogSize; i++) { x.push(logs[i]); } - - if (searchText === '') return x; - return x.filter((r) => r.payload.toLowerCase().indexOf(searchText) >= 0); } -); - -export function updateSearchText(text: string) { - return (dispatch: DispatchFn) => { - dispatch('logsUpdateSearchText', (s) => { - s.logs.searchText = text.toLowerCase(); - }); - }; -} - -export function clearLogs() { - return (dispatch: DispatchFn) => { - dispatch('logsClearLogs', (s) => { - s.logs.logs = []; - s.logs.tail = -1; - }); - }; -} - -export function appendLog(log: Log) { - return (dispatch: DispatchFn, getState: GetStateFn) => { - const s = getState(); - const logs = getLogs(s); - const tailCurr = getTail(s); - const tail = tailCurr >= LogSize - 1 ? 0 : tailCurr + 1; - // mutate intentionally for performance - logs[tail] = log; + for (let i = 0; i <= tail; i++) { + x.push(logs[i]); + } + return x; +}); - dispatch('logsAppendLog', (s: State) => { - s.logs.tail = tail; - }); - }; -} +export const appendLogAtom = atom(null, (get, set, log: Log) => { + const { logs, tail: tailCurr } = get(logsBufferAtom); + const tail = tailCurr >= LogSize - 1 ? 0 : tailCurr + 1; + // mutate intentionally for performance + logs[tail] = log; + set(logsBufferAtom, { logs, tail }); +}); -export const initialState = { - searchText: '', - logs: [], - // tail's initial value must be -1 - tail: -1, -}; +export const clearLogsAtom = atom(null, (_get, set) => { + set(logsBufferAtom, { logs: [], tail: -1 }); +}); diff --git a/src/store/modals.ts b/src/store/modals.ts index 0b27ce9..b43e7c7 100644 --- a/src/store/modals.ts +++ b/src/store/modals.ts @@ -1,6 +1,6 @@ -import { DispatchFn } from './types'; +import { DispatchFn, StateModals } from './types'; -export function openModal(modalName: string) { +export function openModal(modalName: keyof StateModals) { return (dispatch: DispatchFn) => { dispatch(`openModal:${modalName}`, (s) => { s.modals[modalName] = true; @@ -8,7 +8,7 @@ export function openModal(modalName: string) { }; } -export function closeModal(modalName: string) { +export function closeModal(modalName: keyof StateModals) { return (dispatch: DispatchFn) => { dispatch(`closeModal:${modalName}`, (s) => { s.modals[modalName] = false; diff --git a/src/store/proxies.ts b/src/store/proxies.ts new file mode 100644 index 0000000..81cc2bc --- /dev/null +++ b/src/store/proxies.ts @@ -0,0 +1,43 @@ +import { atom, getDefaultStore } from 'jotai'; + +import type { DelayMapping } from './types'; + +/** 代理页搜索词。过滤在 modules/proxies/hooks 里做 */ +export const proxyFilterText = atom(''); + +/** + * 本地测速结果。服务端那份延迟数据在 /proxies 的 history 里,这里只放前端发起的 + * 测速产生的东西:进行中的 testing 标志、失败原因、以及后端数据回来之前的临时值。 + * 合并规则见 modules/proxies/utils 的 mergeDelayMapping + */ +export const delayPatchesAtom = atom<DelayMapping>({}); + +// 用默认 store,这样批量刷新的定时器不必挂在某个组件上 +const store = getDefaultStore(); + +// 批量测速时每个节点的结果各自回来,逐条写 atom 会在一瞬间触发 N 次渲染。 +// 攒够一个窗口再合并写入。 +const FLUSH_INTERVAL_MS = 100; +const pending = new Map<string, DelayMapping[string]>(); +let flushTimer: ReturnType<typeof setTimeout> | null = null; + +export function setDelayPatch( + name: string, + patch: { number?: number; error?: string; testing?: boolean }, +) { + // updatedAt 记的是测速时刻而不是刷新时刻,晚 100ms 写进去会盖掉这期间回来的后端数据 + pending.set(name, { ...pending.get(name), ...patch, updatedAt: Date.now() }); + if (flushTimer !== null) return; + flushTimer = setTimeout(() => { + flushTimer = null; + const patches = new Map(pending); + pending.clear(); + store.set(delayPatchesAtom, (prev) => { + const next = { ...prev }; + for (const [proxyName, p] of patches) { + next[proxyName] = { ...next[proxyName], ...p }; + } + return next; + }); + }, FLUSH_INTERVAL_MS); +} diff --git a/src/store/proxies.tsx b/src/store/proxies.tsx deleted file mode 100644 index 02140cc..0000000 --- a/src/store/proxies.tsx +++ /dev/null @@ -1,613 +0,0 @@ -import { atom } from 'jotai'; - -/* import { ProxyItem, ProxiesMapping, DelayMapping } from '~/store/types'; */ -import { - DispatchFn, - FormattedProxyProvider, - GetStateFn, - ProxiesMapping, - ProxyItem, - ProxyProvider, - State, - StateProxies, - SwitchProxyCtxItem, -} from '~/store/types'; -import { ClashAPIConfig } from '~/types'; - -import * as connAPI from '../api/connections'; -import * as proxiesAPI from '../api/proxies'; - -import { - getAutoCloseOldConns, - getLatencyTestExpectedStatus, - getLatencyTestTimeout, - getLatencyTestUrl, - getPreferBackendLatencyTestUrl, - getProviderHealthcheckTimeout, -} from './app'; - -export const initialState: StateProxies = { - proxies: {}, - delay: {}, - groupNames: [], - showModalClosePrevConns: false, -}; - -const noop = () => null; - -// see all types: -// https://github.com/Dreamacro/clash/blob/master/constant/adapters.go - -// const ProxyTypeBuiltin = ['DIRECT', 'GLOBAL', 'REJECT']; -// const ProxyGroupTypes = ['Fallback', 'URLTest', 'Selector', 'LoadBalance']; -// const ProxyTypes = ['Shadowsocks', 'Snell', 'Socks5', 'Http', 'Vmess']; - -export const NonProxyTypes = [ - 'Direct', - 'Fallback', - 'Reject', - 'Pass', - 'Selector', - 'URLTest', - 'LoadBalance', - 'Unknown', -]; - -export const getProxies = (s: State) => s.proxies.proxies; -export const getDelay = (s: State) => s.proxies.delay; -export const getProxyGroupNames = (s: State) => s.proxies.groupNames; -export const getProxyProviders = (s: State) => s.proxies.proxyProviders || []; -export const getDangleProxyNames = (s: State) => s.proxies.dangleProxyNames; -export const getShowModalClosePrevConns = (s: State) => s.proxies.showModalClosePrevConns; - -// The URL the backend is configured to test a group against: its `testUrl`, -// falling back to the first key of `extra` (extra is keyed by test URL). -function getGroupBackendTestUrl(s: State, groupName: string): string | undefined { - const g = getProxies(s)[groupName]; - if (!g) return undefined; - if (g.testUrl) return g.testUrl; - const keys = g.extra ? Object.keys(g.extra) : []; - return keys.length > 0 ? keys[0] : undefined; -} - -// Resolve the effective latency-test URL for a group test, honoring the -// "prefer backend test URL" setting. Falls back to the panel URL. -function resolveGroupTestUrl(s: State, groupName: string): string { - if (getPreferBackendLatencyTestUrl(s)) { - const backendUrl = getGroupBackendTestUrl(s, groupName); - if (backendUrl) return backendUrl; - } - return getLatencyTestUrl(s); -} - -// Structural equality for the plain JSON data coming from the API. -function deepEqualJson(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (Array.isArray(a)) { - if (!Array.isArray(b) || a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (!deepEqualJson(a[i], b[i])) return false; - } - return true; - } - if (a && b && typeof a === 'object' && typeof b === 'object' && !Array.isArray(b)) { - const aObj = a as Record<string, unknown>; - const bObj = b as Record<string, unknown>; - const aKeys = Object.keys(aObj); - if (aKeys.length !== Object.keys(bObj).length) return false; - for (const k of aKeys) { - if (!deepEqualJson(aObj[k], bObj[k])) return false; - } - return true; - } - return false; -} - -export function fetchProxies(apiConfig: ClashAPIConfig) { - return async (dispatch: any, getState: any) => { - 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 }; - // providerProxies has providerName set, but proxiesData.proxies overwrites those entries, - // losing providerName. Restore it for all proxies that came from a provider. - for (const name of Object.keys(providerProxies)) { - if (proxies[name]) { - proxies[name] = { ...proxies[name], providerName: providerProxies[name].providerName }; - } - } - const [groupNames, proxyNames] = retrieveGroupNamesFrom(proxies); - - // Everything below (until the dispatch) is synchronous, so this state - // snapshot can't go stale in between. - const state = getState(); - const delayPrev = getDelay(state); - const delayNext = { ...delayPrev }; - let delayChanged = false; - - for (let i = 0; i < proxyNames.length; i++) { - const name = proxyNames[i]; - const { history } = proxies[name] || { history: [] }; - const h = history[history.length - 1]; - if (h && typeof h.delay === 'number') { - const prev = delayPrev[name]; - // keep the previous entry (and its identity) when it already carries this number - if (prev && prev.number === h.delay && !prev.error && !prev.testing) continue; - delayNext[name] = { number: h.delay }; - delayChanged = true; - } - } - - // proxies that are not from a provider - const dangleProxyNames = []; - for (const v of proxyNames) { - if (!providerProxies[v]) dangleProxyNames.push(v); - } - - // Reuse previous references for entries that didn't change so memoized - // components (Proxy, ProxyGroup) can bail out of re-rendering; when - // nothing changed at all, every field keeps its identity and the dispatch - // below becomes a no-op (no re-render, e.g. on window-focus refetch). - const proxiesPrev = getProxies(state); - const proxyKeys = Object.keys(proxies); - let proxiesChanged = proxyKeys.length !== Object.keys(proxiesPrev).length; - for (const name of proxyKeys) { - const prev = proxiesPrev[name]; - if (prev && deepEqualJson(prev, proxies[name])) { - proxies[name] = prev; - } else { - proxiesChanged = true; - } - } - - const providersPrev = getProxyProviders(state); - let providersChanged = providersPrev.length !== proxyProviders.length; - for (let i = 0; i < proxyProviders.length; i++) { - const prev = providersPrev[i]; - if (prev && deepEqualJson(prev, proxyProviders[i])) { - proxyProviders[i] = prev; - } else { - providersChanged = true; - } - } - - const groupNamesPrev = getProxyGroupNames(state); - const danglePrev = getDangleProxyNames(state); - - dispatch('store/proxies#fetchProxies', (s: State) => { - s.proxies.proxies = proxiesChanged ? proxies : proxiesPrev; - s.proxies.groupNames = deepEqualJson(groupNamesPrev, groupNames) - ? groupNamesPrev - : groupNames; - s.proxies.delay = delayChanged ? delayNext : delayPrev; - s.proxies.proxyProviders = providersChanged ? proxyProviders : providersPrev; - s.proxies.dangleProxyNames = - danglePrev && deepEqualJson(danglePrev, dangleProxyNames) ? danglePrev : dangleProxyNames; - }); - }; -} - -export function updateProviderByName(apiConfig: ClashAPIConfig, name: string) { - return async (dispatch: DispatchFn) => { - try { - await proxiesAPI.updateProviderByName(apiConfig, name); - } catch (x) { - // ignore - } - // should be optimized - // but ¯\_(ツ)_/¯ - dispatch(fetchProxies(apiConfig)); - }; -} - -export function updateProviders(apiConfig: ClashAPIConfig, names: string[]) { - return async (dispatch: DispatchFn) => { - for (let i = 0; i < names.length; i++) { - try { - await proxiesAPI.updateProviderByName(apiConfig, names[i]); - } catch (x) { - // ignore - } - } - // should be optimized - // but ¯\_(ツ)_/¯ - dispatch(fetchProxies(apiConfig)); - }; -} - -// Run `fn` with a signal that aborts after `ms`, always clearing the timer. -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); - } -} - -async function healthcheckProviderByNameInternal( - apiConfig: ClashAPIConfig, - name: string, - signal?: AbortSignal, -) { - try { - await proxiesAPI.healthcheckProviderByName(apiConfig, name, signal); - } catch (x) { - // ignore (includes AbortError when the client-side timeout fires) - } -} - -export function healthcheckProviderByName(apiConfig: ClashAPIConfig, name: string) { - return async (dispatch: DispatchFn, getState: GetStateFn) => { - await withTimeout(getProviderHealthcheckTimeout(getState()), (signal) => - healthcheckProviderByNameInternal(apiConfig, name, signal), - ); - // should be optimized - // but ¯\_(ツ)_/¯ - await dispatch(fetchProxies(apiConfig)); - }; -} - -type DelayPatch = { number?: number; error?: string; testing?: boolean; updatedAt?: number }; - -// Batch delay updates: during a bulk latency test each proxy's result lands -// separately, and dispatching per result re-renders the whole proxies page N -// times in a burst. Buffer the patches and flush them in one dispatch. -const DELAY_FLUSH_INTERVAL_MS = 100; -const pendingDelayPatches = new Map<string, DelayPatch>(); -let delayFlushTimer: ReturnType<typeof setTimeout> | null = null; - -function updateDelayEntry(dispatch: DispatchFn, name: string, patch: DelayPatch) { - pendingDelayPatches.set(name, { ...pendingDelayPatches.get(name), ...patch }); - if (delayFlushTimer !== null) return; - delayFlushTimer = setTimeout(() => { - delayFlushTimer = null; - const patches = new Map(pendingDelayPatches); - pendingDelayPatches.clear(); - dispatch('store/proxies#delay', (s: State) => { - for (const [proxyName, p] of patches) { - s.proxies.delay[proxyName] = { ...s.proxies.delay[proxyName], ...p }; - } - }); - }, DELAY_FLUSH_INTERVAL_MS); -} - -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); - /* throw new Error(); */ - } - const json = await res.json(); - const connections = json.connections; - const idsToClose = []; - for (const conn of connections) { - if ( - // include the groupName - conn.chains.indexOf(groupName) > -1 && - // but not include the itemName - conn.chains.indexOf(exceptionItemName) < 0 - ) { - idsToClose.push(conn.id); - } - } - - await Promise.all(idsToClose.map((id) => connAPI.closeConnById(apiConfig, id).catch(noop))); -} - -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; -} - -async function switchProxyImpl( - dispatch: DispatchFn, - getState: GetStateFn, - apiConfig: ClashAPIConfig, - groupName: string, - itemName: string, -) { - try { - const res = await proxiesAPI.requestToSwitchProxy(apiConfig, groupName, itemName); - if (res.ok === false) { - throw new Error(`failed to switch proxy: res.statusText`); - } - } catch (err) { - - console.log(err, 'failed to swith proxy'); - throw err; - } - - dispatch(fetchProxies(apiConfig)); - const autoCloseOldConns = getAutoCloseOldConns(getState()); - if (autoCloseOldConns) { - // use fresh state - const proxies = getProxies(getState()); - // no wait - closePrevConns(apiConfig, proxies, { groupName, itemName }); - } - - /* dispatch('showModalClosePrevConns', (s: GlobalState) => { */ - /* s.proxies.showModalClosePrevConns = true; */ - /* s.proxies.switchProxyCtx = { to: { groupName, itemName } }; */ - /* }); */ -} - -function closeModalClosePrevConns() { - return (dispatch: DispatchFn) => { - dispatch('closeModalClosePrevConns', (s: State) => { - s.proxies.showModalClosePrevConns = false; - }); - }; -} - -function closePrevConns( - apiConfig: ClashAPIConfig, - proxies: ProxiesMapping, - switchTo: SwitchProxyCtxItem, -) { - // we must have fetched the proxies before - // so the proxies here is fresh - /* const proxies = s.proxies.proxies; */ - const chain = resolveChain(proxies, switchTo.groupName, switchTo.itemName); - closeGroupConns(apiConfig, switchTo.groupName, chain[0]); -} - -function closePrevConnsAndTheModal(apiConfig: ClashAPIConfig) { - return async (dispatch: DispatchFn, getState: GetStateFn) => { - const s = getState(); - const switchTo = s.proxies.switchProxyCtx?.to; - if (!switchTo) { - dispatch(closeModalClosePrevConns()); - return; - } - - // we must have fetched the proxies before - // so the proxies here is fresh - const proxies = s.proxies.proxies; - closePrevConns(apiConfig, proxies, switchTo); - - dispatch('closePrevConnsAndTheModal', (s: State) => { - s.proxies.showModalClosePrevConns = false; - s.proxies.switchProxyCtx = undefined; - }); - }; -} - -export function switchProxy(apiConfig: ClashAPIConfig, groupName: string, itemName: string) { - return async (dispatch: DispatchFn, getState: GetStateFn) => { - // switch proxy asynchronously - switchProxyImpl(dispatch, getState, apiConfig, groupName, itemName).catch(noop); - - // optimistic UI update - dispatch('store/proxies#switchProxy', (s) => { - const proxies = s.proxies.proxies; - if (proxies[groupName] && proxies[groupName].now) { - proxies[groupName].now = itemName; - } - }); - }; -} - -function requestDelayForProxyOnce(apiConfig: ClashAPIConfig, name: string) { - return async (dispatch: DispatchFn, getState: GetStateFn) => { - let error = ''; - let delayNumber: number | undefined; - try { - const latencyTestUrl = getLatencyTestUrl(getState()); - const latencyTestTimeout = getLatencyTestTimeout(getState()); - const expected = getLatencyTestExpectedStatus(getState()); - const res = await proxiesAPI.requestDelayForProxy(apiConfig, name, latencyTestUrl, latencyTestTimeout, expected); - if (res.ok === false) { - error = res.statusText; - } - const body = await res.json(); - delayNumber = body?.delay; - } catch (err) { - error = (err as Error).message; - } - - const normalizedDelay = - typeof delayNumber === 'number' && delayNumber > 0 ? delayNumber : undefined; - - updateDelayEntry(dispatch, name, { - error, - number: normalizedDelay, - testing: false, - updatedAt: Date.now(), - }); - }; -} - -export function requestDelayForProxy(apiConfig: ClashAPIConfig, name: string) { - return async (dispatch: DispatchFn) => { - await dispatch(requestDelayForProxyOnce(apiConfig, name)); - }; -} - -export function requestDelayForProxies(apiConfig: ClashAPIConfig, names: string[]) { - return async (dispatch: DispatchFn, getState: GetStateFn) => { - const proxyNames = getDangleProxyNames(getState()); - - const works = names - // remove names that are provided by proxy providers - .filter((p) => proxyNames.indexOf(p) > -1) - .map((p) => dispatch(requestDelayForProxy(apiConfig, p))); - await Promise.all(works); - await dispatch(fetchProxies(apiConfig)); - }; -} - -// Test latency for a whole group. On Meta backends this uses the single -// `/group/{name}/delay` endpoint (one request for the whole group), resolving -// the test URL via resolveGroupTestUrl (backend-configured URL when preferred). -// On non-Meta backends it falls back to testing each member proxy individually. -export function requestDelayForGroup( - apiConfig: ClashAPIConfig, - groupName: string, - isMeta: boolean, - memberNames: string[], -) { - return async (dispatch: DispatchFn, getState: GetStateFn) => { - if (isMeta) { - const state = getState(); - const latencyTestUrl = resolveGroupTestUrl(state, groupName); - const latencyTestTimeout = getLatencyTestTimeout(state); - const expected = getLatencyTestExpectedStatus(state); - await proxiesAPI.requestDelayForProxyGroup( - apiConfig, - groupName, - latencyTestUrl, - latencyTestTimeout, - expected, - ); - await dispatch(fetchProxies(apiConfig)); - } else { - // requestDelayForProxies already refreshes proxies when done - await dispatch(requestDelayForProxies(apiConfig, memberNames)); - } - }; -} - -export function requestDelayAll(apiConfig: ClashAPIConfig) { - return async (dispatch: DispatchFn, getState: GetStateFn) => { - const proxyNames = getDangleProxyNames(getState()); - await Promise.all(proxyNames.map((p) => dispatch(requestDelayForProxy(apiConfig, p)))); - const proxyProviders = getProxyProviders(getState()); - const providerHealthcheckTimeout = getProviderHealthcheckTimeout(getState()); - // one by one, each bounded so a slow provider can't stall the whole run - for (const p of proxyProviders) { - await withTimeout(providerHealthcheckTimeout, (signal) => - healthcheckProviderByNameInternal(apiConfig, p.name, signal), - ); - } - await dispatch(fetchProxies(apiConfig)); - }; -} - -export function healthcheckProxy(apiConfig: ClashAPIConfig, name: string) { - return async (dispatch: DispatchFn, getState: GetStateFn) => { - updateDelayEntry(dispatch, name, { testing: true, error: '' }); - - let delayNumber: number | undefined; - let error = ''; - try { - const proxy = getProxies(getState())[name]; - const providerName = proxy?.providerName; - const latencyTestUrl = getLatencyTestUrl(getState()); - const latencyTestTimeout = getLatencyTestTimeout(getState()); - const expected = getLatencyTestExpectedStatus(getState()); - const res = providerName - ? await proxiesAPI.healthcheckProviderProxy(apiConfig, providerName, name, latencyTestUrl, latencyTestTimeout, expected) - : await proxiesAPI.requestDelayForProxy(apiConfig, name, latencyTestUrl, latencyTestTimeout, expected); - if (res.ok === false) { - error = res.statusText; - } - const body = await res.json().catch(() => undefined); - delayNumber = body?.delay; - } catch (err) { - error = (err as Error).message || 'Request failed'; - } - - const normalizedDelay = - typeof delayNumber === 'number' && delayNumber > 0 ? delayNumber : undefined; - - const errorMessage = error || (normalizedDelay === undefined ? 'Timeout' : ''); - updateDelayEntry(dispatch, name, { - number: normalizedDelay, - error: errorMessage, - testing: false, - updatedAt: Date.now(), - }); - }; -} - -function retrieveGroupNamesFrom(proxies: Record<string, ProxyItem>) { - let groupNames = []; - let globalAll: string[]; - const proxyNames = []; - 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) { - // Put GLOBAL in the end - globalAll.push('GLOBAL'); - // Sort groups according to its index in GLOBAL group - groupNames = groupNames - .map((name) => [globalAll.indexOf(name), name]) - .sort((a, b) => a[0] - b[0]) - .map((group) => group[1]); - } - return [groupNames, proxyNames]; -} - -type ProvidersRaw = { - [key: string]: ProxyProvider; -}; - -function formatProxyProviders(providersInput: ProvidersRaw): { - providers: Array<FormattedProxyProvider>; - proxies: { [key: string]: ProxyItem }; -} { - const keys = Object.keys(providersInput); - const providers = []; - const proxies = {}; - for (let i = 0; i < keys.length; i++) { - const provider: ProxyProvider = providersInput[keys[i]]; - if (provider.name === 'default' || provider.vehicleType === 'Compatible') { - continue; - } - const proxiesArr = provider.proxies; - const names = []; - for (let j = 0; j < proxiesArr.length; j++) { - const proxy = proxiesArr[j]; - proxies[proxy.name] = { ...proxy, providerName: provider.name }; - names.push(proxy.name); - } - - const formattedProvider = { ...provider, proxies: names }; - providers.push(formattedProvider); - } - - return { - providers, - proxies, - }; -} - -export const actions = { - requestDelayForProxies, - requestDelayForGroup, - closeModalClosePrevConns, - closePrevConnsAndTheModal, - healthcheckProxy, -}; - -export const proxyFilterText = atom(''); diff --git a/src/store/toast.ts b/src/store/toast.ts new file mode 100644 index 0000000..f962c8f --- /dev/null +++ b/src/store/toast.ts @@ -0,0 +1,28 @@ +import { atom, getDefaultStore } from 'jotai'; + +export type ToastKind = 'success' | 'error' | 'info'; + +export type Toast = { + id: number; + kind: ToastKind; + message: string; +}; + +export const toastsAtom = atom<Toast[]>([]); + +const DEFAULT_DURATION_MS = 6000; + +// 用默认 store,这样非组件代码(store 里的 thunk)也能弹通知 +const store = getDefaultStore(); +let seq = 0; + +export function dismissToast(id: number) { + store.set(toastsAtom, (prev) => prev.filter((t) => t.id !== id)); +} + +export function toast(kind: ToastKind, message: string, duration = DEFAULT_DURATION_MS) { + const id = ++seq; + store.set(toastsAtom, (prev) => [...prev, { id, kind, message }]); + setTimeout(() => dismissToast(id), duration); + return id; +} diff --git a/src/store/types.ts b/src/store/types.ts index 88a2495..bf1c1d4 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -51,7 +51,7 @@ export type ClashGeneralConfig = { }; export type TunPartial<T> = { - [P in keyof T]?: T[P] extends ClashTunConfig ? TunPartial<T[P]> : T[P]; + [P in keyof T]?: NonNullable<T[P]> extends ClashTunConfig ? TunPartial<NonNullable<T[P]>> : T[P]; }; ///// store.proxies @@ -59,11 +59,12 @@ export type TunPartial<T> = { type LatencyHistory = Array<{ time: string; delay: number }>; type PrimitiveProxyType = 'Shadowsocks' | 'Snell' | 'Socks5' | 'Http' | 'Vmess'; +/** 后端要么整个不给(provider 上是可选的),要么四个字段一起给 */ export type SubscriptionInfo = { - Download?: number; - Upload?: number; - Total?: number; - Expire?: number; + Download: number; + Upload: number; + Total: number; + Expire: number; }; export type ProxyItem = { name: string; @@ -104,37 +105,6 @@ export type FormattedProxyProvider = Omit<ProxyProvider, 'proxies'> & { proxies: string[]; }; -export type SwitchProxyCtxItem = { groupName: string; itemName: string }; -type SwitchProxyCtx = { - to: SwitchProxyCtxItem; -}; -export type StateProxies = { - proxies: ProxiesMapping; - delay: DelayMapping; - groupNames: string[]; - proxyProviders?: FormattedProxyProvider[]; - dangleProxyNames?: string[]; - - showModalClosePrevConns: boolean; - switchProxyCtx?: SwitchProxyCtx; -}; - -///// store.logs - -export type Log = { - time: string; - even: boolean; - payload: string; - type: string; - id: string; -}; - -export type StateLogs = { - searchText: string; - logs: Log[]; - tail: number; -}; - ///// store.configs export type StateConfigs = { @@ -153,16 +123,12 @@ export type StateModals = { export type State = { app: StateApp; configs: StateConfigs; - proxies: StateProxies; - logs: StateLogs; modals: StateModals; }; export type GetStateFn = () => State; export interface DispatchFn { (msg: string, change: (s: State) => void): void; - ( - action: (dispatch: DispatchFn, getState: GetStateFn) => Promise<void>, - ): ReturnType<typeof action>; - (action: (dispatch: DispatchFn, getState: GetStateFn) => void): ReturnType<typeof action>; + // thunk:原样返回 action 的返回值,异步 thunk 可以把结果交回调用方 + <T>(action: (dispatch: DispatchFn, getState: GetStateFn) => T): T; } diff --git a/src/styles/main.scss b/src/styles/main.scss index 3932141..7be86a6 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -117,8 +117,6 @@ body { --color-toggle-selected: #1f2937; --color-icon: #9ca3af; --color-separator: #374151; - --color-btn-bg: #374151; - --color-btn-fg: #e5e7eb; --color-bg-proxy: #1f2937; --color-row-odd: #1f2937; --bg-log-info-tag: #4b5563; @@ -130,9 +128,36 @@ body { --bc-tooltip: #374151; --select-border-color: #374151; --select-bg-hover: url(data:image/svg+xml,%0A%20%20%20%20%3Csvg%20width%3D%228%22%20height%3D%2224%22%20viewBox%3D%220%200%208%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%207L7%2011H1L4%207Z%22%20fill%3D%22%23ffffff%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%2017L1%2013L7%2013L4%2017Z%22%20fill%3D%22%23ffffff%22%20%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E%0A%20%20); - --bg-log-info-card: #1f2937; --shadow-card: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); --border-radius: 12px; + + // 卡片 / 徽章 / 弹层 + --color-card: #1f2937; + --color-card-border: #374151; + --color-badge-bg: rgba(255, 255, 255, 0.08); + --color-badge-fg: #9ca3af; + --color-accent-soft-bg: rgba(59, 130, 246, 0.2); + --color-accent-soft-fg: #93c5fd; + --color-node-active-bg: rgba(59, 130, 246, 0.14); + --color-hover-soft: rgba(255, 255, 255, 0.06); + --color-track: #111827; + --shadow-popover: 0 12px 32px -8px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.3); + --shadow-segment: 0 1px 2px rgba(0, 0, 0, 0.4); + + // 侧栏 / 顶栏等「外壳」表面:半透明叠在页面底色上,比卡片暗一档, + // 这样卡片始终是抬起来的那一层 + --color-chrome: rgba(31, 41, 55, 0.72); + --color-chrome-border: rgba(255, 255, 255, 0.08); + + // 语义状态色 + --color-success: #4ade80; + --color-success-soft-bg: rgba(74, 222, 128, 0.16); + --color-danger: #f87171; + --color-danger-soft-bg: rgba(248, 113, 113, 0.16); + --color-warn: #fbbf24; + --color-warn-soft-bg: rgba(251, 191, 36, 0.16); + --color-udp-bg: rgba(192, 132, 252, 0.18); + --color-udp-fg: #d8b4fe; } @mixin light { @@ -159,8 +184,6 @@ body { --color-toggle-selected: #ffffff; --color-icon: #64748b; --color-separator: #e2e8f0; - --color-btn-bg: #f8fafc; - --color-btn-fg: #334155; --color-bg-proxy: #f8fafc; --color-row-odd: #f1f5f9; --bg-log-info-tag: #e2e8f0; @@ -171,9 +194,38 @@ body { --bc-tooltip: #e2e8f0; --select-border-color: #cbd5e1; --select-bg-hover: url(data:image/svg+xml,%0A%20%20%20%20%3Csvg%20width%3D%228%22%20height%3D%2224%22%20viewBox%3D%220%200%208%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%207L7%2011H1L4%207Z%22%20fill%3D%22%23222222%22%20%2F%3E%0A%20%20%20%20%20%20%3Cpath%20d%3D%22M4%2017L1%2013L7%2013L4%2017Z%22%20fill%3D%22%23222222%22%20%2F%3E%0A%20%20%20%20%3C%2Fsvg%3E%0A%20%20); - --bg-log-info-card: #f1f5f9; --shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); --border-radius: 12px; + + // 卡片 / 徽章 / 弹层 + // 与概览页 --color-bg-card 同档的柔和灰白,避免代理组 / 连接卡片在 + // 浅灰页面底色上显得刺眼的纯白;边框同步压深一档保持轮廓清晰 + --color-card: #f8fafc; + --color-card-border: #e2e8f0; + --color-badge-bg: #f1f5f9; + --color-badge-fg: #64748b; + --color-accent-soft-bg: #e0edff; + --color-accent-soft-fg: #2563eb; + --color-node-active-bg: #eff6ff; + --color-hover-soft: rgba(15, 23, 42, 0.05); + --color-track: #eef2f6; + --shadow-popover: 0 12px 32px -8px rgba(15, 23, 42, 0.22), 0 2px 8px rgba(15, 23, 42, 0.08); + --shadow-segment: 0 1px 2px rgba(15, 23, 42, 0.12); + + // 侧栏 / 顶栏等「外壳」表面:半透明白叠在页面底色上,算出来约 #f9fafc, + // 比纯白的卡片灰一档,卡片才浮得起来 + --color-chrome: rgba(255, 255, 255, 0.62); + --color-chrome-border: rgba(0, 0, 0, 0.08); + + // 语义状态色 + --color-success: #16a34a; + --color-success-soft-bg: rgba(22, 163, 74, 0.14); + --color-danger: #ef4444; + --color-danger-soft-bg: rgba(239, 68, 68, 0.12); + --color-warn: #b45309; + --color-warn-soft-bg: rgba(245, 158, 11, 0.16); + --color-udp-bg: rgba(168, 85, 247, 0.12); + --color-udp-fg: #7e22ce; } :root[data-theme='auto'] { @@ -1,5 +1,4 @@ /// <reference lib="webworker" /> - // This service worker can be customized! // See https://developers.google.com/web/tools/workbox/modules @@ -46,7 +45,7 @@ registerRoute( // Return true to signal that we want to use the handler. return true; }, - createHandlerBoundToURL('index.html') + createHandlerBoundToURL('index.html'), ); // An example runtime caching route for requests that aren't handled by the @@ -62,7 +61,7 @@ registerRoute( // least-recently used images are removed. new ExpirationPlugin({ maxEntries: 50 }), ], - }) + }), ); // This allows the web app to trigger skipWaiting via diff --git a/src/swRegistration.ts b/src/swRegistration.ts index 0da5d01..00cbcea 100644 --- a/src/swRegistration.ts +++ b/src/swRegistration.ts @@ -1,9 +1,9 @@ const isLocalhost = Boolean( window.location.hostname === 'localhost' || - // [::1] is the IPv6 localhost address. - window.location.hostname === '[::1]' || - // 127.0.0.0/8 are considered localhost for IPv4. - window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.0/8 are considered localhost for IPv4. + window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/), ); type Config = { @@ -59,7 +59,7 @@ function registerValidSW(swUrl: string, config?: Config) { // content until all client tabs are closed. console.log( 'New content is available and will be used when all ' + - 'tabs for this page are closed. See https://cra.link/PWA.' + 'tabs for this page are closed. See https://cra.link/PWA.', ); // Execute callback @@ -114,6 +114,25 @@ function checkValidServiceWorker(swUrl: string, config?: Config) { }); } +// 后端刚把面板静态文件换掉。直接 reload 会被 Service Worker 的 precache 挡住 —— +// 新的 sw.js 只会进入 waiting,要等所有标签页关掉才激活,这一次刷新拿到的还是旧资源。 +// 所以先把 SW 和它的缓存清干净再整页刷新,下次加载 main.tsx 会重新 register。 +export async function unregisterAndReload() { + try { + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((registration) => registration.unregister())); + } + if ('caches' in window) { + const keys = await caches.keys(); + await Promise.all(keys.map((key) => caches.delete(key))); + } + } catch (err) { + console.log('Failed to clear service worker caches before reload', err); + } + window.location.reload(); +} + export function unregister() { if ('serviceWorker' in navigator) { navigator.serviceWorker.ready diff --git a/src/types.ts b/src/types.ts index 8446dfc..105abc5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,3 +4,12 @@ export type ClashAPIConfig = { }; export type LogsAPIConfig = ClashAPIConfig & { logLevel: string }; + +/** 一条日志。`/logs` 是 WebSocket 流,不进自研 store,见 store/logs.ts */ +export type Log = { + time: string; + even: boolean; + payload: string; + type: string; + id: string; +}; |
