diff options
| author | Larvan2 <[email protected]> | 2026-06-04 18:43:18 +0800 |
|---|---|---|
| committer | Larvan2 <[email protected]> | 2026-06-04 18:43:18 +0800 |
| commit | 120f06c59ef7e1514baa3cdf81dec79c7fa6e1e6 (patch) | |
| tree | 49d75bb660b922d64a3f6c5e516c740533ed8c75 /src | |
| parent | 4dcd2231ee74f2ea7dd8a9a556208a0e2efc7377 (diff) | |
feat: proxy group by providers
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/proxies/Proxies.tsx | 2 | ||||
| -rw-r--r-- | src/components/proxies/ProxyGroup.tsx | 48 | ||||
| -rw-r--r-- | src/components/proxies/ProxyList.module.scss | 13 | ||||
| -rw-r--r-- | src/components/proxies/ProxyList.tsx | 60 | ||||
| -rw-r--r-- | src/components/proxies/Settings.tsx | 15 | ||||
| -rw-r--r-- | src/i18n/en.ts | 1 | ||||
| -rw-r--r-- | src/i18n/ru.ts | 1 | ||||
| -rw-r--r-- | src/i18n/vi.ts | 1 | ||||
| -rw-r--r-- | src/i18n/zh-cn.ts | 1 | ||||
| -rw-r--r-- | src/i18n/zh-tw.ts | 1 | ||||
| -rw-r--r-- | src/pages/ProxiesPage.tsx | 13 | ||||
| -rw-r--r-- | src/store/app.ts | 2 | ||||
| -rw-r--r-- | src/store/proxies.tsx | 17 | ||||
| -rw-r--r-- | src/store/types.ts | 7 |
14 files changed, 154 insertions, 28 deletions
diff --git a/src/components/proxies/Proxies.tsx b/src/components/proxies/Proxies.tsx index aae829f..e8e3d4b 100644 --- a/src/components/proxies/Proxies.tsx +++ b/src/components/proxies/Proxies.tsx @@ -27,6 +27,7 @@ type AppConfig = { hideUnavailableProxies: boolean; autoCloseOldConns: boolean; proxiesLayout: string; + proxyGroupByProvider: boolean; }; type Props = { @@ -98,6 +99,7 @@ export default function Proxies({ proxySortBy={appConfig.proxySortBy} isOpen={Boolean(collapsibleIsOpen[`proxyGroup:${name}`])} latencyTestUrl={latencyTestUrl} + proxyGroupByProvider={appConfig.proxyGroupByProvider} /> </div> ))} diff --git a/src/components/proxies/ProxyGroup.tsx b/src/components/proxies/ProxyGroup.tsx index 7b751fb..b493ff1 100644 --- a/src/components/proxies/ProxyGroup.tsx +++ b/src/components/proxies/ProxyGroup.tsx @@ -17,7 +17,7 @@ import CollapsibleSectionHeader from '../CollapsibleSectionHeader'; import { useStoreActions } from '../StateProvider'; import s0 from './ProxyGroup.module.scss'; -import { ProxyList, ProxyListSummaryView } from './ProxyList'; +import { ProxyList, ProxyListGroupedByProvider, ProxyListSummaryView } from './ProxyList'; const { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } = React; @@ -94,6 +94,7 @@ type Props = { latencyTestUrl: string; apiConfig: ClashAPIConfig; dispatch: DispatchFn; + proxyGroupByProvider?: boolean; }; export const ProxyGroup = memo(function ProxyGroup({ @@ -106,6 +107,7 @@ export const ProxyGroup = memo(function ProxyGroup({ latencyTestUrl, apiConfig, dispatch, + proxyGroupByProvider = false, }: Props) { const group = proxies[name] as ProxyItem & { all?: string[]; now?: string }; const { all: allItems = [], type, now } = group || {}; @@ -115,18 +117,18 @@ export const ProxyGroup = memo(function ProxyGroup({ const nowChain = useMemo(() => buildNowChain(proxies, name), [proxies, name]); const nowLatency = useMemo( () => (now ? getProxyLatency(proxies, delay, now) : undefined), - [proxies, delay, now] + [proxies, delay, now], ); const availableCount = useMemo(() => countAvailableProxies(allItems, delay), [allItems, delay]); const qtyLabel = `${availableCount}/${allItems.length}`; const { data: version } = useQuery(['/version', apiConfig], () => - fetchVersion('/version', apiConfig) + fetchVersion('/version', apiConfig), ); const isSelectable = useMemo( () => ['Selector', version.meta && 'Fallback', version.meta && 'URLTest'].includes(type), - [type, version.meta] + [type, version.meta], ); const { @@ -143,7 +145,7 @@ export const ProxyGroup = memo(function ProxyGroup({ if (!isSelectable) return; dispatch(switchProxy(apiConfig, name, proxyName)); }, - [apiConfig, dispatch, name, isSelectable] + [apiConfig, dispatch, name, isSelectable], ); const [isTestingLatency, setIsTestingLatency] = useState(false); @@ -206,17 +208,31 @@ export const ProxyGroup = memo(function ProxyGroup({ </div> </div> <Collapsible isOpen={isOpen}> - <ProxyList - apiConfig={apiConfig} - all={all} - delay={delay} - dispatch={dispatch} - latencyTestUrl={latencyTestUrl} - now={now} - isSelectable={isSelectable} - itemOnTapCallback={itemOnTapCallback} - proxies={proxies} - /> + {proxyGroupByProvider ? ( + <ProxyListGroupedByProvider + apiConfig={apiConfig} + all={all} + delay={delay} + dispatch={dispatch} + latencyTestUrl={latencyTestUrl} + now={now} + isSelectable={isSelectable} + itemOnTapCallback={itemOnTapCallback} + proxies={proxies} + /> + ) : ( + <ProxyList + apiConfig={apiConfig} + all={all} + delay={delay} + dispatch={dispatch} + latencyTestUrl={latencyTestUrl} + now={now} + isSelectable={isSelectable} + itemOnTapCallback={itemOnTapCallback} + proxies={proxies} + /> + )} </Collapsible> <Collapsible isOpen={!isOpen}> {nowChain && ( diff --git a/src/components/proxies/ProxyList.module.scss b/src/components/proxies/ProxyList.module.scss index f4c8d87..f4b1211 100644 --- a/src/components/proxies/ProxyList.module.scss +++ b/src/components/proxies/ProxyList.module.scss @@ -18,3 +18,16 @@ grid-template-columns: repeat(auto-fill, 15px); padding-left: 10px; } + +.providerGroup { + margin-top: 8px; +} + +.providerLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-secondary, #909399); + padding: 2px 0 4px; + border-bottom: 1px solid var(--color-separator); + margin-bottom: 4px; +} diff --git a/src/components/proxies/ProxyList.tsx b/src/components/proxies/ProxyList.tsx index 1782a8f..aee7ac6 100644 --- a/src/components/proxies/ProxyList.tsx +++ b/src/components/proxies/ProxyList.tsx @@ -105,3 +105,63 @@ export function ProxyListSummaryView({ </div> ); } + +export function ProxyListGroupedByProvider({ + all, + proxies, + delay, + latencyTestUrl, + apiConfig, + dispatch, + now, + isSelectable, + itemOnTapCallback, +}: ProxyListProps) { + const httpsLatencyTest = latencyTestUrl.startsWith('https://'); + + // Group proxy names by their providerName + const groups: { label: string; names: string[] }[] = React.useMemo(() => { + const map = new Map<string, string[]>(); + for (const proxyName of all) { + const providerName = proxies[proxyName]?.providerName ?? ''; + if (!map.has(providerName)) map.set(providerName, []); + map.get(providerName)!.push(proxyName); + } + return Array.from(map.entries()).map(([label, names]) => ({ label, names })); + }, [all, proxies]); + + return ( + <div> + {groups.map(({ label, names }) => ( + <div key={label} className={s.providerGroup}> + {label ? <div className={s.providerLabel}>{label}</div> : null} + <div className={cx(s.list, s.detail)}> + {names.map((proxyName) => { + const proxy = proxies[proxyName] || { + name: proxyName, + type: 'Http' as const, + udp: false, + tfo: false, + history: [], + }; + return ( + <Proxy + apiConfig={apiConfig} + dispatch={dispatch} + proxy={proxy} + latency={getProxyLatency(proxies, delay, proxyName)} + httpsLatencyTest={httpsLatencyTest} + key={proxyName} + onClick={itemOnTapCallback} + isSelectable={isSelectable} + name={proxyName} + now={proxyName === now} + /> + ); + })} + </div> + </div> + ))} + </div> + ); +} diff --git a/src/components/proxies/Settings.tsx b/src/components/proxies/Settings.tsx index 5cf90f8..d61925a 100644 --- a/src/components/proxies/Settings.tsx +++ b/src/components/proxies/Settings.tsx @@ -16,6 +16,7 @@ type AppConfig = { hideUnavailableProxies: boolean; autoCloseOldConns: boolean; proxiesLayout: string; + proxyGroupByProvider: boolean; }; type Props = { @@ -31,14 +32,14 @@ export default function Settings({ appConfig }: Props) { (e) => { updateAppConfig('proxySortBy', e.target.value); }, - [updateAppConfig] + [updateAppConfig], ); const handleHideUnavailablesSwitchOnChange = useCallback( (v) => { updateAppConfig('hideUnavailableProxies', v); }, - [updateAppConfig] + [updateAppConfig], ); const { t } = useTranslation(); return ( @@ -86,6 +87,16 @@ export default function Settings({ appConfig }: Props) { /> </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> </> ); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 645b762..c09068b 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -29,6 +29,7 @@ export const data = { hide_unavail_proxies: 'Hide unavailable proxies', auto_close_conns: 'Automatically close old connections', double_column_layout: 'Double column layout', + group_by_provider: 'Group proxies by provider', order_natural: 'Original order in config file', order_latency_asc: 'By latency from small to big', order_latency_desc: 'By latency from big to small', diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index e2a6bf9..f5bcfbf 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -29,6 +29,7 @@ export const data = { hide_unavail_proxies: 'Скрыть недоступные прокси', auto_close_conns: 'Автоматически закрывать старые подключения', double_column_layout: 'Двухколоночный макет', + group_by_provider: 'Группировать прокси по провайдеру', order_natural: 'Исходный порядок из конфигурации', order_latency_asc: 'По задержке (от меньшей к большей)', order_latency_desc: 'По задержке (от большей к меньшей)', diff --git a/src/i18n/vi.ts b/src/i18n/vi.ts index fc5fe56..0435c9a 100644 --- a/src/i18n/vi.ts +++ b/src/i18n/vi.ts @@ -24,6 +24,7 @@ export const data = { 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ũ', + group_by_provider: 'Nhóm proxy theo nhà cung cấp', order_natural: 'Thứ tự ban đầu trong tệp cấu hình', order_latency_asc: 'Theo độ trễ từ nhỏ đến lớn', order_latency_desc: 'Theo độ trễ từ lớn đến nhỏ', diff --git a/src/i18n/zh-cn.ts b/src/i18n/zh-cn.ts index 908b0bd..25684a9 100644 --- a/src/i18n/zh-cn.ts +++ b/src/i18n/zh-cn.ts @@ -30,6 +30,7 @@ export const data = { hide_unavail_proxies: '隐藏不可用代理', auto_close_conns: '切换代理时自动断开旧连接', double_column_layout: '双列显示', + group_by_provider: '按提供商分组节点', order_natural: '原 config 文件中的排序', order_latency_asc: '按延迟从小到大', order_latency_desc: '按延迟从大到小', diff --git a/src/i18n/zh-tw.ts b/src/i18n/zh-tw.ts index 1d23baa..8b78b74 100644 --- a/src/i18n/zh-tw.ts +++ b/src/i18n/zh-tw.ts @@ -26,6 +26,7 @@ export const data = { hide_unavail_proxies: '隱藏不可用的代理伺服器', auto_close_conns: '切換代理伺服器時自動斷開舊連線', double_column_layout: '雙列顯示', + group_by_provider: '依提供商分組節點', order_natural: '原 config 文件中的順序', order_latency_asc: '按延遲從小到大', order_latency_desc: '按延遲從大到小', diff --git a/src/pages/ProxiesPage.tsx b/src/pages/ProxiesPage.tsx index 43881fb..7fcd125 100644 --- a/src/pages/ProxiesPage.tsx +++ b/src/pages/ProxiesPage.tsx @@ -10,6 +10,7 @@ import { getLatencyTestUrl, getProxiesLayout, getProxySortBy, + getProxyGroupByProvider, } from '~/store/app'; import { getDelay, @@ -25,12 +26,20 @@ const getAppConfig = createSelector( getHideUnavailableProxies, getAutoCloseOldConns, getProxiesLayout, - (proxySortBy, hideUnavailableProxies, autoCloseOldConns, proxiesLayout) => ({ + getProxyGroupByProvider, + ( proxySortBy, hideUnavailableProxies, autoCloseOldConns, proxiesLayout, - }) + proxyGroupByProvider, + ) => ({ + proxySortBy, + hideUnavailableProxies, + autoCloseOldConns, + proxiesLayout, + proxyGroupByProvider, + }), ); const mapState = (state: State) => ({ diff --git a/src/store/app.ts b/src/store/app.ts index d25c42c..0d4f811 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -22,6 +22,7 @@ export const getHideUnavailableProxies = (s: State) => s.app.hideUnavailableProx export const getAutoCloseOldConns = (s: State) => s.app.autoCloseOldConns; export const getLogStreamingPaused = (s: State) => s.app.logStreamingPaused; export const getProxiesLayout = (s: State) => s.app.proxiesLayout; +export const getProxyGroupByProvider = (s: State) => s.app.proxyGroupByProvider; const saveStateDebounced = debounce(saveState, 600); @@ -176,6 +177,7 @@ const defaultState: StateApp = { autoCloseOldConns: true, logStreamingPaused: false, proxiesLayout: 'single', + proxyGroupByProvider: false, }; function parseConfigQueryString() { diff --git a/src/store/proxies.tsx b/src/store/proxies.tsx index 8164b90..42e4b16 100644 --- a/src/store/proxies.tsx +++ b/src/store/proxies.tsx @@ -61,9 +61,16 @@ export function fetchProxies(apiConfig: ClashAPIConfig) { ]); const { providers: proxyProviders, proxies: providerProxies } = formatProxyProviders( - providersData.providers + 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); const delayPrev = getDelay(getState()); @@ -143,7 +150,7 @@ function updateDelayEntry( dispatch: DispatchFn, getState: GetStateFn, name: string, - patch: { number?: number; error?: string; testing?: boolean; updatedAt?: number } + patch: { number?: number; error?: string; testing?: boolean; updatedAt?: number }, ) { const delayPrev = getDelay(getState()); const prev = delayPrev[name] || {}; @@ -161,7 +168,7 @@ function updateDelayEntry( async function closeGroupConns( apiConfig: ClashAPIConfig, groupName: string, - exceptionItemName: string + exceptionItemName: string, ) { const res = await connAPI.fetchConns(apiConfig); if (!res.ok) { @@ -202,7 +209,7 @@ async function switchProxyImpl( getState: GetStateFn, apiConfig: ClashAPIConfig, groupName: string, - itemName: string + itemName: string, ) { try { const res = await proxiesAPI.requestToSwitchProxy(apiConfig, groupName, itemName); @@ -241,7 +248,7 @@ function closeModalClosePrevConns() { function closePrevConns( apiConfig: ClashAPIConfig, proxies: ProxiesMapping, - switchTo: SwitchProxyCtxItem + switchTo: SwitchProxyCtxItem, ) { // we must have fetched the proxies before // so the proxies here is fresh diff --git a/src/store/types.ts b/src/store/types.ts index 2174ae3..5eeaeaf 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -15,6 +15,7 @@ export type StateApp = { autoCloseOldConns: boolean; logStreamingPaused: boolean; proxiesLayout: string; + proxyGroupByProvider: boolean; }; export type ClashTunConfig = { @@ -144,8 +145,8 @@ export type State = { 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) => Promise<void>, + ): ReturnType<typeof action>; (action: (dispatch: DispatchFn, getState: GetStateFn) => void): ReturnType<typeof action>; } |
