fix(ui): match node connection-outbound picker to panel-outbound selector

Group the tags into Outbounds/Balancers, hide blackhole outbounds, and show
the 'Direct connection' placeholder on empty via getValueProps so the field
never looks unset and an empty default can't read as a second 'direct'.
This commit is contained in:
MHSanaei
2026-06-14 23:25:37 +02:00
parent 05ad7f417c
commit 335470607f
2 changed files with 55 additions and 3 deletions
@@ -34,3 +34,38 @@ export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
},
});
}
export interface OutboundTagGroups {
outbounds: string[];
balancers: string[];
}
// Same data as useOutboundTags, but keeps outbound and balancer tags apart so a
// picker can render them in labeled groups (like the panel-outbound selector)
// instead of one flat list.
export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
const excludeBlackhole = opts?.excludeBlackhole ?? false;
return useQuery({
queryKey: keys.xray.config(),
queryFn: fetchXrayConfig,
staleTime: Infinity,
select: (data): OutboundTagGroups => {
const outbounds = new Set<string>();
for (const o of data?.xraySetting?.outbounds ?? []) {
const ob = o as { tag?: string; protocol?: string } | null;
if (!ob?.tag) continue;
if (excludeBlackhole && ob.protocol === 'blackhole') continue;
outbounds.add(ob.tag);
}
for (const t of data?.subscriptionOutboundTags ?? []) {
if (t) outbounds.add(t);
}
const balancers: string[] = [];
const bal = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers;
for (const b of bal ?? []) {
if (b?.tag && !outbounds.has(b.tag)) balancers.push(b.tag);
}
return { outbounds: [...outbounds], balancers };
},
});
}
+20 -3
View File
@@ -18,7 +18,7 @@ import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
import type { Msg } from '@/utils';
import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
import { antdRule } from '@/utils/zodForm';
import { useOutboundTags } from '@/api/queries/useOutboundTags';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import './NodeFormModal.css';
type Mode = 'add' | 'edit';
@@ -77,7 +77,23 @@ export default function NodeFormModal({
const scheme = Form.useWatch('scheme', form) ?? 'https';
const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
const { data: outboundTags } = useOutboundTags({ excludeBlackhole: true });
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
// Outbounds and balancers share one picker (like the panel-outbound selector);
// when balancers exist they get a labeled group so it's clear the selection
// routes through a balancer. Empty falls back to the placeholder ("Direct
// connection") rather than a synthetic option, so it can't read as a second
// "direct" next to a real freedom outbound.
const outboundOptions = useMemo<
({ label: string; value: string } | { label: string; options: { label: string; value: string }[] })[]
>(() => {
const outOpts = (outboundGroups?.outbounds ?? []).map((tag) => ({ label: tag, value: tag }));
if (!outboundGroups?.balancers.length) return outOpts;
return [
{ label: t('pages.xray.Outbounds'), options: outOpts },
{ label: t('pages.xray.Balancers'), options: outboundGroups.balancers.map((tag) => ({ label: tag, value: tag })) },
];
}, [outboundGroups, t]);
useEffect(() => {
if (!open) return;
@@ -364,12 +380,13 @@ export default function NodeFormModal({
label={t('pages.nodes.outboundTag')}
name="outboundTag"
extra={t('pages.nodes.outboundTagHint')}
getValueProps={(v) => ({ value: (v as string) || undefined })}
>
<Select
allowClear
showSearch
placeholder={t('pages.nodes.outboundTagPlaceholder')}
options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
options={outboundOptions}
/>
</Form.Item>