summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorLarvan2 <[email protected]>2026-06-04 18:43:18 +0800
committerLarvan2 <[email protected]>2026-06-04 18:43:18 +0800
commit120f06c59ef7e1514baa3cdf81dec79c7fa6e1e6 (patch)
tree49d75bb660b922d64a3f6c5e516c740533ed8c75 /src
parent4dcd2231ee74f2ea7dd8a9a556208a0e2efc7377 (diff)
feat: proxy group by providers
Diffstat (limited to 'src')
-rw-r--r--src/components/proxies/Proxies.tsx2
-rw-r--r--src/components/proxies/ProxyGroup.tsx48
-rw-r--r--src/components/proxies/ProxyList.module.scss13
-rw-r--r--src/components/proxies/ProxyList.tsx60
-rw-r--r--src/components/proxies/Settings.tsx15
-rw-r--r--src/i18n/en.ts1
-rw-r--r--src/i18n/ru.ts1
-rw-r--r--src/i18n/vi.ts1
-rw-r--r--src/i18n/zh-cn.ts1
-rw-r--r--src/i18n/zh-tw.ts1
-rw-r--r--src/pages/ProxiesPage.tsx13
-rw-r--r--src/store/app.ts2
-rw-r--r--src/store/proxies.tsx17
-rw-r--r--src/store/types.ts7
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>;
}