Bläddra i källkod

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'.
MHSanaei 1 dag sedan
förälder
incheckning
335470607f

+ 35 - 0
frontend/src/api/queries/useOutboundTags.ts

@@ -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
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -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>