summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/configs.ts18
-rw-r--r--src/api/connections.ts12
-rw-r--r--src/api/logs.ts23
-rw-r--r--src/api/memory.ts10
-rw-r--r--src/api/proxies.ts40
-rw-r--r--src/api/rule-provider.ts6
-rw-r--r--src/api/rules.ts47
-rw-r--r--src/api/traffic.ts10
-rw-r--r--src/api/version.ts4
-rw-r--r--src/app/APIDiscoveryContainer.tsx (renamed from src/app/APIDiscovery.tsx)2
-rw-r--r--src/app/ErrorBoundary.tsx (renamed from src/components/ErrorBoundary.tsx)2
-rw-r--r--src/app/ErrorBoundaryFallback.module.scss (renamed from src/components/ErrorBoundaryFallback.module.scss)0
-rw-r--r--src/app/ErrorBoundaryFallback.tsx (renamed from src/components/ErrorBoundaryFallback.tsx)6
-rw-r--r--src/app/SideBar.module.scss (renamed from src/components/SideBar.module.scss)18
-rw-r--r--src/app/SideBar.tsx (renamed from src/components/SideBar.tsx)19
-rw-r--r--src/app/bootstrap.ts4
-rw-r--r--src/app/providers.tsx10
-rw-r--r--src/app/router.tsx8
-rw-r--r--src/components/CollapsibleSectionHeader.module.scss49
-rw-r--r--src/components/CollapsibleSectionHeader.tsx51
-rw-r--r--src/components/ContentHeader.module.scss34
-rw-r--r--src/components/ContentHeader.tsx13
-rw-r--r--src/components/Field.module.scss42
-rw-r--r--src/components/Field.tsx27
-rw-r--r--src/components/Icon.tsx20
-rw-r--r--src/components/Input.module.scss28
-rw-r--r--src/components/Loading2.module.scss8
-rw-r--r--src/components/Loading2.tsx14
-rw-r--r--src/components/Modal.module.scss21
-rw-r--r--src/components/Modal.tsx56
-rw-r--r--src/components/Search.module.scss32
-rw-r--r--src/components/Search.tsx38
-rw-r--r--src/components/SwitchThemed.tsx36
-rw-r--r--src/components/about/About.tsx2
-rw-r--r--src/components/backend/APIConfig.module.scss266
-rw-r--r--src/components/backend/APIConfig.tsx210
-rw-r--r--src/components/backend/APIDiscovery.module.scss5
-rw-r--r--src/components/backend/APIDiscovery.tsx2
-rw-r--r--src/components/backend/BackendList.module.scss186
-rw-r--r--src/components/backend/BackendList.tsx152
-rw-r--r--src/components/config/Config.module.scss34
-rw-r--r--src/components/config/Config.tsx46
-rw-r--r--src/components/connections/ConnectionCard.module.scss155
-rw-r--r--src/components/connections/ConnectionCard.tsx34
-rw-r--r--src/components/connections/ConnectionDetailModal.module.scss142
-rw-r--r--src/components/connections/ConnectionDetailModal.tsx98
-rw-r--r--src/components/connections/ConnectionSettingsModal.module.scss365
-rw-r--r--src/components/connections/ConnectionSettingsModal.tsx252
-rw-r--r--src/components/connections/ConnectionStats.module.scss100
-rw-r--r--src/components/connections/ConnectionStats.tsx81
-rw-r--r--src/components/connections/ConnectionTable.module.scss485
-rw-r--r--src/components/connections/ConnectionTable.scss84
-rw-r--r--src/components/connections/ConnectionTable.tsx778
-rw-r--r--src/components/connections/Connections.css71
-rw-r--r--src/components/connections/Connections.module.scss227
-rw-r--r--src/components/connections/Connections.tsx311
-rw-r--r--src/components/connections/ConnectionsHeader.module.scss64
-rw-r--r--src/components/connections/ConnectionsHeader.tsx136
-rw-r--r--src/components/connections/ModalCloseAllConnections.module.scss7
-rw-r--r--src/components/connections/ModalCloseAllConnections.tsx21
-rw-r--r--src/components/connections/ModalConnectionDetails.module.scss49
-rw-r--r--src/components/connections/ModalConnectionDetails.tsx73
-rw-r--r--src/components/connections/ModalManageConnectionColumns.module.scss61
-rw-r--r--src/components/connections/ModalManageConnectionColumns.tsx156
-rw-r--r--src/components/connections/ModalSourceIP.module.scss9
-rw-r--r--src/components/connections/ModalSourceIP.tsx71
-rw-r--r--src/components/home/Home.module.scss197
-rw-r--r--src/components/home/Home.tsx22
-rw-r--r--src/components/home/MemoryCard.tsx50
-rw-r--r--src/components/home/MemoryChart.tsx84
-rw-r--r--src/components/home/StatCards.tsx72
-rw-r--r--src/components/home/TrafficCard.tsx74
-rw-r--r--src/components/home/TrafficChart.tsx118
-rw-r--r--src/components/home/TrafficNow.module.scss85
-rw-r--r--src/components/home/TrafficNow.tsx104
-rw-r--r--src/components/logs/LogSearch.ts6
-rw-r--r--src/components/logs/Logs.module.scss257
-rw-r--r--src/components/logs/Logs.tsx144
-rw-r--r--src/components/logs/LogsHeader.module.scss33
-rw-r--r--src/components/logs/LogsHeader.tsx67
-rw-r--r--src/components/proxies/ClosePrevConns.tsx48
-rw-r--r--src/components/proxies/Proxies.module.scss151
-rw-r--r--src/components/proxies/Proxies.tsx204
-rw-r--r--src/components/proxies/ProxiesHeader.module.scss22
-rw-r--r--src/components/proxies/ProxiesHeader.tsx136
-rw-r--r--src/components/proxies/Proxy.module.scss149
-rw-r--r--src/components/proxies/Proxy.tsx156
-rw-r--r--src/components/proxies/ProxyCard.module.scss232
-rw-r--r--src/components/proxies/ProxyCard.tsx214
-rw-r--r--src/components/proxies/ProxyGroup.module.scss101
-rw-r--r--src/components/proxies/ProxyGroup.tsx289
-rw-r--r--src/components/proxies/ProxyLatency.module.scss38
-rw-r--r--src/components/proxies/ProxyLatency.tsx6
-rw-r--r--src/components/proxies/ProxyList.module.scss40
-rw-r--r--src/components/proxies/ProxyList.tsx31
-rw-r--r--src/components/proxies/ProxyPageFab.tsx56
-rw-r--r--src/components/proxies/ProxyProvider.module.scss51
-rw-r--r--src/components/proxies/ProxyProvider.tsx250
-rw-r--r--src/components/proxies/ProxyProviderList.tsx52
-rw-r--r--src/components/proxies/Settings.module.scss115
-rw-r--r--src/components/proxies/Settings.tsx393
-rw-r--r--src/components/proxies/index.tsx1
-rw-r--r--src/components/rules/Rule.module.scss116
-rw-r--r--src/components/rules/Rule.tsx149
-rw-r--r--src/components/rules/RuleProviderItem.module.scss108
-rw-r--r--src/components/rules/RuleProviderItem.tsx69
-rw-r--r--src/components/rules/Rules.module.scss127
-rw-r--r--src/components/rules/Rules.tsx135
-rw-r--r--src/components/rules/RulesHeader.tsx80
-rw-r--r--src/components/rules/RulesPageFab.tsx24
-rw-r--r--src/components/shared/BaseModal.module.scss12
-rw-r--r--src/components/shared/BaseModal.tsx22
-rw-r--r--src/components/shared/Basic.module.scss15
-rw-r--r--src/components/shared/Basic.tsx9
-rw-r--r--src/components/shared/Button.module.scss (renamed from src/components/Button.module.scss)36
-rw-r--r--src/components/shared/Button.tsx (renamed from src/components/Button.tsx)6
-rw-r--r--src/components/shared/Collapsible.tsx (renamed from src/components/Collapsible.tsx)2
-rw-r--r--src/components/shared/Fab.module.scss33
-rw-r--r--src/components/shared/Fab.tsx155
-rw-r--r--src/components/shared/FeatherIcons.ts4
-rw-r--r--src/components/shared/Head.tsx5
-rw-r--r--src/components/shared/Input.module.scss36
-rw-r--r--src/components/shared/Input.tsx (renamed from src/components/Input.tsx)11
-rw-r--r--src/components/shared/Loading.module.scss (renamed from src/components/Loading.module.scss)0
-rw-r--r--src/components/shared/Loading.tsx (renamed from src/components/Loading.tsx)0
-rw-r--r--src/components/shared/Modal.module.scss52
-rw-r--r--src/components/shared/Modal.tsx81
-rw-r--r--src/components/shared/PageHeader.module.scss400
-rw-r--r--src/components/shared/PageHeader.tsx206
-rw-r--r--src/components/shared/Popover.module.scss43
-rw-r--r--src/components/shared/Popover.tsx127
-rw-r--r--src/components/shared/SegmentedControl.module.scss50
-rw-r--r--src/components/shared/SegmentedControl.tsx48
-rw-r--r--src/components/shared/Select.module.scss12
-rw-r--r--src/components/shared/Select.tsx10
-rw-r--r--src/components/shared/Selection.module.scss (renamed from src/components/Selection.module.scss)0
-rw-r--r--src/components/shared/Selection.tsx (renamed from src/components/Selection.tsx)8
-rw-r--r--src/components/shared/Sparkline.module.scss5
-rw-r--r--src/components/shared/Sparkline.tsx120
-rw-r--r--src/components/shared/SvgGithub.tsx (renamed from src/components/SvgGithub.tsx)0
-rw-r--r--src/components/shared/SvgYacd.module.scss (renamed from src/components/SvgYacd.module.scss)0
-rw-r--r--src/components/shared/SvgYacd.tsx (renamed from src/components/SvgYacd.tsx)0
-rw-r--r--src/components/shared/SwitchThemed.module.scss56
-rw-r--r--src/components/shared/SwitchThemed.tsx33
-rw-r--r--src/components/shared/TextFilter.module.scss (renamed from src/components/shared/TextFitler.module.scss)0
-rw-r--r--src/components/shared/TextFilter.tsx (renamed from src/components/shared/TextFitler.tsx)10
-rw-r--r--src/components/shared/ThemeSwitcher.tsx8
-rw-r--r--src/components/shared/Toast.module.scss87
-rw-r--r--src/components/shared/Toast.tsx40
-rw-r--r--src/components/shared/ToggleSwitch.module.scss (renamed from src/components/ToggleSwitch.module.scss)0
-rw-r--r--src/components/shared/ToggleSwitch.tsx (renamed from src/components/ToggleSwitch.tsx)12
-rw-r--r--src/components/shared/Tooltip.tsx6
-rw-r--r--src/components/shared/TrafficChartSample.tsx4
-rw-r--r--src/components/styleguide/StyleGuide.tsx (renamed from src/components/StyleGuide.tsx)12
-rw-r--r--src/components/svg/Equalizer.tsx27
-rw-r--r--src/hooks/useMemory.ts25
-rw-r--r--src/hooks/useRemainingViewPortHeight.ts32
-rw-r--r--src/hooks/useTextInput.ts6
-rw-r--r--src/hooks/useTraffic.ts25
-rw-r--r--src/hooks/useVersion.ts13
-rw-r--r--src/i18n/en.ts108
-rw-r--r--src/i18n/ru.ts31
-rw-r--r--src/i18n/vi.ts30
-rw-r--r--src/i18n/zh-cn.ts110
-rw-r--r--src/i18n/zh-tw.ts103
-rw-r--r--src/main.tsx5
-rw-r--r--src/misc/chart-lib.ts2
-rw-r--r--src/misc/chart-memory.ts139
-rw-r--r--src/misc/chart.ts4
-rw-r--r--src/misc/createResource.ts45
-rw-r--r--src/misc/errors.ts4
-rw-r--r--src/misc/i18n.ts2
-rw-r--r--src/misc/motion.ts3
-rw-r--r--src/misc/request-helper.ts25
-rw-r--r--src/misc/shallowEqual.ts31
-rw-r--r--src/misc/storage.ts2
-rw-r--r--src/modules/backend/hooks.ts100
-rw-r--r--src/modules/backend/utils.ts98
-rw-r--r--src/modules/config/hooks.ts59
-rw-r--r--src/modules/config/utils.ts4
-rw-r--r--src/modules/connections/hooks.ts208
-rw-r--r--src/modules/connections/utils.ts263
-rw-r--r--src/modules/home/hooks.ts175
-rw-r--r--src/modules/home/utils.ts80
-rw-r--r--src/modules/logs/hooks.ts28
-rw-r--r--src/modules/logs/utils.ts4
-rw-r--r--src/modules/proxies/hooks.ts606
-rw-r--r--src/modules/proxies/utils.ts235
-rw-r--r--src/modules/rules/hooks.ts100
-rw-r--r--src/modules/rules/utils.ts36
-rw-r--r--src/pages/AboutPage.tsx2
-rw-r--r--src/pages/BackendPage.tsx17
-rw-r--r--src/pages/ConfigPage.tsx2
-rw-r--r--src/pages/ConnectionsPage.tsx2
-rw-r--r--src/pages/HomePage.tsx2
-rw-r--r--src/pages/LogsPage.tsx4
-rw-r--r--src/pages/ProxiesPage.tsx22
-rw-r--r--src/pages/RulesPage.tsx2
-rw-r--r--src/pages/StyleGuidePage.tsx2
-rw-r--r--src/store/StateProvider.tsx (renamed from src/components/StateProvider.tsx)37
-rw-r--r--src/store/app.ts169
-rw-r--r--src/store/configs.ts94
-rw-r--r--src/store/connections.ts21
-rw-r--r--src/store/index.ts7
-rw-r--r--src/store/logs.ts90
-rw-r--r--src/store/modals.ts6
-rw-r--r--src/store/proxies.ts43
-rw-r--r--src/store/proxies.tsx613
-rw-r--r--src/store/toast.ts28
-rw-r--r--src/store/types.ts50
-rw-r--r--src/styles/main.scss64
-rw-r--r--src/sw.ts5
-rw-r--r--src/swRegistration.ts29
-rw-r--r--src/types.ts9
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}% ) &nbsp;&nbsp; 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'] {
diff --git a/src/sw.ts b/src/sw.ts
index fffccca..74a4d85 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -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;
+};