From af6a4e19a647220932fde84720b4f8cc9f6dff2a Mon Sep 17 00:00:00 2001 From: local-dev Date: Mon, 27 Jul 2026 00:24:45 +0800 Subject: [PATCH 1/5] feat: per-group listener ports Bind a local mixed inbound port to any proxy group (builtin, custom, or dialer), generating mihomo listeners entries that route traffic through that group directly. - core: GroupListenerBinding stores a stable target id ({kind, id}) instead of the display name, so renaming a group keeps its listener working; resolveGroupListenerEntries validates targets and ports against the effective mixed-port (including base-YAML overrides), node listener ports, other group listeners, and base-YAML listeners, failing generation with a clear error instead of silently skipping - listeners default to 127.0.0.1; 0.0.0.0 only with an explicit allow-LAN opt-in; disabled groups pause generation but keep the binding so re-enabling restores it - UI: the group-type button in each group row becomes an advanced settings button opening a save/cancel dialog (group type, load-balance strategy, listener toggle, port, allow-LAN with security hint); a status dot marks non-default settings; no extra text in the row - store action + subscription save/load + auth handoff serialization - regression tests for all three group kinds, rename stability, disable/re-enable, conflict sources, deleted targets, and persistence round-trips --- .../src/generator/group-listeners.test.ts | 206 +++++++++++++++ .../core/src/generator/group-listeners.ts | 119 +++++++++ packages/core/src/generator/index.ts | 79 +++++- .../src/subscription/config-utils.test.ts | 28 +++ .../core/src/subscription/config-utils.ts | 42 ++++ packages/core/src/types/config.ts | 23 ++ .../dialer-proxy-groups-section.test.ts | 55 +++- .../sections/dialer-proxy-groups-section.tsx | 114 ++++++--- .../group-advanced-settings-dialog.test.ts | 236 ++++++++++++++++++ .../group-advanced-settings-dialog.tsx | 230 +++++++++++++++++ .../sections/group-listener-settings.test.ts | 105 ++++++++ .../sections/group-listener-settings.ts | 108 ++++++++ .../sections/proxy-groups-categories.test.ts | 40 ++- .../sections/proxy-groups-categories.tsx | 58 ++++- ...ups-custom-groups-panel-card-props.test.ts | 18 +- .../proxy-groups-custom-groups-panel.test.ts | 55 +++- .../proxy-groups-custom-groups-panel.tsx | 80 +++++- .../sections/proxy-groups-module-card.tsx | 47 ++-- .../use-editing-subscription-loader.test.ts | 12 + .../home/use-editing-subscription-loader.ts | 30 +++ .../home/use-subscription-link.test.ts | 2 + .../product/home/use-subscription-link.tsx | 1 + packages/ui/src/store/config-store.ts | 2 + .../actions/custom-actions.test.ts | 19 ++ .../config-store/actions/custom-actions.ts | 7 + .../actions/dialer-actions.test.ts | 19 ++ .../config-store/actions/dialer-actions.ts | 15 +- .../actions/group-listener-actions.test.ts | 58 +++++ .../actions/group-listener-actions.ts | 56 +++++ .../store/config-store/auth-handoff.test.ts | 2 + .../ui/src/store/config-store/auth-handoff.ts | 6 +- .../ui/src/store/config-store/definitions.ts | 12 +- .../src/store/config-store/generated-yaml.ts | 1 + 33 files changed, 1775 insertions(+), 110 deletions(-) create mode 100644 packages/core/src/generator/group-listeners.test.ts create mode 100644 packages/core/src/generator/group-listeners.ts create mode 100644 packages/ui/src/product/converter/advanced-mode/sections/group-advanced-settings-dialog.test.ts create mode 100644 packages/ui/src/product/converter/advanced-mode/sections/group-advanced-settings-dialog.tsx create mode 100644 packages/ui/src/product/converter/advanced-mode/sections/group-listener-settings.test.ts create mode 100644 packages/ui/src/product/converter/advanced-mode/sections/group-listener-settings.ts create mode 100644 packages/ui/src/store/config-store/actions/group-listener-actions.test.ts create mode 100644 packages/ui/src/store/config-store/actions/group-listener-actions.ts diff --git a/packages/core/src/generator/group-listeners.test.ts b/packages/core/src/generator/group-listeners.test.ts new file mode 100644 index 0000000..dc7984f --- /dev/null +++ b/packages/core/src/generator/group-listeners.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; +import { generateClashConfig } from "./index"; +import { GroupListenerError } from "./group-listeners"; +import type { ParsedNode } from "@subboost/core/types/node"; +import type { GroupListenerBinding } from "@subboost/core/types/config"; + +function ssNode(patch: Partial = {}): ParsedNode { + return { + name: "Node", + type: "ss", + server: "ss.example.com", + port: 8388, + cipher: "aes-128-gcm", + password: "secret", + ...patch, + } as ParsedNode; +} + +function binding(patch: Partial = {}): GroupListenerBinding { + return { + id: "gl-1", + target: { kind: "module", id: "auto" }, + port: 7891, + ...patch, + }; +} + +function findGroupListeners(config: ReturnType) { + return ((config.listeners ?? []) as Array>).filter((l) => + String(l.name).startsWith("group-mixed-") + ); +} + +describe("group listeners", () => { + it("generates listeners for builtin, custom, and dialer groups by stable id", () => { + const config = generateClashConfig({ + nodes: [ssNode()], + customProxyGroups: [{ id: "custom-1", name: "C Custom", emoji: "C", groupType: "select" }], + dialerProxyGroups: [ + { + id: "dialer-1", + name: "Chain", + relayNodes: ["Node"], + type: "select", + targetNodes: [], + }, + ], + groupListeners: [ + binding({ id: "gl-1", target: { kind: "module", id: "auto" }, port: 7891 }), + binding({ id: "gl-2", target: { kind: "custom", id: "custom-1" }, port: 7892 }), + binding({ id: "gl-3", target: { kind: "dialer", id: "dialer-1" }, port: 7893 }), + ], + userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] }, + }); + + expect(findGroupListeners(config)).toEqual([ + { name: "group-mixed-0", type: "mixed", listen: "127.0.0.1", port: 7891, proxy: "⚡ 自动选择", udp: true }, + { name: "group-mixed-1", type: "mixed", listen: "127.0.0.1", port: 7892, proxy: "C Custom", udp: true }, + { name: "group-mixed-2", type: "mixed", listen: "127.0.0.1", port: 7893, proxy: "Chain", udp: true }, + ]); + }); + + it("keeps listeners working after the target group is renamed", () => { + const config = generateClashConfig({ + nodes: [ssNode()], + proxyGroupNameOverrides: { auto: "改名后的自动" }, + groupListeners: [binding()], + userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] }, + }); + + expect(findGroupListeners(config)).toEqual([ + expect.objectContaining({ proxy: "⚡ 改名后的自动", port: 7891 }), + ]); + }); + + it("listens on 0.0.0.0 only when allowLan is explicitly enabled", () => { + const config = generateClashConfig({ + nodes: [ssNode()], + groupListeners: [binding({ allowLan: true })], + userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] }, + }); + + expect(findGroupListeners(config)[0]).toMatchObject({ listen: "0.0.0.0" }); + }); + + it("pauses generation while the binding or target group is disabled and resumes after re-enable", () => { + const base = { + nodes: [ssNode()], + customProxyGroups: [ + { id: "custom-1", name: "C Custom", emoji: "C", groupType: "select" as const, enabled: false }, + ], + groupListeners: [binding({ target: { kind: "custom" as const, id: "custom-1" }, port: 7892 })], + userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] }, + }; + + // 目标组停用:暂停生成,但不报错(配置保留) + expect(findGroupListeners(generateClashConfig(base))).toEqual([]); + + // 重新启用:恢复生成 + const reEnabled = generateClashConfig({ + ...base, + customProxyGroups: [{ ...base.customProxyGroups[0], enabled: true }], + }); + expect(findGroupListeners(reEnabled)).toEqual([expect.objectContaining({ proxy: "C Custom" })]); + + // 绑定本身停用:同样暂停 + const bindingDisabled = generateClashConfig({ + ...base, + customProxyGroups: [{ ...base.customProxyGroups[0], enabled: true }], + groupListeners: [binding({ target: { kind: "custom", id: "custom-1" }, port: 7892, enabled: false })], + }); + expect(findGroupListeners(bindingDisabled)).toEqual([]); + }); + + it("throws a clear error when the target group has been deleted", () => { + expect(() => + generateClashConfig({ + nodes: [ssNode()], + groupListeners: [binding({ target: { kind: "custom", id: "gone" } })], + userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] }, + }) + ).toThrow(GroupListenerError); + expect(() => + generateClashConfig({ + nodes: [ssNode()], + groupListeners: [binding({ target: { kind: "custom", id: "gone" } })], + userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] }, + }) + ).toThrow(/已被删除/); + }); + + it("rejects conflicts with the effective mixed-port from base YAML overrides", () => { + expect(() => + generateClashConfig({ + nodes: [ssNode()], + groupListeners: [binding({ port: 9999 })], + userConfig: { + dnsYaml: "mixed-port: 9999\n", + enabledGroups: ["select", "auto", "global", "final"], + }, + }) + ).toThrow(/mixed-port/); + }); + + it("rejects conflicts with node listener ports and base YAML listeners", () => { + // 节点监听端口冲突 + expect(() => + generateClashConfig({ + nodes: [ssNode()], + groupListeners: [binding({ port: 12000 })], + userConfig: { + dnsYaml: "", + listenerPorts: { Node: 12000 }, + enabledGroups: ["select", "auto", "global", "final"], + }, + }) + ).toThrow(/节点监听端口/); + + // 基础 YAML 已有 listeners 冲突 + expect(() => + generateClashConfig({ + nodes: [ssNode()], + groupListeners: [binding({ port: 7000 })], + userConfig: { + dnsYaml: "listeners:\n - name: base-in\n type: mixed\n port: 7000\n", + enabledGroups: ["select", "auto", "global", "final"], + }, + }) + ).toThrow(/listeners/); + }); + + it("rejects conflicts between two group listener bindings", () => { + expect(() => + generateClashConfig({ + nodes: [ssNode()], + customProxyGroups: [{ id: "custom-1", name: "C Custom", emoji: "C", groupType: "select" }], + groupListeners: [ + binding({ id: "gl-1", target: { kind: "module", id: "auto" }, port: 7891 }), + binding({ id: "gl-2", target: { kind: "custom", id: "custom-1" }, port: 7891 }), + ], + userConfig: { dnsYaml: "", enabledGroups: ["select", "auto", "global", "final"] }, + }) + ).toThrow(/冲突/); + }); + + it("keeps one port per group, merges after node listeners, and avoids name clashes", () => { + const config = generateClashConfig({ + nodes: [ssNode()], + groupListeners: [ + binding({ id: "gl-1", port: 7891 }), + // 同一目标的第二条绑定被忽略(一组一端口) + binding({ id: "gl-2", port: 7899 }), + ], + userConfig: { + dnsYaml: "listeners:\n - name: group-mixed-0\n type: socks\n port: 6000\n", + listenerPorts: { Node: 12000 }, + enabledGroups: ["select", "auto", "global", "final"], + }, + }); + + const listeners = (config.listeners ?? []) as Array>; + // 基础 YAML listeners 在前,节点监听随后,分组监听最后;名字避开 base 中的 group-mixed-0 + expect(listeners.map((l) => l.name)).toEqual(["group-mixed-0", "mixed0", "group-mixed-1"]); + expect(listeners[2]).toMatchObject({ port: 7891, proxy: "⚡ 自动选择" }); + }); +}); diff --git a/packages/core/src/generator/group-listeners.ts b/packages/core/src/generator/group-listeners.ts new file mode 100644 index 0000000..75dbcab --- /dev/null +++ b/packages/core/src/generator/group-listeners.ts @@ -0,0 +1,119 @@ +/** + * 分组监听:把绑定到策略组的本地 mixed 监听端口展开成 mihomo listeners 条目。 + * + * mihomo 依据(wiki.metacubex.one/config/inbound/listeners/): + * - listener 的 proxy 字段可直接指向策略组,流量绕过 rules 固定出站; + * - proxy 名称必须合法(真实存在的出站/策略组),否则 mihomo 报错; + * - listen 控制监听地址;mixed 类型支持 udp: true。 + */ + +import type { GroupListenerBinding } from "@subboost/core/types/config"; + +export class GroupListenerError extends Error { + constructor(message: string) { + super(message); + this.name = "GroupListenerError"; + } +} + +/** 目标策略组解析结果:exists = 配置里是否还有这个组;active = 该组当前是否参与生成 */ +export interface GroupListenerTargetResolution { + exists: boolean; + active: boolean; + /** 组当前显示名(exists 时必有) */ + name?: string; +} + +export interface ResolveGroupListenersOptions { + bindings: GroupListenerBinding[]; + /** 由稳定 ID 解析目标策略组(三类:module/custom/dialer) */ + resolveTarget: (binding: GroupListenerBinding) => GroupListenerTargetResolution; + /** 最终生效的 mixed-port(含基础 YAML 覆盖值) */ + effectiveMixedPort?: number; + /** 节点监听端口(listenerPorts 生成的 listeners) */ + nodeListenerPorts: number[]; + /** 基础 YAML 中已有的 listeners 占用的端口 */ + baseListenerPorts: number[]; + /** 已占用的 listener 名称(节点监听 + 基础 YAML listeners) */ + usedNames: Set; +} + +function isValidPort(port: unknown): port is number { + return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65535; +} + +/** + * 展开分组监听绑定。无效目标或端口冲突会抛出 GroupListenerError(不静默跳过); + * 仅"目标组存在但被停用"或"绑定本身被停用"时暂停生成(保留配置,不报错)。 + */ +export function resolveGroupListenerEntries( + options: ResolveGroupListenersOptions +): Array> { + const { bindings, resolveTarget, effectiveMixedPort, nodeListenerPorts, baseListenerPorts, usedNames } = options; + if (!Array.isArray(bindings) || bindings.length === 0) return []; + + const usedPorts = new Map(); + if (isValidPort(effectiveMixedPort)) usedPorts.set(effectiveMixedPort, "全局 mixed-port"); + for (const port of nodeListenerPorts) { + if (isValidPort(port) && !usedPorts.has(port)) usedPorts.set(port, "节点监听端口"); + } + for (const port of baseListenerPorts) { + if (isValidPort(port) && !usedPorts.has(port)) usedPorts.set(port, "基础和 DNS 配置中的 listeners"); + } + + const seenTargets = new Set(); + const names = new Set(usedNames); + const out: Array> = []; + let autoIndex = 0; + + for (const binding of bindings) { + if (!binding || typeof binding !== "object") continue; + + const resolution = resolveTarget(binding); + if (!resolution.exists) { + throw new GroupListenerError( + "分组监听的目标策略组已被删除,请在对应策略组的高级设置中移除或修改该监听配置。" + ); + } + + // 组存在但停用,或绑定本身停用:暂停生成,保留配置 + if (binding.enabled === false || !resolution.active) continue; + + const targetName = (resolution.name ?? "").trim(); + if (!targetName) { + throw new GroupListenerError("分组监听的目标策略组名称为空,无法生成 listener。"); + } + + // 一组一端口:同一目标重复绑定只保留首条 + const targetKey = `${binding.target.kind}:${binding.target.id}`; + if (seenTargets.has(targetKey)) continue; + seenTargets.add(targetKey); + + if (!isValidPort(binding.port)) { + throw new GroupListenerError(`策略组「${targetName}」的监听端口无效(需为 1-65535 的整数)。`); + } + const conflict = usedPorts.get(binding.port); + if (conflict) { + throw new GroupListenerError( + `策略组「${targetName}」的监听端口 ${binding.port} 与${conflict}冲突,请修改后再生成。` + ); + } + usedPorts.set(binding.port, `策略组「${targetName}」的监听端口`); + + let name = `group-mixed-${autoIndex++}`; + while (names.has(name)) name = `group-mixed-${autoIndex++}`; + names.add(name); + + out.push({ + name, + type: "mixed", + // 默认仅本机可用;「允许局域网访问」需用户显式开启 + listen: binding.allowLan === true ? "0.0.0.0" : "127.0.0.1", + port: binding.port, + proxy: targetName, + udp: true, + }); + } + + return out; +} diff --git a/packages/core/src/generator/index.ts b/packages/core/src/generator/index.ts index 5157b1c..cfb476e 100644 --- a/packages/core/src/generator/index.ts +++ b/packages/core/src/generator/index.ts @@ -26,6 +26,7 @@ import type { ClashConfig, CustomProxyGroup, CustomRuleSet, + GroupListenerBinding, ProxyGroup, ProxyGroupAdvancedConfig, TemplateType, @@ -35,6 +36,7 @@ import type { DialerProxyGroup } from "@subboost/core/types/template-config"; import { collectDnsPolicyEntries, configToYaml } from "./yaml"; import { isMihomoSupportedProxyNode, normalizeMihomoVlessForGeneration } from "../mihomo/proxy-sanitizer"; import { chooseFallbackPolicyTarget, withBuiltinPolicyTargets } from "./policy-targets"; +import { resolveGroupListenerEntries, type GroupListenerTargetResolution } from "./group-listeners"; export interface GenerateOptions { nodes: ParsedNode[]; @@ -48,6 +50,7 @@ export interface GenerateOptions { builtinRuleEdits?: BuiltinRuleEdits; proxyGroupNameOverrides?: Record; proxyGroupOrder?: string[]; + groupListeners?: GroupListenerBinding[]; } type BaseConfig = Record; @@ -476,10 +479,78 @@ export function generateClashConfig(options: GenerateOptions): ClashConfig { availablePolicyTargets ); const resolvedListeners = (() => { - if (!listeners) return baseTopLevelPatch.listeners; - if (baseTopLevelPatch.listeners === undefined) return listeners; - if (Array.isArray(baseTopLevelPatch.listeners)) return [...baseTopLevelPatch.listeners, ...listeners]; - throw new BaseConfigYamlError("基础和 DNS 配置中的 listeners 必须是数组,才能与节点监听端口合并。"); + const baseListeners = baseTopLevelPatch.listeners; + if (baseListeners !== undefined && !Array.isArray(baseListeners)) { + throw new BaseConfigYamlError("基础和 DNS 配置中的 listeners 必须是数组,才能与节点监听端口合并。"); + } + const baseListenerArray = Array.isArray(baseListeners) ? (baseListeners as Array>) : []; + + // 分组监听:按稳定 ID 解析目标策略组(module/custom/dialer),改名后依然有效 + const groupListenerEntries = (() => { + const bindings = Array.isArray(options.groupListeners) ? options.groupListeners : []; + if (bindings.length === 0) return []; + + const finalGroupNames = new Set(proxyGroups.map((g) => g.name)); + const resolveTarget = (binding: GroupListenerBinding): GroupListenerTargetResolution => { + const target = binding?.target; + if (!target || typeof target !== "object" || typeof target.id !== "string") { + return { exists: false, active: false }; + } + if (target.kind === "module") { + const mod = PROXY_GROUP_MODULES.find((m) => m.id === target.id); + if (!mod) return { exists: false, active: false }; + const name = resolveProxyGroupModuleName(mod, proxyGroupNameOverrides?.[mod.id]); + return { exists: true, active: finalGroupNames.has(name), name }; + } + if (target.kind === "custom") { + const group = customProxyGroups.find((g) => g && g.id === target.id); + if (!group) return { exists: false, active: false }; + const name = typeof group.name === "string" ? group.name.trim() : ""; + return { exists: true, active: group.enabled !== false && finalGroupNames.has(name), name }; + } + if (target.kind === "dialer") { + const group = dialerProxyGroups.find((g) => g && g.id === target.id); + if (!group) return { exists: false, active: false }; + const name = typeof group.name === "string" ? group.name.trim() : ""; + return { exists: true, active: group.enabled !== false && finalGroupNames.has(name), name }; + } + return { exists: false, active: false }; + }; + + // 最终生效的 mixed-port:基础 YAML patch 里的值即最终写入配置的值 + const patchMixedPort = baseTopLevelPatch["mixed-port"]; + const effectiveMixedPort = typeof patchMixedPort === "number" ? patchMixedPort : undefined; + + const collectPorts = (list: Array> | undefined) => + (list ?? []) + .map((l) => (l && typeof l === "object" ? l.port : undefined)) + .filter((p): p is number => typeof p === "number"); + const collectNames = (list: Array> | undefined) => + (list ?? []) + .map((l) => (l && typeof l === "object" && typeof l.name === "string" ? l.name : "")) + .filter(Boolean); + + return resolveGroupListenerEntries({ + bindings, + resolveTarget, + effectiveMixedPort, + nodeListenerPorts: listeners ? listeners.map((l) => l.port) : [], + baseListenerPorts: collectPorts(baseListenerArray), + usedNames: new Set([ + ...(listeners ? listeners.map((l) => l.name) : []), + ...collectNames(baseListenerArray), + ]), + }); + })(); + + const generatedListeners = [ + ...(listeners ?? []), + ...groupListenerEntries, + ]; + + if (generatedListeners.length === 0) return baseListeners; + if (baseListeners === undefined) return generatedListeners; + return [...baseListenerArray, ...generatedListeners]; })(); const mergedProxyProviders = (() => { diff --git a/packages/core/src/subscription/config-utils.test.ts b/packages/core/src/subscription/config-utils.test.ts index 4771a3f..657e1df 100644 --- a/packages/core/src/subscription/config-utils.test.ts +++ b/packages/core/src/subscription/config-utils.test.ts @@ -550,4 +550,32 @@ describe("subscription config utils", () => { expect(options.dialerProxyGroups).toBeUndefined(); }); + it("restores persisted group listeners with stable targets and drops malformed entries", () => { + const options = buildGenerateOptionsFromConfig( + { + groupListeners: [ + { id: "gl-1", target: { kind: "module", id: "auto" }, port: 7891 }, + { id: "gl-2", target: { kind: "custom", id: "c1" }, port: 7892, enabled: false, allowLan: true }, + // 同目标重复:仅保留首条 + { id: "gl-dup", target: { kind: "module", id: "auto" }, port: 7899 }, + // 非法条目:全部丢弃 + { id: "gl-bad-kind", target: { kind: "node", id: "n1" }, port: 7893 }, + { id: "gl-bad-port", target: { kind: "dialer", id: "d1" }, port: 70000 }, + { id: "gl-no-target", port: 7894 }, + { target: { kind: "dialer", id: "d2" }, port: 7895 }, + ], + }, + { nodes: [node()] }, + ); + + expect(options.groupListeners).toEqual([ + { id: "gl-1", target: { kind: "module", id: "auto" }, port: 7891 }, + { id: "gl-2", target: { kind: "custom", id: "c1" }, port: 7892, enabled: false, allowLan: true }, + { id: "group_listener_7", target: { kind: "dialer", id: "d2" }, port: 7895 }, + ]); + + // 无 groupListeners 时不携带该字段 + expect(buildGenerateOptionsFromConfig({}, { nodes: [node()] })).not.toHaveProperty("groupListeners"); + }); + }); diff --git a/packages/core/src/subscription/config-utils.ts b/packages/core/src/subscription/config-utils.ts index e867494..d813ab1 100644 --- a/packages/core/src/subscription/config-utils.ts +++ b/packages/core/src/subscription/config-utils.ts @@ -8,6 +8,7 @@ import { isProxyGroupGroupType, type CustomProxyGroup, type CustomRule, + type GroupListenerBinding, type ProxyGroupRuleTarget, type TemplateType, type UserConfig, @@ -128,6 +129,45 @@ function normalizeListenerPorts(value: unknown): Record | undefi return Object.keys(out).length > 0 ? out : undefined; } +function normalizeGroupListeners(value: unknown): GroupListenerBinding[] { + if (!Array.isArray(value)) return []; + const out: GroupListenerBinding[] = []; + const usedIds = new Set(); + const usedTargets = new Set(); + for (let index = 0; index < value.length; index += 1) { + const item = value[index]; + if (!isRecord(item)) continue; + + const rawTarget = item.target; + if (!isRecord(rawTarget)) continue; + const kind = rawTarget.kind; + if (kind !== "module" && kind !== "custom" && kind !== "dialer") continue; + const targetId = toTrimmedString(rawTarget.id); + if (!targetId) continue; + + const port = normalizePort(item.port); + if (port === undefined) continue; + + // 一组一端口:同一目标重复绑定只保留首条 + const targetKey = `${kind}:${targetId}`; + if (usedTargets.has(targetKey)) continue; + usedTargets.add(targetKey); + + let id = toTrimmedString(item.id) || `group_listener_${index + 1}`; + while (usedIds.has(id)) id = `${id}_${index + 1}`; + usedIds.add(id); + + out.push({ + id, + target: { kind, id: targetId }, + port, + ...(item.enabled === false ? { enabled: false } : {}), + ...(item.allowLan === true ? { allowLan: true } : {}), + }); + } + return out; +} + function normalizeEnabledList(value: unknown): string[] | undefined { const list = normalizeStringArray(value); return list.length > 0 ? list : undefined; @@ -300,6 +340,7 @@ export function buildGenerateOptionsFromConfig( const dialerProxyGroups = normalizeDialerProxyGroups(config.dialerProxyGroups); const proxyGroupOrder = normalizeProxyGroupOrder(config.proxyGroupOrder); const effectiveNodes = resolveNodeNameFilter(opts.nodes, config.nodeNameFilter).effectiveNodes; + const groupListeners = normalizeGroupListeners(config.groupListeners); const sanitizedNodes = stripImportedNodeControlFieldsFromList(effectiveNodes); const proxyGroupAdvanced = isRecord(config.proxyGroupAdvanced) ? Object.fromEntries( @@ -321,5 +362,6 @@ export function buildGenerateOptionsFromConfig( ...(Object.keys(builtinRuleEdits).length > 0 ? { builtinRuleEdits } : {}), ...(proxyGroupNameOverrides ? { proxyGroupNameOverrides } : {}), ...(proxyGroupOrder ? { proxyGroupOrder } : {}), + ...(groupListeners.length > 0 ? { groupListeners } : {}), }; } diff --git a/packages/core/src/types/config.ts b/packages/core/src/types/config.ts index 49babde..a4c0316 100644 --- a/packages/core/src/types/config.ts +++ b/packages/core/src/types/config.ts @@ -95,6 +95,29 @@ export interface ListenerConfig { [key: string]: unknown; } +/** + * 分组监听绑定:给一个"已存在的策略组"开本地 mixed inbound 监听端口。 + * target 保存稳定 ID(而非组显示名),策略组改名后监听仍然有效。 + * 生成时映射为 listeners 条目:{ name: 自动, type: mixed, listen, port, proxy: 组当前名称, udp: true }。 + */ +export type GroupListenerTargetKind = "module" | "custom" | "dialer"; + +export interface GroupListenerTarget { + // module = 内置分流组(PROXY_GROUP_MODULES 的 id),custom = 自定义分流组,dialer = 中转组 + kind: GroupListenerTargetKind; + id: string; +} + +export interface GroupListenerBinding { + id: string; + target: GroupListenerTarget; + port: number; + // 默认启用;false 时保留配置但不生成 listener + enabled?: boolean; + // 默认 false → listen 127.0.0.1;true → 0.0.0.0(允许局域网访问,需用户显式开启) + allowLan?: boolean; +} + export interface SnifferConfig { enable?: boolean; "parse-pure-ip"?: boolean; diff --git a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.test.ts index d436709..a3a5e4c 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ proxyGroupAdded: vi.fn(), }, toast: vi.fn(), + confirmDialog: vi.fn(async () => true), })); const stateMock = vi.hoisted(() => ({ @@ -119,6 +120,7 @@ vi.mock("@subboost/ui/components/ui/switch", () => ({ }, })); vi.mock("@subboost/ui/components/ui/toaster", () => ({ toast: mocks.toast })); +vi.mock("@subboost/ui/components/ui/confirm-dialog", () => ({ confirmDialog: mocks.confirmDialog })); vi.mock("@subboost/core/generator/proxy-groups", () => ({ PROXY_GROUP_MODULES: [ { id: "auto", name: "Auto" }, @@ -147,6 +149,12 @@ vi.mock("../section-header", () => ({ return null; }, })); +vi.mock("./group-advanced-settings-dialog", () => ({ + GroupAdvancedSettingsDialog: (props: any) => { + mocks.captures.settingsDialogs.push(props); + return null; + }, +})); import { DialerProxyGroupsSection } from "./dialer-proxy-groups-section"; @@ -187,6 +195,7 @@ function renderSection(overrides: Record = {}, props = { isExpa mocks.captures.dropdownContents = []; mocks.captures.dropdownRoots = []; mocks.captures.intrinsics = []; + mocks.captures.settingsDialogs = []; try { const html = renderToStaticMarkup(React.createElement(DialerProxyGroupsSection, props)); return { html, setters: stateMock.setters }; @@ -228,6 +237,11 @@ describe("DialerProxyGroupsSection", () => { updateDialerProxyGroup: vi.fn(), addNodeToDialerGroup: vi.fn(), removeNodeFromDialerGroup: vi.fn(), + groupListeners: [], + setGroupListener: vi.fn(), + dnsYaml: "", + mixedPort: 7890, + listenerPorts: {}, }; }); @@ -384,19 +398,22 @@ describe("DialerProxyGroupsSection", () => { expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-a", { enabled: false }); mocks.captures.switches[0].onClick({ stopPropagation: vi.fn() }); - const groupTypeButton = mocks.captures.buttons.find((props: any) => props["aria-label"] === "修改 Group A 类型"); - expect(groupTypeButton).toEqual(expect.objectContaining({ title: "类型:手动选择" })); - groupTypeButton.onClick({ stopPropagation: vi.fn() }); - const autoTypeItem = mocks.captures.menuItems.find((props: any) => textOf(props.children).includes("自动测速")); - autoTypeItem.onSelect(); + // 类型/监听改动统一走高级设置弹窗 + const settingsButton = mocks.captures.buttons.find((props: any) => props["aria-label"] === "打开 Group A 高级设置"); + expect(settingsButton).toEqual(expect.objectContaining({ title: "高级设置(类型:手动选择)" })); + const stopSettingsClick = vi.fn(); + settingsButton.onClick({ stopPropagation: stopSettingsClick }); + expect(stopSettingsClick).toHaveBeenCalled(); + expect(stateMock.setters[7]).toHaveBeenCalledWith("g-a"); + + renderSection({ 0: new Set(["g-a"]), 7: "g-a" }); + const settingsDialog = mocks.captures.settingsDialogs[0]; + expect(settingsDialog.groupName).toBe("Group A"); + settingsDialog.onSave({ groupType: "url-test", listener: null }); expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-a", { type: "url-test", strategy: undefined }); + expect(mocks.store.setGroupListener).toHaveBeenCalledWith({ kind: "dialer", id: "g-a" }, null); - const fallbackTypeItem = mocks.captures.menuItems.find((props: any) => textOf(props.children).includes("故障切换")); - fallbackTypeItem.onSelect(); - expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-a", { type: "fallback", strategy: undefined }); - - const roundRobinTypeItem = mocks.captures.menuItems.find((props: any) => textOf(props.children).includes("轮询均摊")); - roundRobinTypeItem.onSelect(); + settingsDialog.onSave({ groupType: "load-balance", strategy: "round-robin", listener: null }); expect(mocks.store.updateDialerProxyGroup).toHaveBeenCalledWith("g-a", { type: "load-balance", strategy: "round-robin" }); mocks.captures.switches[1].onCheckedChange(true); @@ -417,6 +434,22 @@ describe("DialerProxyGroupsSection", () => { expect(mocks.store.removeDialerProxyGroup).toHaveBeenCalledWith("g-a"); }); + it("confirms and cascades listener removal when deleting a dialer group with a binding", async () => { + mocks.store.groupListeners = [{ id: "gl-1", target: { kind: "dialer", id: "g-a" }, port: 7891 }]; + renderSection({ 0: new Set(["g-a"]) }); + const deleteButton = mocks.captures.iconButtons.find((props: any) => props.label === "删除 Group A 中转组"); + + mocks.confirmDialog.mockResolvedValueOnce(false); + await deleteButton.onClick({ stopPropagation: vi.fn() }); + expect(mocks.confirmDialog).toHaveBeenCalledTimes(1); + expect(mocks.confirmDialog).toHaveBeenCalledWith(expect.objectContaining({ variant: "warning" })); + expect(mocks.store.removeDialerProxyGroup).not.toHaveBeenCalled(); + + mocks.confirmDialog.mockResolvedValueOnce(true); + await deleteButton.onClick({ stopPropagation: vi.fn() }); + expect(mocks.store.removeDialerProxyGroup).toHaveBeenCalledWith("g-a"); + }); + it("enables disabled groups without conflicts and with relay-only cleanup", () => { mocks.store.dialerProxyGroups = [ { ...groupA, relayNodes: ["Custom"], targetNodes: ["Alpha"] }, diff --git a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx index ebe112e..bbe9882 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/dialer-proxy-groups-section.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { Check, ChevronDown, ChevronRight, Link as LinkIcon, Pencil, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react"; import { Badge } from "@subboost/ui/components/ui/badge"; import { Button } from "@subboost/ui/components/ui/button"; +import { confirmDialog } from "@subboost/ui/components/ui/confirm-dialog"; import { IconButton } from "@subboost/ui/components/ui/icon-button"; import { DropdownMenu, @@ -34,8 +35,9 @@ import { ProxyGroupSummary } from "./proxy-group-summary"; import { getLoadBalanceStrategyLabel, getProxyGroupTypeLabel, - ProxyGroupTypeMenu, } from "./proxy-group-type-menu"; +import { GroupAdvancedSettingsDialog } from "./group-advanced-settings-dialog"; +import { findGroupListenerBinding } from "./group-listener-settings"; type DialerSelectableNode = { name: string; @@ -63,6 +65,11 @@ export function DialerProxyGroupsSection({ updateDialerProxyGroup, addNodeToDialerGroup, removeNodeFromDialerGroup, + groupListeners = [], + setGroupListener, + dnsYaml, + mixedPort, + listenerPorts = {}, } = useConfigStore(); const [expandedDialerGroups, setExpandedDialerGroups] = React.useState>(new Set()); @@ -78,6 +85,11 @@ export function DialerProxyGroupsSection({ }); const [relaySearchByGroupId, setRelaySearchByGroupId] = React.useState>({}); const [targetSearchByGroupId, setTargetSearchByGroupId] = React.useState>({}); + const [settingsDialerGroupId, setSettingsDialerGroupId] = React.useState(null); + const listenerConflictState = React.useMemo( + () => ({ dnsYaml, mixedPort, listenerPorts, groupListeners }), + [dnsYaml, mixedPort, listenerPorts, groupListeners] + ); const interactions = useProductInteractionAdapter(); const effectiveNodes = React.useMemo( () => resolveNodeNameFilter(nodes, nodeNameFilter).effectiveNodes, @@ -397,37 +409,49 @@ export function DialerProxyGroupsSection({ }} onClick={(e) => e.stopPropagation()} /> - - updateDialerProxyGroup(group.id, { - type: groupType, - ...(groupType === "load-balance" - ? { strategy: strategy ?? group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } - : { strategy: undefined }), - }) - } - trigger={ - - } - /> + { + onClick={async (e) => { e.stopPropagation(); + const listenerBinding = findGroupListenerBinding(groupListeners, { kind: "dialer", id: group.id }); + if (listenerBinding) { + const ok = await confirmDialog({ + title: `确认删除「${group.name}」?`, + description: ( + + + 警告: + 该中转组已绑定监听端口 {listenerBinding.port},删除中转组会一并移除该监听端口。 + + + ), + confirmText: "删除", + variant: "warning", + }); + if (!ok) return; + } removeDialerProxyGroup(group.id); }} className="pointer-events-auto relative z-10 h-7 w-7 p-1 text-white/30 hover:text-red-400" @@ -640,6 +664,40 @@ export function DialerProxyGroupsSection({ )} + + {(() => { + const settingsGroup = settingsDialerGroupId + ? dialerProxyGroups.find((group) => group.id === settingsDialerGroupId) + : undefined; + if (!settingsGroup) return null; + const target = { kind: "dialer" as const, id: settingsGroup.id }; + return ( + { + if (!open) setSettingsDialerGroupId(null); + }} + groupName={settingsGroup.name} + groupType={settingsGroup.type} + strategy={settingsGroup.strategy} + listenerTarget={target} + listenerBinding={findGroupListenerBinding(groupListeners, target)} + conflictState={listenerConflictState} + onSave={({ groupType, strategy, listener }) => { + updateDialerProxyGroup(settingsGroup.id, { + type: groupType, + ...(groupType === "load-balance" + ? { strategy: strategy ?? settingsGroup.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } + : { strategy: undefined }), + }); + setGroupListener( + target, + listener ? { port: listener.port, enabled: listener.enabled, allowLan: listener.allowLan } : null + ); + }} + /> + ); + })()} ); } diff --git a/packages/ui/src/product/converter/advanced-mode/sections/group-advanced-settings-dialog.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/group-advanced-settings-dialog.test.ts new file mode 100644 index 0000000..6188412 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/group-advanced-settings-dialog.test.ts @@ -0,0 +1,236 @@ +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + captures: {} as Record, +})); + +const stateMock = vi.hoisted(() => ({ + enabled: false, + callIndex: 0, + overrides: {} as Record, + setters: [] as Array>, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useState: (initial: unknown) => { + if (!stateMock.enabled) return actual.useState(initial); + const index = stateMock.callIndex++; + const value = Object.prototype.hasOwnProperty.call(stateMock.overrides, index) + ? stateMock.overrides[index] + : typeof initial === "function" + ? (initial as () => unknown)() + : initial; + const setter = vi.fn(); + stateMock.setters[index] = setter; + return [value, setter]; + }, + useEffect: () => undefined, + }; +}); + +vi.mock("@subboost/ui/components/ui/button", () => ({ + Button: (props: any) => { + mocks.captures.buttons.push(props); + return React.createElement("button", { className: props.className }, props.children); + }, +})); +vi.mock("@subboost/ui/components/ui/dialog", () => ({ + Dialog: (props: any) => props.children, + DialogContent: (props: any) => { + mocks.captures.dialogContents.push(props); + return React.createElement("section", { className: props.className }, props.children); + }, + DialogHeader: (props: any) => React.createElement("header", null, props.children), + DialogTitle: (props: any) => React.createElement("h2", { className: props.className }, props.children), + DialogFooter: (props: any) => React.createElement("footer", null, props.children), +})); +vi.mock("@subboost/ui/components/ui/form-field", () => ({ + FormField: (props: any) => { + mocks.captures.formFields.push(props); + return React.createElement("div", null, props.label, props.children, props.error ?? null); + }, +})); +vi.mock("@subboost/ui/components/ui/input", () => ({ + Input: (props: any) => { + mocks.captures.inputs.push(props); + return null; + }, +})); +vi.mock("@subboost/ui/components/ui/select", () => ({ + Select: (props: any) => { + mocks.captures.selects.push(props); + return props.children; + }, + SelectTrigger: (props: any) => props.children ?? null, + SelectValue: () => null, + SelectContent: (props: any) => props.children, + SelectItem: (props: any) => { + mocks.captures.selectItems.push(props); + return null; + }, +})); +vi.mock("@subboost/ui/components/ui/switch", () => ({ + Switch: (props: any) => { + mocks.captures.switches.push(props); + return null; + }, +})); + +import { GroupAdvancedSettingsDialog } from "./group-advanced-settings-dialog"; + +const TARGET = { kind: "module" as const, id: "auto" }; +const CONFLICT_STATE = { + dnsYaml: "", + mixedPort: 7890, + listenerPorts: { Node: 9200 }, + groupListeners: [{ id: "gl-other", target: { kind: "custom" as const, id: "c1" }, port: 9300 }], +}; + +// state 索引:0=draftType 1=draftStrategy 2=listenerOn 3=portInput 4=allowLan +function renderDialog(overrides: Record = {}, props: Record = {}) { + stateMock.enabled = true; + stateMock.callIndex = 0; + stateMock.overrides = overrides; + stateMock.setters = []; + mocks.captures = { buttons: [], dialogContents: [], formFields: [], inputs: [], selects: [], selectItems: [], switches: [] }; + try { + const html = renderToStaticMarkup( + React.createElement(GroupAdvancedSettingsDialog, { + open: true, + onOpenChange: vi.fn(), + groupName: "⚡ 自动选择", + groupType: "select", + listenerTarget: TARGET, + conflictState: CONFLICT_STATE, + onSave: vi.fn(), + ...props, + } as any) + ); + return { html, setters: stateMock.setters }; + } finally { + stateMock.enabled = false; + } +} + +describe("GroupAdvancedSettingsDialog", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows the strategy field only for load-balance", () => { + renderDialog(); + expect(mocks.captures.formFields.map((f: any) => f.label)).toEqual(["代理组类型"]); + + renderDialog({ 0: "load-balance" }); + expect(mocks.captures.formFields.map((f: any) => f.label)).toEqual(["代理组类型", "负载均衡策略"]); + }); + + it("saves only on the save button and passes the full draft", () => { + const onSave = vi.fn(); + const onOpenChange = vi.fn(); + renderDialog( + { 0: "load-balance", 1: "round-robin", 2: true, 3: "7891", 4: true }, + { onSave, onOpenChange } + ); + + const saveButton = mocks.captures.buttons.find((props: any) => props.children === "保存"); + expect(saveButton.disabled).toBe(false); + saveButton.onClick(); + expect(onSave).toHaveBeenCalledWith({ + groupType: "load-balance", + strategy: "round-robin", + listener: { port: 7891, enabled: true, allowLan: true }, + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("cancels without leaking partial edits", () => { + const onSave = vi.fn(); + const onOpenChange = vi.fn(); + renderDialog({ 0: "url-test", 2: true, 3: "7891" }, { onSave, onOpenChange }); + + mocks.captures.buttons.find((props: any) => props.children === "取消").onClick(); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("blocks saving on port conflicts with an inline error", () => { + const onSave = vi.fn(); + renderDialog({ 2: true, 3: "9200" }, { onSave }); + + const portField = mocks.captures.formFields.find((f: any) => f.label === "端口"); + expect(String(portField.error)).toMatch(/节点监听端口/); + const saveButton = mocks.captures.buttons.find((props: any) => props.children === "保存"); + expect(saveButton.disabled).toBe(true); + saveButton.onClick?.(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("removes the listener when the port field is cleared", () => { + const onSave = vi.fn(); + renderDialog( + { 2: false, 3: "" }, + { onSave, listenerBinding: { id: "gl-1", target: TARGET, port: 7891 } } + ); + + mocks.captures.buttons.find((props: any) => props.children === "保存").onClick(); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ listener: null })); + }); + + it("keeps a disabled listener configuration on save", () => { + const onSave = vi.fn(); + renderDialog({ 2: false, 3: "7891" }, { onSave }); + + mocks.captures.buttons.find((props: any) => props.children === "保存").onClick(); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ listener: { port: 7891, enabled: false, allowLan: false } }) + ); + }); + + it("saves a paused listener even when its port conflicts elsewhere", () => { + const onSave = vi.fn(); + // 关闭开关=暂停:端口 9200 与节点监听冲突,但停用绑定不参与生成,应可保存 + renderDialog({ 2: false, 3: "9200" }, { onSave }); + + const portField = mocks.captures.formFields.find((f: any) => f.label === "端口"); + expect(portField.error).toBeNull(); + const saveButton = mocks.captures.buttons.find((props: any) => props.children === "保存"); + expect(saveButton.disabled).toBe(false); + saveButton.onClick(); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ listener: { port: 9200, enabled: false, allowLan: false } }) + ); + }); + + it("still rejects malformed ports while the listener is paused", () => { + const onSave = vi.fn(); + renderDialog({ 2: false, 3: "abc" }, { onSave }); + + const portField = mocks.captures.formFields.find((f: any) => f.label === "端口"); + expect(String(portField.error)).toMatch(/1-65535/); + const saveButton = mocks.captures.buttons.find((props: any) => props.children === "保存"); + expect(saveButton.disabled).toBe(true); + saveButton.onClick?.(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("shows the amber security hint only when allowLan is enabled", () => { + const withLan = renderDialog({ 2: true, 3: "7891", 4: true }); + expect(withLan.html).toContain("0.0.0.0"); + expect(withLan.html).toContain("amber"); + + const withoutLan = renderDialog({ 2: true, 3: "7891", 4: false }); + expect(withoutLan.html).not.toContain("amber"); + }); + + it("stays within the viewport width on narrow screens", () => { + renderDialog(); + // w-[calc(100vw-2rem)]:窄屏下弹窗不超过视口宽度,不产生横向滚动 + expect(mocks.captures.dialogContents[0].className).toContain("w-[calc(100vw-2rem)]"); + }); +}); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/group-advanced-settings-dialog.tsx b/packages/ui/src/product/converter/advanced-mode/sections/group-advanced-settings-dialog.tsx new file mode 100644 index 0000000..0a2df20 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/group-advanced-settings-dialog.tsx @@ -0,0 +1,230 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@subboost/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@subboost/ui/components/ui/dialog"; +import { FormField } from "@subboost/ui/components/ui/form-field"; +import { Input } from "@subboost/ui/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@subboost/ui/components/ui/select"; +import { Switch } from "@subboost/ui/components/ui/switch"; +import { + DEFAULT_LOAD_BALANCE_STRATEGY, + LOAD_BALANCE_STRATEGIES, + type GroupListenerBinding, + type GroupListenerTarget, + type LoadBalanceStrategy, + type ProxyGroupGroupType, +} from "@subboost/core/types/config"; +import { getLoadBalanceStrategyLabel, getProxyGroupTypeLabel } from "./proxy-group-type-menu"; +import { + validateGroupListenerPort, + type GroupListenerConflictState, +} from "./group-listener-settings"; + +const GROUP_TYPE_OPTIONS: ProxyGroupGroupType[] = [ + "select", + "url-test", + "fallback", + "load-balance", + "direct-first", + "reject-first", +]; + +export interface GroupAdvancedSettingsValue { + groupType: ProxyGroupGroupType; + strategy?: LoadBalanceStrategy; + listener: { port: number; enabled: boolean; allowLan: boolean } | null; +} + +interface GroupAdvancedSettingsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + groupName: string; + groupType: ProxyGroupGroupType; + strategy?: LoadBalanceStrategy; + listenerTarget: GroupListenerTarget; + listenerBinding?: GroupListenerBinding; + conflictState: GroupListenerConflictState; + onSave: (value: GroupAdvancedSettingsValue) => void; +} + +/** + * 策略组统一高级设置弹窗:组类型、负载均衡策略(仅 load-balance)、监听端口。 + * 保存/取消语义:所有修改保存前只存在于本地 state,取消不落任何变更。 + */ +export function GroupAdvancedSettingsDialog({ + open, + onOpenChange, + groupName, + groupType, + strategy, + listenerTarget, + listenerBinding, + conflictState, + onSave, +}: GroupAdvancedSettingsDialogProps) { + const [draftType, setDraftType] = React.useState(groupType); + const [draftStrategy, setDraftStrategy] = React.useState( + strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY + ); + const [listenerOn, setListenerOn] = React.useState(false); + const [portInput, setPortInput] = React.useState(""); + const [allowLan, setAllowLan] = React.useState(false); + + // 每次打开时从当前配置重建草稿,丢弃上次未保存的修改 + React.useEffect(() => { + if (!open) return; + setDraftType(groupType); + setDraftStrategy(strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY); + setListenerOn(Boolean(listenerBinding && listenerBinding.enabled !== false)); + setPortInput(listenerBinding ? String(listenerBinding.port) : ""); + setAllowLan(listenerBinding?.allowLan === true); + }, [open, groupType, strategy, listenerBinding]); + + // 开关关闭=暂停(保留配置不生成),端口只需格式合法、无需无冲突(与生成器一致) + const portCheck = React.useMemo( + () => validateGroupListenerPort(portInput, conflictState, listenerTarget, { checkConflict: listenerOn }), + [portInput, conflictState, listenerTarget, listenerOn] + ); + // 仅当监听开启,或关闭但保留了端口值时才需要端口合法(允许清空端口来彻底移除配置) + const portRequired = listenerOn || portInput.trim() !== ""; + const portError = portRequired ? portCheck.error : null; + const canSave = !portError; + + const handleSave = () => { + if (!canSave) return; + onSave({ + groupType: draftType, + ...(draftType === "load-balance" ? { strategy: draftStrategy } : {}), + listener: portRequired && portCheck.port !== null + ? { port: portCheck.port, enabled: listenerOn, allowLan } + : null, + }); + onOpenChange(false); + }; + + return ( + + + + {groupName} · 高级设置 + + +
+ + + + + {draftType === "load-balance" && ( + + + + )} + +
+
+
+
监听端口
+
+ 为该策略组开一个本地 mixed 入站端口,流量固定走此组 +
+
+ +
+ + {(listenerOn || portInput.trim() !== "") && ( + <> + + setPortInput(event.target.value)} + /> + + +
+
允许局域网访问
+ +
+ {allowLan && ( +
+ 开启后监听 0.0.0.0,局域网内任何设备都能通过该端口使用此代理,请确认所在网络可信。 + 默认仅本机(127.0.0.1)可访问。 +
+ )} + + )} +
+
+ + + + + +
+
+ ); +} diff --git a/packages/ui/src/product/converter/advanced-mode/sections/group-listener-settings.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/group-listener-settings.test.ts new file mode 100644 index 0000000..e630fa4 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/group-listener-settings.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + collectUsedListenerPorts, + findGroupListenerBinding, + resolveEffectiveMixedPort, + validateGroupListenerPort, + type GroupListenerConflictState, +} from "./group-listener-settings"; + +const TARGET = { kind: "module" as const, id: "auto" }; + +function state(patch: Partial = {}): GroupListenerConflictState { + return { + dnsYaml: "", + mixedPort: 7890, + listenerPorts: {}, + groupListeners: [], + ...patch, + }; +} + +describe("resolveEffectiveMixedPort", () => { + it("prefers the base YAML override over the settings value", () => { + expect(resolveEffectiveMixedPort("mixed-port: 9999\n", 7890)).toBe(9999); + }); + + it("returns undefined when explicit base YAML omits mixed-port", () => { + expect(resolveEffectiveMixedPort("allow-lan: false\n", 7890)).toBeUndefined(); + }); + + it("falls back to the settings value without base YAML", () => { + expect(resolveEffectiveMixedPort("", 7890)).toBe(7890); + }); +}); + +describe("validateGroupListenerPort", () => { + it("covers all four conflict sources", () => { + const conflictState = state({ + dnsYaml: "mixed-port: 9000\nlisteners:\n - name: base-in\n type: mixed\n port: 9100\n", + listenerPorts: { Node: 9200 }, + groupListeners: [{ id: "gl-1", target: { kind: "custom", id: "c1" }, port: 9300 }], + }); + + expect(validateGroupListenerPort("9000", conflictState, TARGET).error).toMatch(/mixed-port/); + expect(validateGroupListenerPort("9100", conflictState, TARGET).error).toMatch(/listeners/); + expect(validateGroupListenerPort("9200", conflictState, TARGET).error).toMatch(/节点监听端口/); + expect(validateGroupListenerPort("9300", conflictState, TARGET).error).toMatch(/其他策略组/); + expect(validateGroupListenerPort("9400", conflictState, TARGET)).toEqual({ port: 9400, error: null }); + }); + + it("excludes the binding being edited from self-conflict", () => { + const conflictState = state({ + groupListeners: [{ id: "gl-1", target: TARGET, port: 7891 }], + }); + expect(validateGroupListenerPort("7891", conflictState, TARGET).error).toBeNull(); + }); + + it("rejects empty and out-of-range input", () => { + expect(validateGroupListenerPort("", state(), TARGET).error).toMatch(/请输入/); + expect(validateGroupListenerPort("0", state(), TARGET).error).toMatch(/1-65535/); + expect(validateGroupListenerPort("70000", state(), TARGET).error).toMatch(/1-65535/); + expect(validateGroupListenerPort("abc", state(), TARGET).error).toMatch(/1-65535/); + }); + + it("skips conflict checks but keeps format checks when checkConflict is false", () => { + const conflictState = state({ mixedPort: 7890 }); + + expect(validateGroupListenerPort("7890", conflictState, TARGET).error).toMatch(/冲突/); + expect(validateGroupListenerPort("7890", conflictState, TARGET, { checkConflict: false })).toEqual({ + port: 7890, + error: null, + }); + expect(validateGroupListenerPort("abc", conflictState, TARGET, { checkConflict: false }).error).toMatch(/1-65535/); + expect(validateGroupListenerPort("", conflictState, TARGET, { checkConflict: false }).error).toMatch(/请输入/); + }); +}); + +describe("collectUsedListenerPorts / findGroupListenerBinding", () => { + it("labels the first source that claims a port", () => { + const used = collectUsedListenerPorts(state({ mixedPort: 7890 })); + expect(used.get(7890)).toBe("全局 mixed-port"); + }); + + it("ignores disabled bindings as conflict sources, matching generator behavior", () => { + const used = collectUsedListenerPorts( + state({ + groupListeners: [ + { id: "gl-1", target: { kind: "custom", id: "paused" }, port: 9300, enabled: false }, + { id: "gl-2", target: { kind: "custom", id: "active" }, port: 9400 }, + ], + }) + ); + expect(used.get(9300)).toBeUndefined(); + expect(used.get(9400)).toBe("其他策略组的监听端口"); + }); + + it("matches bindings by target kind and id", () => { + const bindings = [ + { id: "gl-1", target: { kind: "custom" as const, id: "x" }, port: 1 }, + { id: "gl-2", target: { kind: "module" as const, id: "auto" }, port: 2 }, + ]; + expect(findGroupListenerBinding(bindings, TARGET)?.id).toBe("gl-2"); + expect(findGroupListenerBinding(bindings, { kind: "dialer", id: "auto" })).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/group-listener-settings.ts b/packages/ui/src/product/converter/advanced-mode/sections/group-listener-settings.ts new file mode 100644 index 0000000..8f5bee8 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/group-listener-settings.ts @@ -0,0 +1,108 @@ +import yaml from "js-yaml"; +import type { GroupListenerBinding, GroupListenerTarget } from "@subboost/core/types/config"; + +/** 分组监听端口冲突检查所需的最小状态切片 */ +export interface GroupListenerConflictState { + dnsYaml: string; + mixedPort: number; + listenerPorts: Record; + groupListeners: GroupListenerBinding[]; +} + +export function isSameGroupListenerTarget(a: GroupListenerTarget, b: GroupListenerTarget): boolean { + return a.kind === b.kind && a.id === b.id; +} + +export function findGroupListenerBinding( + bindings: GroupListenerBinding[], + target: GroupListenerTarget +): GroupListenerBinding | undefined { + return bindings.find((b) => b && b.target && isSameGroupListenerTarget(b.target, target)); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isValidPort(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; +} + +/** + * 最终生效的 mixed-port:基础 YAML 显式给出时以 YAML 为准(生成器直接透传 YAML patch), + * 否则回落到设置项 mixedPort。YAML 解析失败时同样回落(生成阶段会单独报 YAML 错误)。 + */ +export function resolveEffectiveMixedPort(dnsYaml: string, mixedPort: number): number | undefined { + const parsed = parseBaseYamlRecord(dnsYaml); + if (parsed) { + const fromYaml = parsed["mixed-port"]; + if (isValidPort(fromYaml)) return fromYaml; + if (fromYaml !== undefined) return undefined; + // 显式 YAML 中没有 mixed-port:生成结果里也不会有 + if (dnsYaml.trim()) return undefined; + } + return isValidPort(mixedPort) ? mixedPort : undefined; +} + +function parseBaseYamlRecord(dnsYaml: string): Record | null { + if (!dnsYaml.trim()) return null; + try { + const parsed = yaml.load(dnsYaml) as unknown; + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function collectBaseYamlListenerPorts(dnsYaml: string): number[] { + const parsed = parseBaseYamlRecord(dnsYaml); + if (!parsed || !Array.isArray(parsed.listeners)) return []; + return parsed.listeners + .map((l) => (isRecord(l) ? l.port : undefined)) + .filter(isValidPort); +} + +/** + * 收集除指定目标之外所有已占用端口 → 冲突来源说明。 + * 覆盖:最终生效 mixed-port(含基础 YAML 覆盖)、节点监听端口、其他分组监听、基础 YAML listeners。 + */ +export function collectUsedListenerPorts( + state: GroupListenerConflictState, + excludeTarget?: GroupListenerTarget +): Map { + const used = new Map(); + const add = (port: number | undefined, label: string) => { + if (isValidPort(port) && !used.has(port)) used.set(port, label); + }; + + add(resolveEffectiveMixedPort(state.dnsYaml, state.mixedPort), "全局 mixed-port"); + for (const port of Object.values(state.listenerPorts)) add(port, "节点监听端口"); + for (const port of collectBaseYamlListenerPorts(state.dnsYaml)) add(port, "基础和 DNS 配置中的 listeners"); + for (const binding of state.groupListeners) { + if (!binding || !binding.target) continue; + // 停用的绑定不参与生成,也不占用端口(与生成器一致) + if (binding.enabled === false) continue; + if (excludeTarget && isSameGroupListenerTarget(binding.target, excludeTarget)) continue; + add(binding.port, "其他策略组的监听端口"); + } + + return used; +} + +/** 校验端口输入;返回 null 表示可用,否则为错误文案。checkConflict=false 时只校验格式(停用的绑定不参与生成,无需无冲突) */ +export function validateGroupListenerPort( + portInput: string, + state: GroupListenerConflictState, + excludeTarget: GroupListenerTarget, + opts?: { checkConflict?: boolean } +): { port: number; error: null } | { port: null; error: string } { + const trimmed = portInput.trim(); + if (!trimmed) return { port: null, error: "请输入监听端口。" }; + const port = Number(trimmed); + if (!isValidPort(port)) return { port: null, error: "端口需为 1-65535 的整数。" }; + if (opts?.checkConflict !== false) { + const conflict = collectUsedListenerPorts(state, excludeTarget).get(port); + if (conflict) return { port: null, error: `端口 ${port} 与${conflict}冲突。` }; + } + return { port, error: null }; +} diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts index 9abb12c..3d572bf 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.test.ts @@ -133,6 +133,12 @@ vi.mock("./proxy-groups-module-card", () => ({ return null; }, })); +vi.mock("./group-advanced-settings-dialog", () => ({ + GroupAdvancedSettingsDialog: (props: any) => { + mocks.captures.settingsDialogs.push(props); + return null; + }, +})); import { ProxyGroupsCategories } from "./proxy-groups-categories"; import { generateProxyGroups } from "@subboost/core/generator/proxy-groups"; @@ -146,6 +152,7 @@ function renderCategories(overrides: Record = {}) { mocks.captures.inputs = []; mocks.captures.dropdownItems = []; mocks.captures.moduleCards = []; + mocks.captures.settingsDialogs = []; mocks.captures.customPanelRendered = false; mocks.captures.customRulesRendered = false; try { @@ -165,6 +172,7 @@ function renderCategoryTree(overrides: Record = {}) { mocks.captures.inputs = []; mocks.captures.dropdownItems = []; mocks.captures.moduleCards = []; + mocks.captures.settingsDialogs = []; mocks.captures.customPanelRendered = false; mocks.captures.customRulesRendered = false; try { @@ -193,7 +201,7 @@ describe("ProxyGroupsCategories", () => { vi.clearAllMocks(); stateMock.refOverride = undefined; mocks.confirmDialog.mockResolvedValue(true); - mocks.captures = { inputs: [], dropdownItems: [], moduleCards: [] }; + mocks.captures = { inputs: [], dropdownItems: [], moduleCards: [], settingsDialogs: [] }; mocks.store = { ruleProviderBaseUrl: "https://rules.example/base/", nodes: [], @@ -231,6 +239,11 @@ describe("ProxyGroupsCategories", () => { proxyGroupAdvancedModeEnabled: false, setProxyGroupAdvancedModeEnabled: vi.fn(), updateProxyGroupAdvanced: vi.fn(), + groupListeners: [], + setGroupListener: vi.fn(), + dnsYaml: "", + mixedPort: 7890, + listenerPorts: {}, }; }); @@ -419,24 +432,29 @@ describe("ProxyGroupsCategories", () => { expect(card.hiddenPresetRuleIds.auto).toContain("auto-rule"); expect(card.hiddenPresetRuleIds.auto).toContain("moved-rule"); - card.onChangeGroupType({ groupType: "load-balance" }); + // 类型/监听改动统一走高级设置弹窗 + card.onOpenAdvancedSettings(); + expect(stateMock.setters[4]).toHaveBeenCalledWith("auto"); + + renderCategories({ 0: new Set(["core"]), 4: "auto" }); + const settingsDialog = mocks.captures.settingsDialogs[0]; + expect(settingsDialog.groupName).toBe("Auto Override"); + settingsDialog.onSave({ groupType: "load-balance", strategy: "round-robin", listener: null }); expect(mocks.store.updateProxyGroupAdvanced).toHaveBeenCalledWith("auto", { groupType: "load-balance", strategy: "round-robin", }); - card.onChangeGroupType({ groupType: "fallback", strategy: "consistent-hashing" }); + expect(mocks.store.setGroupListener).toHaveBeenCalledWith({ kind: "module", id: "auto" }, null); + + settingsDialog.onSave({ groupType: "fallback", listener: { port: 7891, enabled: true, allowLan: false } }); expect(mocks.store.updateProxyGroupAdvanced).toHaveBeenCalledWith("auto", { groupType: "fallback", strategy: undefined, }); - - mocks.store.proxyGroupAdvanced = { auto: {} }; - renderCategories({ 0: new Set(["core"]) }); - mocks.captures.moduleCards[0].onChangeGroupType({ groupType: "load-balance" }); - expect(mocks.store.updateProxyGroupAdvanced).toHaveBeenCalledWith("auto", { - groupType: "load-balance", - strategy: "round-robin", - }); + expect(mocks.store.setGroupListener).toHaveBeenCalledWith( + { kind: "module", id: "auto" }, + { port: 7891, enabled: true, allowLan: false } + ); const advancedElement = card.renderAdvancedContent(React.createElement("div", null, "rules"), 2); expect(advancedElement.props.target).toEqual({ kind: "module", id: "auto", name: "Auto Override" }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx index 3642d3c..9d86e7f 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-categories.tsx @@ -36,6 +36,8 @@ import { ProxyGroupsCustomGroupsPanel } from "./proxy-groups-custom-groups-panel import { ProxyGroupsCustomRoutingRules } from "./proxy-groups-custom-routing-rules"; import { ProxyGroupAdvancedPanel } from "./proxy-group-advanced-panel"; import { ProxyGroupsModuleCard } from "./proxy-groups-module-card"; +import { GroupAdvancedSettingsDialog } from "./group-advanced-settings-dialog"; +import { findGroupListenerBinding } from "./group-listener-settings"; const PROXY_GROUP_SECTION_LABEL_ROW_CLASS = "flex min-h-7 items-center gap-2"; const PROXY_GROUP_SECTION_LABEL_CLASS = "text-xs text-white/50"; @@ -78,6 +80,11 @@ export function ProxyGroupsCategories() { setProxyGroupAdvancedModeEnabled, updateProxyGroupAdvanced, dialerProxyGroups = [], + groupListeners = [], + setGroupListener, + dnsYaml, + mixedPort, + listenerPorts = {}, } = useConfigStore(); const [expandedCategories, setExpandedCategories] = React.useState< @@ -95,6 +102,11 @@ export function ProxyGroupsCategories() { const [expandedModuleRules, setExpandedModuleRules] = React.useState< Set >(new Set()); + const [settingsModuleId, setSettingsModuleId] = React.useState(null); + const listenerConflictState = React.useMemo( + () => ({ dnsYaml, mixedPort, listenerPorts, groupListeners }), + [dnsYaml, mixedPort, listenerPorts, groupListeners] + ); React.useEffect(() => { if (didApplyCustomCategoryDefault.current || customProxyGroups.length === 0) return; didApplyCustomCategoryDefault.current = true; @@ -611,13 +623,10 @@ export function ProxyGroupsCategories() { } groupType={effectiveGroupType} strategy={advancedConfig.strategy} - onChangeGroupType={({ groupType, strategy }) => - updateProxyGroupAdvanced(module.id, { - groupType: groupType as ProxyGroupGroupType, - ...(groupType === "load-balance" - ? { strategy: strategy ?? advancedConfig.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } - : { strategy: undefined }), - }) + onOpenAdvancedSettings={() => setSettingsModuleId(module.id)} + advancedSettingsActive={ + effectiveGroupType !== module.groupType || + Boolean(findGroupListenerBinding(groupListeners, { kind: "module", id: module.id })) } advancedMode={proxyGroupAdvancedModeEnabled} nodeCount={generatedProxyGroupNodeCounts.get(display.full) ?? 0} @@ -647,6 +656,41 @@ export function ProxyGroupsCategories() { + {(() => { + const settingsModule = settingsModuleId + ? PROXY_GROUP_MODULES.find((m) => m.id === settingsModuleId) + : undefined; + if (!settingsModule) return null; + const advancedConfig = proxyGroupAdvanced[settingsModule.id] || {}; + const target = { kind: "module" as const, id: settingsModule.id }; + return ( + { + if (!open) setSettingsModuleId(null); + }} + groupName={resolveModuleDisplayName(settingsModule).full} + groupType={(advancedConfig.groupType ?? settingsModule.groupType) as ProxyGroupGroupType} + strategy={advancedConfig.strategy} + listenerTarget={target} + listenerBinding={findGroupListenerBinding(groupListeners, target)} + conflictState={listenerConflictState} + onSave={({ groupType, strategy, listener }) => { + updateProxyGroupAdvanced(settingsModule.id, { + groupType, + ...(groupType === "load-balance" + ? { strategy: strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } + : { strategy: undefined }), + }); + setGroupListener( + target, + listener ? { port: listener.port, enabled: listener.enabled, allowLan: listener.allowLan } : null + ); + }} + /> + ); + })()} + ); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts index 57b7cf9..0240d37 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel-card-props.test.ts @@ -94,6 +94,10 @@ vi.mock("./proxy-group-type-menu", () => ({ ProxyGroupTypeMenu: () => null, })); +vi.mock("./group-advanced-settings-dialog", () => ({ + GroupAdvancedSettingsDialog: () => null, +})); + vi.mock("./proxy-groups-module-card", () => ({ ProxyGroupsModuleCard: (props: any) => { mocks.cards.push(props); @@ -176,8 +180,10 @@ describe("ProxyGroupsCustomGroupsPanel card props", () => { card.onResetRuleTarget(); card.onChangeCnIpNoResolve(true); card.onChangeExperimentalCnUseCnRuleSet(true); - card.onChangeGroupType({ groupType: "load-balance", strategy: "round-robin" }); - card.onChangeGroupType({ groupType: "select" }); + // 类型/监听改动统一走高级设置弹窗(onSave 行为见 proxy-groups-custom-groups-panel.test.ts) + expect(typeof card.onOpenAdvancedSettings).toBe("function"); + expect(card.advancedSettingsActive).toBe(false); + card.onOpenAdvancedSettings(); const advanced = card.renderAdvancedContent(React.createElement("div", null, "rules"), 2) as React.ReactElement; advanced.props.onChange({ regions: ["us"] }); @@ -189,14 +195,6 @@ describe("ProxyGroupsCustomGroupsPanel card props", () => { target: { kind: "module", id: "auto" }, }); expect(mocks.store.removeCustomRule).toHaveBeenCalledWith(0); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { - groupType: "load-balance", - strategy: "round-robin", - }); - expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { - groupType: "select", - strategy: undefined, - }); expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { advanced: { sourceIds: ["old-source"], regions: ["us"] }, }); diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts index 03f1d51..fe8fd2f 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ proxyGroupAdded: vi.fn(), }, toast: vi.fn(), + confirmDialog: vi.fn(async () => true), })); const stateMock = vi.hoisted(() => ({ @@ -64,6 +65,7 @@ vi.mock("@subboost/ui/components/ui/input", () => ({ }, })); vi.mock("@subboost/ui/components/ui/toaster", () => ({ toast: mocks.toast })); +vi.mock("@subboost/ui/components/ui/confirm-dialog", () => ({ confirmDialog: mocks.confirmDialog })); vi.mock("@subboost/core/generator/proxy-groups", () => ({ PROXY_GROUP_MODULES: [ { @@ -123,6 +125,12 @@ vi.mock("./proxy-group-type-menu", () => ({ getLoadBalanceStrategyLabel: (value: string) => `strategy:${value}`, getProxyGroupTypeLabel: (value: string) => `type:${value}`, })); +vi.mock("./group-advanced-settings-dialog", () => ({ + GroupAdvancedSettingsDialog: (props: any) => { + mocks.captures.settingsDialogs.push(props); + return null; + }, +})); import { ProxyGroupsCustomGroupsPanel } from "./proxy-groups-custom-groups-panel"; @@ -160,6 +168,7 @@ function renderPanel(overrides: Record = {}) { mocks.captures.ruleRows = []; mocks.captures.manualRows = []; mocks.captures.moveMenus = []; + mocks.captures.settingsDialogs = []; try { const html = renderToStaticMarkup(React.createElement(ProxyGroupsCustomGroupsPanel)); return { html, setters: stateMock.setters }; @@ -171,7 +180,7 @@ function renderPanel(overrides: Record = {}) { describe("ProxyGroupsCustomGroupsPanel", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.captures = { buttons: [], inputs: [], typeMenus: [], ruleRows: [], manualRows: [], moveMenus: [] }; + mocks.captures = { buttons: [], inputs: [], typeMenus: [], ruleRows: [], manualRows: [], moveMenus: [], settingsDialogs: [] }; mocks.store = { ruleProviderBaseUrl: "https://rules.example/", enabledProxyGroups: ["auto"], @@ -191,6 +200,7 @@ describe("ProxyGroupsCustomGroupsPanel", () => { removeCustomRule: vi.fn(), toggleProxyGroup: vi.fn(), addModuleRules: vi.fn(), + setGroupListener: vi.fn(), }; }); @@ -253,17 +263,45 @@ describe("ProxyGroupsCustomGroupsPanel", () => { renameInput.onKeyDown({ key: "Escape" }); expect(setters[3]).toHaveBeenCalledWith(null); - renderPanel({ 0: new Set(["custom-1"]) }); - mocks.captures.typeMenus[0].onChange({ groupType: "load-balance", strategy: "round-robin" }); + renderPanel({ 0: new Set(["custom-1"]), 6: "custom-1" }); + mocks.captures.settingsDialogs[0].onSave({ + groupType: "load-balance", + strategy: "round-robin", + listener: null, + }); expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { groupType: "load-balance", strategy: "round-robin", }); + expect(mocks.store.setGroupListener).toHaveBeenCalledWith({ kind: "custom", id: "custom-1" }, null); mocks.captures.buttons.find((props: any) => props.title === "删除").onClick({ stopPropagation: vi.fn() }); expect(mocks.store.removeCustomProxyGroup).toHaveBeenCalledWith("custom-1"); }); + it("confirms and cascades listener removal when deleting a custom group with a binding", async () => { + const flushAsync = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }; + mocks.store.groupListeners = [{ id: "gl-1", target: { kind: "custom", id: "custom-1" }, port: 7891 }]; + renderPanel({ 0: new Set(["custom-1"]) }); + const deleteButton = mocks.captures.buttons.find((props: any) => props.title === "删除"); + + mocks.confirmDialog.mockResolvedValueOnce(false); + deleteButton.onClick({ stopPropagation: vi.fn() }); + await flushAsync(); + expect(mocks.confirmDialog).toHaveBeenCalledTimes(1); + expect(mocks.confirmDialog).toHaveBeenCalledWith(expect.objectContaining({ variant: "warning" })); + expect(mocks.store.removeCustomProxyGroup).not.toHaveBeenCalled(); + + mocks.confirmDialog.mockResolvedValueOnce(true); + deleteButton.onClick({ stopPropagation: vi.fn() }); + await flushAsync(); + expect(mocks.store.removeCustomProxyGroup).toHaveBeenCalledWith("custom-1"); + }); + it("moves custom rule sets to custom groups or modules", () => { renderPanel({ 0: new Set(["custom-1"]) }); expect(mocks.captures.moveMenus[0].kinds).toEqual(["module", "custom"]); @@ -368,7 +406,16 @@ describe("ProxyGroupsCustomGroupsPanel", () => { expect(stateMock.setters[4]).toHaveBeenCalledWith("🧩 Custom"); expect(stateMock.setters[5]).toHaveBeenCalledWith(""); - mocks.captures.typeMenus[0].onChange({ groupType: "select" }); + const settingsButton = mocks.captures.buttons.find( + (props: any) => props["aria-label"] === "打开 🧩 Custom 高级设置" + ); + const stopSettingsClick = vi.fn(); + settingsButton.onClick({ stopPropagation: stopSettingsClick }); + expect(stopSettingsClick).toHaveBeenCalled(); + expect(stateMock.setters[6]).toHaveBeenCalledWith("custom-1"); + + renderPanel({ 0: new Set(["custom-1"]), 6: "custom-1" }); + mocks.captures.settingsDialogs[0].onSave({ groupType: "select", listener: null }); expect(mocks.store.updateCustomProxyGroup).toHaveBeenCalledWith("custom-1", { groupType: "select", strategy: undefined, diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx index 9d159f7..2e2ff2b 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-custom-groups-panel.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { Check, Trash2 } from "lucide-react"; import { Button } from "@subboost/ui/components/ui/button"; +import { confirmDialog } from "@subboost/ui/components/ui/confirm-dialog"; import { Input } from "@subboost/ui/components/ui/input"; import { toast } from "@subboost/ui/components/ui/toaster"; import { PROXY_GROUP_MODULES, type ProxyGroupModule } from "@subboost/core/generator/proxy-groups"; @@ -34,6 +35,8 @@ import { type ProxyGroupNameDraft, } from "./proxy-group-name-editor"; import { ProxyGroupsModuleCard } from "./proxy-groups-module-card"; +import { GroupAdvancedSettingsDialog } from "./group-advanced-settings-dialog"; +import { findGroupListenerBinding } from "./group-listener-settings"; export function ProxyGroupsCustomGroupsPanel({ advancedMode = false, @@ -58,6 +61,11 @@ export function ProxyGroupsCustomGroupsPanel({ moveModuleRule, removeModuleRule, dialerProxyGroups = [], + groupListeners = [], + setGroupListener, + dnsYaml, + mixedPort, + listenerPorts = {}, } = useConfigStore(); const [expandedCustomGroups, setExpandedCustomGroups] = React.useState>(new Set()); @@ -69,7 +77,13 @@ export function ProxyGroupsCustomGroupsPanel({ const [editingCustomGroupId, setEditingCustomGroupId] = React.useState(null); const [editingCustomGroupName, setEditingCustomGroupName] = React.useState(""); const [editingCustomGroupDescription, setEditingCustomGroupDescription] = React.useState(""); + // 注意:新增 useState 追加在末尾,保持既有测试的 setter 索引稳定 + const [settingsGroupId, setSettingsGroupId] = React.useState(null); const interactions = useProductInteractionAdapter(); + const listenerConflictState = React.useMemo( + () => ({ dnsYaml, mixedPort, listenerPorts, groupListeners }), + [dnsYaml, mixedPort, listenerPorts, groupListeners] + ); const getAllGroupNamesForUniqCheck = React.useCallback(() => { const names: string[] = []; @@ -424,7 +438,26 @@ export function ProxyGroupsCustomGroupsPanel({ setEditingCustomGroupDescription(""); }} onCommitEditing={commitCustomRename} - onHide={() => removeCustomProxyGroup(group.id)} + onHide={async () => { + const listenerBinding = findGroupListenerBinding(groupListeners, { kind: "custom", id: group.id }); + if (listenerBinding) { + const ok = await confirmDialog({ + title: `确认删除「${group.name}」?`, + description: ( + + + 警告: + 该分组已绑定监听端口 {listenerBinding.port},删除分组会一并移除该监听端口。 + + + ), + confirmText: "删除", + variant: "warning", + }); + if (!ok) return; + } + removeCustomProxyGroup(group.id); + }} extraRules={[]} ruleSetsByTarget={{}} hiddenPresetRuleIds={{}} @@ -456,13 +489,10 @@ export function ProxyGroupsCustomGroupsPanel({ description={description} groupType={group.groupType as ProxyGroupTypeMenuValue} strategy={group.strategy} - onChangeGroupType={({ groupType, strategy }) => - updateCustomProxyGroup(group.id, { - groupType: groupType as ProxyGroupGroupType, - ...(groupType === "load-balance" - ? { strategy: strategy ?? group.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } - : { strategy: undefined }), - }) + onOpenAdvancedSettings={() => setSettingsGroupId(group.id)} + advancedSettingsActive={ + group.groupType !== "select" || + Boolean(findGroupListenerBinding(groupListeners, { kind: "custom", id: group.id })) } rulesContentOverride={ totalRules === 0 ? ( @@ -494,6 +524,40 @@ export function ProxyGroupsCustomGroupsPanel({ })} )} + + {(() => { + const settingsGroup = settingsGroupId + ? customProxyGroups.find((group) => group.id === settingsGroupId) + : undefined; + if (!settingsGroup) return null; + const target = { kind: "custom" as const, id: settingsGroup.id }; + return ( + { + if (!open) setSettingsGroupId(null); + }} + groupName={settingsGroup.name} + groupType={settingsGroup.groupType} + strategy={settingsGroup.strategy} + listenerTarget={target} + listenerBinding={findGroupListenerBinding(groupListeners, target)} + conflictState={listenerConflictState} + onSave={({ groupType, strategy, listener }) => { + updateCustomProxyGroup(settingsGroup.id, { + groupType: groupType as ProxyGroupGroupType, + ...(groupType === "load-balance" + ? { strategy: strategy ?? settingsGroup.strategy ?? DEFAULT_LOAD_BALANCE_STRATEGY } + : { strategy: undefined }), + }); + setGroupListener( + target, + listener ? { port: listener.port, enabled: listener.enabled, allowLan: listener.allowLan } : null + ); + }} + /> + ); + })()} ); } diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx index fd493b2..7ade25a 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-groups-module-card.tsx @@ -33,7 +33,6 @@ import { } from "./proxy-group-name-editor"; import { ProxyGroupSummary } from "./proxy-group-summary"; import { - ProxyGroupTypeMenu, getLoadBalanceStrategyLabel, getProxyGroupTypeLabel, type ProxyGroupTypeMenuValue, @@ -140,7 +139,8 @@ export function ProxyGroupsModuleCard({ description, groupType, strategy, - onChangeGroupType, + onOpenAdvancedSettings, + advancedSettingsActive = false, rulesContentOverride, rulesCountOverride, advancedMode = false, @@ -190,7 +190,8 @@ export function ProxyGroupsModuleCard({ description?: string; groupType?: ProxyGroupTypeMenuValue; strategy?: LoadBalanceStrategy; - onChangeGroupType?: (next: { groupType: ProxyGroupTypeMenuValue; strategy?: LoadBalanceStrategy }) => void; + onOpenAdvancedSettings?: () => void; + advancedSettingsActive?: boolean; rulesContentOverride?: React.ReactNode; rulesCountOverride?: number; advancedMode?: boolean; @@ -381,25 +382,27 @@ export function ProxyGroupsModuleCard({ className="pointer-events-auto" onClick={(e) => e.stopPropagation()} /> - {onChangeGroupType && ( - - - - } - /> + {onOpenAdvancedSettings && ( + )}