diff --git a/docs/release-notes.md b/docs/release-notes.md index 1f1a7b6..3e4f1b8 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,43 +1,57 @@ -# SubBoost v2.6.0 +# SubBoost v2.7.0 ## 中文 ### 更新重点 -SubBoost v2.6.0 改进了高级代理组的成员管理,并增强多种订阅格式和 ECH 参数的兼容性。自部署备份流程也更加可靠。建议 v2.5.1 用户升级。 +SubBoost v2.7.0 新增代理组监听端口和自动节点处理,并加强自部署环境的订阅导入安全、首次初始化、定时任务、更新回滚与备份可靠性。本版本也改进了多处界面交互、可访问性和订阅配置兼容性。 ### 主要变化 -- 高级代理组现在可以一键添加或移除全部节点、全部代理组,并可恢复默认成员;批量添加代理组时会自动跳过可能形成循环引用的项目。 -- 代理组成员区域会分别显示节点数和代理组数,代理组卡片的节点统计只计算真实节点,不再把 `DIRECT`、`REJECT` 或其它代理组引用计入节点数量。 -- 改进 Clash/Mihomo YAML 导入兼容性,可识别以单行对象列表书写的节点配置,并容忍这类列表中常见的缩进不一致。 -- 修复 VMess、VLESS、Trojan 和 AnyTLS 分享链接中的 ECH 查询服务器名称在转换时丢失的问题。 -- 自部署备份现在会在数据库导出失败时安全终止,不再把不完整文件当作成功备份;备份保留数量可通过 `SUBBOOST_BACKUP_RETENTION_COUNT` 调整,默认仍保留 10 份。 +- 代理组现在可以绑定独立的 mixed 监听端口,让接入该端口的流量固定通过指定代理组。默认只允许本机访问;允许局域网访问时,请务必使用防火墙阻止公网连接。 +- 新增自动节点处理,可通过节点名称正则表达式排除不需要的节点。关闭后会恢复原有节点和相关配置,不会永久删除选择。 +- 自部署管理员可以显式允许受信任的本机或局域网订阅源;默认仍会阻止本机、私有和保留地址,并继续检查重定向、响应大小和危险 URL。 +- 加强首次管理员初始化、登录限流、请求大小限制和定时任务互斥,降低未授权初始化、暴力尝试和重复任务带来的风险。 +- v2.7.0 会安装新的事务式更新器;从后续更新开始,它会先准备并验证回滚备份,候选版本失败时恢复旧镜像、配置和数据库。数据库备份失败时也不会再留下看似成功的不完整备份。 +- 修复更新后启动定时任务可能重新创建应用容器、导致短暂连接重置的问题。 +- 改进表单、弹窗、开关、密码输入、图标按钮和窄屏布局,并修复来源编辑、高级模式和代理组交互中的多处不一致。 +- 加强订阅解析、规则配置、导入错误和旧配置读取兼容性,同时更新存在安全公告的运行时依赖。 +- amd64 与 arm64 镜像现在使用原生构建环境,减少 QEMU 构建导致的偶发非法指令和长时间卡住。 ### 升级说明 -- 建议升级前备份 `/opt/subboost/.env` 和数据库,方便需要时回滚。 -- 现有 v2.5.1 自部署实例可以继续使用 `subboost update` 更新。 -- 本次升级不要求手动迁移数据库,也不要求新增环境变量;只有需要修改默认备份保留数量时,才需要设置 `SUBBOOST_BACKUP_RETENTION_COUNT`。 -- 现有订阅、模板、规则和高级代理组配置保持兼容,不会因为升级自动改写成员选择。 +- 升级前仍建议备份 `/opt/subboost/.env` 和数据库。 +- 数据库变更是增量 migration,正常升级不需要手工运行 SQL,migration 会在启动时自动执行。 +- 官方 v2.6.0 一键安装即使还没有 `LOCAL_SETUP_TOKEN`,也可以通过 v2.7.0 Compose 配置完成第一跳;首次管理员初始化在令牌缺失时仍会拒绝执行。 +- 从 v2.6.0 发起的这一跳仍由旧更新器执行,因此本次升级本身还没有新版的事务式回滚保护。升级前请务必单独备份 `/opt/subboost/.env` 和数据库;安装 v2.7.0 后的后续更新才使用新的保护流程。 +- 完全手工维护 Docker Compose 的用户:全新初始化前必须在 `.env` 中设置高熵的 `LOCAL_SETUP_TOKEN`。已经初始化的旧实例即使暂时缺少它也能启动和正常登录,但在补齐令牌前不能再次执行首次管理员初始化。 +- 自动节点处理、内网订阅源和代理组监听端口默认都不会自动开启,现有订阅、模板、规则和代理组配置不会被升级过程主动改写。 +- 代理组监听端口若允许局域网访问,将成为没有账号密码的局域网代理入口;请只在可信网络使用,并确认公网防火墙未开放对应端口。 ## English ### Highlights -SubBoost v2.6.0 improves member management for advanced proxy groups and expands compatibility with several subscription formats and ECH parameters. It also makes self-hosted backups more reliable. Users on v2.5.1 are encouraged to upgrade. +SubBoost v2.7.0 adds per-group listener ports and automatic node processing, while strengthening subscription-import security, first-time setup, scheduled jobs, update rollback, and backup reliability for self-hosted installations. It also improves interface consistency, accessibility, and subscription configuration compatibility. ### Main Changes -- Advanced proxy groups can now add or remove all nodes or all proxy groups in one action, and restore their default members. Bulk proxy-group additions automatically skip entries that could create circular references. -- Proxy-group member sections now show separate node and proxy-group counts. Node totals on proxy-group cards count real nodes only, excluding `DIRECT`, `REJECT`, and references to other proxy groups. -- Improved Clash/Mihomo YAML import compatibility for node configurations written as lists of single-line objects, including common indentation inconsistencies in those lists. -- Fixed ECH query server names being lost when converting VMess, VLESS, Trojan, and AnyTLS share links. -- Self-hosted backups now stop safely when the database export fails instead of treating an incomplete file as a successful backup. Backup retention can be adjusted with `SUBBOOST_BACKUP_RETENTION_COUNT`, while the default remains 10 backups. +- Proxy groups can now bind a dedicated mixed listener port, routing traffic received on that port through the selected group. Listeners are local-only by default; if LAN access is enabled, use a firewall to prevent public Internet access. +- Added automatic node processing with regular expressions that exclude unwanted node names. Disabling the feature restores the original nodes and related configuration instead of permanently deleting selections. +- Self-hosted administrators can explicitly allow trusted local or LAN subscription sources. Local, private, and reserved addresses remain blocked by default, with redirect, response-size, and unsafe-URL checks still enforced. +- Strengthened first-administrator setup, login throttling, request-size limits, and scheduled-job locking to reduce unauthorized setup, brute-force attempts, and overlapping jobs. +- v2.7.0 installs a new transactional updater. Starting with subsequent updates, it prepares and verifies a rollback backup before activation, then restores the previous image, configuration, and database if the candidate fails. Failed database exports are no longer left behind as apparently successful backups. +- Fixed a case where starting scheduled jobs after an update could recreate the application container and briefly reset connections. +- Improved forms, dialogs, switches, password fields, icon buttons, and narrow-screen layouts, while fixing inconsistencies in source editing, advanced mode, and proxy-group interactions. +- Improved subscription parsing, rule configuration, import errors, and compatibility with older saved configurations, and updated runtime dependencies affected by security advisories. +- amd64 and arm64 images now use native build environments, reducing intermittent illegal-instruction failures and long stalls associated with QEMU builds. ### Upgrade Notes -- Back up `/opt/subboost/.env` and the database before upgrading so rollback is easier if needed. -- Existing v2.5.1 self-hosted installations can continue to update with `subboost update`. -- This upgrade does not require a manual database migration or a new environment variable. Set `SUBBOOST_BACKUP_RETENTION_COUNT` only if you want to change the default backup retention count. -- Existing subscriptions, templates, rules, and advanced proxy-group configurations remain compatible, and upgrading does not automatically rewrite member selections. +- Back up `/opt/subboost/.env` and the database before upgrading. +- Database changes use additive migrations. Normal upgrades do not require manually running SQL, and migrations run automatically during startup. +- An official v2.6.0 one-click installation without `LOCAL_SETUP_TOKEN` can pass the v2.7.0 Compose configuration and complete the first hop. First-administrator setup still fails closed while the token is absent. +- The v2.6.0-to-v2.7.0 hop still runs through the old updater, so that upgrade itself does not yet have the new transactional rollback protection. Back up `/opt/subboost/.env` and the database separately before upgrading. Subsequent updates after v2.7.0 is installed use the new protected flow. +- For manually maintained Docker Compose deployments, set a high-entropy `LOCAL_SETUP_TOKEN` in `.env` before a fresh setup. An already-initialized legacy instance can still boot and use normal login without it, but first-administrator setup remains unavailable until the token is configured. +- Automatic node processing, local-network subscription sources, and per-group listener ports remain disabled until explicitly enabled. Existing subscriptions, templates, rules, and proxy-group settings are not automatically rewritten during the upgrade. +- Enabling LAN access for a proxy-group listener creates a LAN proxy port without a username or password. Use it only on a trusted network and make sure the corresponding port is not exposed through the public firewall. diff --git a/local/docker-compose.image.yml b/local/docker-compose.image.yml index f582ac5..9382478 100644 --- a/local/docker-compose.image.yml +++ b/local/docker-compose.image.yml @@ -27,7 +27,7 @@ services: ENCRYPTION_KEY: ${ENCRYPTION_KEY:?set ENCRYPTION_KEY} JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET} CRON_SECRET: ${CRON_SECRET:?set CRON_SECRET} - LOCAL_SETUP_TOKEN: ${LOCAL_SETUP_TOKEN:?set LOCAL_SETUP_TOKEN} + LOCAL_SETUP_TOKEN: ${LOCAL_SETUP_TOKEN:-} TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false} APP_URL: ${APP_URL:-http://localhost:3000} diff --git a/local/docker-compose.yml b/local/docker-compose.yml index 01584a0..f3ad049 100644 --- a/local/docker-compose.yml +++ b/local/docker-compose.yml @@ -34,7 +34,7 @@ services: ENCRYPTION_KEY: ${ENCRYPTION_KEY:?set ENCRYPTION_KEY} JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET} CRON_SECRET: ${CRON_SECRET:?set CRON_SECRET} - LOCAL_SETUP_TOKEN: ${LOCAL_SETUP_TOKEN:?set LOCAL_SETUP_TOKEN} + LOCAL_SETUP_TOKEN: ${LOCAL_SETUP_TOKEN:-} TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false} APP_URL: ${APP_URL:-http://localhost:3000} APP_RELEASE_VERSION: ${APP_RELEASE_VERSION:-} diff --git a/local/scripts/selfhost-lifecycle-shell.test.ts b/local/scripts/selfhost-lifecycle-shell.test.ts index b6bb1c8..75e3cfa 100644 --- a/local/scripts/selfhost-lifecycle-shell.test.ts +++ b/local/scripts/selfhost-lifecycle-shell.test.ts @@ -138,10 +138,19 @@ describe("self-host update rollback lifecycle", () => { expect(installer).not.toContain("?setup-token="); }); + it("allows the legacy v2.6.0 environment through first-hop Compose interpolation", () => { + // The v2.6.0 updater evaluates the candidate Compose file before the + // candidate manager can add LOCAL_SETUP_TOKEN to an initialized .env. + for (const file of ["local/docker-compose.yml", "local/docker-compose.image.yml"]) { + const compose = readFileSync(path.join(publicRoot, file), "utf8"); + expect(compose).toContain("LOCAL_SETUP_TOKEN: ${LOCAL_SETUP_TOKEN:-}"); + expect(compose).not.toContain("LOCAL_SETUP_TOKEN: ${LOCAL_SETUP_TOKEN:?"); + } + }); + it("propagates cron endpoint failures to the container restart policy", () => { for (const file of ["local/docker-compose.yml", "local/docker-compose.image.yml"]) { const compose = readFileSync(path.join(publicRoot, file), "utf8"); - expect(compose).toContain("LOCAL_SETUP_TOKEN: ${LOCAL_SETUP_TOKEN:?set LOCAL_SETUP_TOKEN}"); expect(compose).toContain("curl -fsS"); expect(compose).toContain("failed=1"); expect(compose).toContain("exit 1"); diff --git a/package-lock.json b/package-lock.json index 645671f..4532034 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "subboost", - "version": "2.6.0", + "version": "2.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "subboost", - "version": "2.6.0", + "version": "2.7.0", "license": "AGPL-3.0-only", "workspaces": [ "packages/*", diff --git a/package.json b/package.json index 76a2232..8afdb90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subboost", - "version": "2.6.0", + "version": "2.7.0", "license": "AGPL-3.0-only", "private": true, "engines": { 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/components/ui/form-controls.test.ts b/packages/ui/src/components/ui/form-controls.test.ts index ea3b6f4..6823e5e 100644 --- a/packages/ui/src/components/ui/form-controls.test.ts +++ b/packages/ui/src/components/ui/form-controls.test.ts @@ -18,6 +18,19 @@ import { TableRow, } from "./table"; +function getRootClassNames(html: string) { + const className = html.match(/^<[^>]+ class="([^"]*)"/)?.[1]; + expect(className).toBeDefined(); + return className?.split(/\s+/) ?? []; +} + +function hasElementWithClasses(html: string, ...expectedClasses: string[]) { + return [...html.matchAll(/class="([^"]*)"/g)].some(([, className]) => { + const classes = className.split(/\s+/); + return expectedClasses.every((expectedClass) => classes.includes(expectedClass)); + }); +} + describe("composed form controls", () => { it("associates labels, descriptions, errors, required state, and existing IDs", () => { const html = renderToStaticMarkup( @@ -29,6 +42,7 @@ describe("composed form controls", () => { description: "用于登录", error: "不能为空", required: true, + className: "mt-4", }, React.createElement(Input, { "aria-describedby": "external-help" }) ) @@ -40,6 +54,9 @@ describe("composed form controls", () => { expect(html).toContain('aria-invalid="true"'); expect(html).toContain('aria-required="true"'); expect(html).toContain('role="alert"'); + expect(getRootClassNames(html)).toEqual( + expect.arrayContaining(["grid", "gap-3", "mt-4"]) + ); expect(html.indexOf("账号")).toBeLessThan(html.indexOf(" { expect(descriptionIndex).toBeGreaterThan(labelIndex); expect(controlIndex).toBeGreaterThan(descriptionIndex); expect(html).toContain('aria-describedby="exclude-regex-description"'); + expect(getRootClassNames(html)).toEqual(expect.arrayContaining(["grid", "gap-3"])); + expect(hasElementWithClasses(html, "grid", "gap-1")).toBe(true); }); it("renders a disabled, labelled switch with its description", () => { diff --git a/packages/ui/src/components/ui/form-field.tsx b/packages/ui/src/components/ui/form-field.tsx index 96f458a..2949970 100644 --- a/packages/ui/src/components/ui/form-field.tsx +++ b/packages/ui/src/components/ui/form-field.tsx @@ -66,9 +66,9 @@ function FormField({ ) : null; return ( -
+
{descriptionPlacement === "before-control" && descriptionElement ? ( -
+
{labelElement} {descriptionElement}
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..76e4b4c --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/group-advanced-settings-dialog.test.ts @@ -0,0 +1,266 @@ +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/switch-field", () => ({ + SwitchField: (props: any) => { + mocks.captures.switchFields.push(props); + return React.createElement("div", null, props.label, props.description ?? null); + }, +})); +vi.mock("./proxy-group-type-menu", () => ({ + ProxyGroupTypeMenu: (props: any) => { + mocks.captures.typeMenus.push(props); + return React.createElement("div", 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: [], + switchFields: [], + typeMenus: [], + }; + 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("reuses the nested proxy-group type menu for load-balance strategies", () => { + renderDialog(); + expect(mocks.captures.formFields.map((f: any) => f.label)).toEqual(["代理组类型"]); + expect(mocks.captures.typeMenus[0]).toEqual( + expect.objectContaining({ + value: "select", + strategy: "consistent-hashing", + showStrategyLabel: true, + }) + ); + + mocks.captures.typeMenus[0].onChange({ + groupType: "load-balance", + strategy: "round-robin", + }); + expect(stateMock.setters[0]).toHaveBeenCalledWith("load-balance"); + expect(stateMock.setters[1]).toHaveBeenCalledWith("round-robin"); + }); + + it("delegates field spacing to the shared FormField default", () => { + renderDialog({ 2: true, 3: "7891" }); + expect( + mocks.captures.formFields.map((field: any) => ({ + label: field.label, + className: field.className, + })) + ).toEqual([ + { label: "代理组类型", className: undefined }, + { label: "端口", className: undefined }, + ]); + }); + + 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"); + expect(mocks.captures.switchFields.map((field: any) => field.label)).toEqual([ + "监听端口", + "允许其他设备访问", + ]); + + 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..4584253 --- /dev/null +++ b/packages/ui/src/product/converter/advanced-mode/sections/group-advanced-settings-dialog.tsx @@ -0,0 +1,181 @@ +"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 { SwitchField } from "@subboost/ui/components/ui/switch-field"; +import { + DEFAULT_LOAD_BALANCE_STRATEGY, + type GroupListenerBinding, + type GroupListenerTarget, + type LoadBalanceStrategy, + type ProxyGroupGroupType, +} from "@subboost/core/types/config"; +import { ProxyGroupTypeMenu } from "./proxy-group-type-menu"; +import { + validateGroupListenerPort, + type GroupListenerConflictState, +} from "./group-listener-settings"; + +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} · 高级设置 + + +
+ + { + setDraftType(next.groupType); + if (next.strategy) setDraftStrategy(next.strategy); + }} + /> + + +
+ + + {(listenerOn || portInput.trim() !== "") && ( +
+ + setPortInput(event.target.value)} + /> + + + + {allowLan && ( +
+ 开启后监听 0.0.0.0,如果你的端口能从公网访问,任何人都可以使用你的节点。 +
+ )} +
+ )} +
+
+ + + + + +
+
+ ); +} 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-group-advanced-panel-interactions.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel-interactions.test.ts index e803c43..ec8a1a6 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel-interactions.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel-interactions.test.ts @@ -5,6 +5,7 @@ import type { ParsedNode } from "@subboost/core/types/node"; const mocks = vi.hoisted(() => ({ draggingKey: null as string | null, + formFields: [] as any[], generatedProxyGroups: [] as Array<{ name: string; proxies: string[] }>, stateSetters: [] as Array>, store: {} as Record, @@ -46,7 +47,10 @@ vi.mock("@subboost/ui/components/ui/button", () => ({ })); vi.mock("@subboost/ui/components/ui/form-field", () => ({ - FormField: (props: any) => React.createElement("div", null, props.children), + FormField: (props: any) => { + mocks.formFields.push(props); + return React.createElement("div", null, props.children); + }, })); vi.mock("@subboost/ui/components/ui/choice-group", () => ({ @@ -121,6 +125,7 @@ describe("ProxyGroupAdvancedPanel interactions", () => { vi.clearAllMocks(); mocks.confirmDialog.mockResolvedValue(true); mocks.draggingKey = "node:US Source"; + mocks.formFields = []; mocks.generatedProxyGroups = [ { name: "Media", proxies: ["DIRECT", "US Source", "Japan Source"] }, { name: "Select", proxies: ["US Source"] }, @@ -153,6 +158,30 @@ describe("ProxyGroupAdvancedPanel interactions", () => { }; }); + it("delegates regex field spacing to the shared FormField default", () => { + const tree = ProxyGroupAdvancedPanel({ + target: { kind: "custom", id: "media", name: "Media" }, + advanced: {}, + onChange: vi.fn(), + rulesCount: 0, + rulesContent: null, + }); + + flattenElements(tree); + + expect( + mocks.formFields.map((field) => ({ + label: React.isValidElement(field.label) + ? (field.label.props as { children?: React.ReactNode }).children + : field.label, + className: field.className, + })), + ).toEqual([ + { label: "包含正则(可选)", className: undefined }, + { label: "排除正则(可选)", className: undefined }, + ]); + }); + it("fires native source, region, member, and drag callbacks", () => { const onChange = vi.fn(); const tree = ProxyGroupAdvancedPanel({ diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx index 73497f7..aac3fef 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.tsx @@ -507,7 +507,6 @@ export function ProxyGroupAdvancedPanel({
包含正则(可选)} - className="space-y-1" > 排除正则(可选)} - className="space-y-1" > ({ 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 && ( + )}