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 5559699..0d6f41a 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 @@ -173,7 +173,7 @@ describe("ProxyGroupAdvancedPanel interactions", () => { expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ memberOrder: expect.any(Array) })); expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ - excludedMembers: expect.arrayContaining([expect.objectContaining({ kind: "direct" })]), + excludedMembers: expect.arrayContaining([expect.objectContaining({ name: "US Source" })]), }), ); expect(onChange).toHaveBeenCalledWith( @@ -181,10 +181,85 @@ describe("ProxyGroupAdvancedPanel interactions", () => { extraMembers: expect.arrayContaining([expect.objectContaining({ kind: "reject" })]), }), ); - expect(mocks.stateSetters[0]).toHaveBeenCalledWith("direct:DIRECT"); + expect(mocks.stateSetters[0]).toHaveBeenCalledWith("node:US Source"); expect(mocks.stateSetters[0]).toHaveBeenCalledWith(null); }); + it("clears enabled nodes from advanced member config", () => { + const onChange = vi.fn(); + const tree = ProxyGroupAdvancedPanel({ + target: { kind: "custom", id: "media", name: "Media" }, + advanced: { + extraMembers: [ + { kind: "node", name: "Japan Source" }, + { kind: "node", name: "Extra Node" }, + ], + excludedMembers: [{ kind: "reject" }], + memberOrder: [ + { kind: "direct" }, + { kind: "node", name: "Extra Node" }, + { kind: "node", name: "Japan Source" }, + ], + }, + onChange, + rulesCount: 0, + rulesContent: null, + }); + const clearButton = flattenElements(tree).find( + (element) => element.type === "button" && element.props.title === "清空节点", + ); + + expect(clearButton?.props.disabled).toBe(false); + clearButton?.props.onClick(); + + expect(onChange).toHaveBeenCalledWith({ + extraMembers: [{ kind: "node", name: "Extra Node" }], + excludedMembers: [ + { kind: "reject" }, + { kind: "node", name: "US Source" }, + { kind: "node", name: "Japan Source" }, + ], + memberOrder: [{ kind: "direct" }, { kind: "node", name: "Extra Node" }], + }); + }); + + it("clears enabled rule groups from advanced member config", () => { + const onChange = vi.fn(); + const tree = ProxyGroupAdvancedPanel({ + target: { kind: "custom", id: "media", name: "Media" }, + advanced: { + extraMembers: [ + { kind: "direct" }, + { kind: "node", name: "Extra Node" }, + ], + excludedMembers: [{ kind: "reject" }], + memberOrder: [ + { kind: "direct" }, + { kind: "node", name: "US Source" }, + { kind: "node", name: "Extra Node" }, + ], + }, + onChange, + rulesCount: 0, + rulesContent: null, + }); + const clearButton = flattenElements(tree).find( + (element) => element.type === "button" && element.props.title === "清空规则组", + ); + + expect(clearButton?.props.disabled).toBe(false); + clearButton?.props.onClick(); + + expect(onChange).toHaveBeenCalledWith({ + extraMembers: [{ kind: "node", name: "Extra Node" }], + excludedMembers: [{ kind: "reject" }, { kind: "direct" }], + memberOrder: [ + { kind: "node", name: "US Source" }, + { kind: "node", name: "Extra Node" }, + ], + }); + }); + it("ignores member drops without a real move target", () => { const onChange = vi.fn(); mocks.draggingKey = null; @@ -203,7 +278,7 @@ describe("ProxyGroupAdvancedPanel interactions", () => { vi.clearAllMocks(); mocks.stateSetters = []; - mocks.draggingKey = "direct:DIRECT"; + mocks.draggingKey = "node:US Source"; tree = ProxyGroupAdvancedPanel({ target: { kind: "custom", id: "media", name: "Media" }, advanced: {}, diff --git a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.test.ts b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.test.ts index 8c31f28..4983048 100644 --- a/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.test.ts +++ b/packages/ui/src/product/converter/advanced-mode/sections/proxy-group-advanced-panel.test.ts @@ -120,6 +120,8 @@ describe("ProxyGroupAdvancedPanel", () => { expect(html).not.toContain("剩余流量"); expect(html).toContain("US Source"); expect(html).toContain("DIRECT"); + expect(html).toContain("已启用规则组"); + expect(html).toContain("未启用规则组"); expect(html).toContain("rules-content"); expect(html).toContain("还没有分流规则"); expect(html).toContain("max-h-52 space-y-1.5 overflow-y-auto pr-1 custom-scrollbar"); @@ -159,7 +161,8 @@ describe("ProxyGroupAdvancedPanel", () => { ); expect(html).toContain("暂无可匹配的导入源"); - expect(html).toContain("暂无已启用的节点或代理组"); + expect(html).toContain("暂无已启用的节点"); + expect(html).toContain("暂无已启用的规则组"); expect(html).toContain("DIRECT"); expect(html).toContain("REJECT"); expect(html).toContain("existing-rules"); @@ -196,7 +199,7 @@ describe("ProxyGroupAdvancedPanel", () => { expect(html).toContain("#1 订阅链接"); expect(html).toContain("#2 YAML 配置"); expect(html).toContain("#3 节点链接"); - expect(html).toContain("暂无已启用的节点或代理组"); + expect(html).toContain("暂无已启用的节点"); }); it("previews enabled members for a disabled built-in proxy group", () => { @@ -220,7 +223,7 @@ describe("ProxyGroupAdvancedPanel", () => { expect(html).toContain("US Source"); expect(html).toContain("Japan Source"); expect(html).toContain("max-h-52 overflow-y-auto pr-1 custom-scrollbar flex flex-wrap gap-1.5"); - expect(html).not.toContain("暂无已启用的节点或代理组"); + expect(html).not.toContain("暂无已启用的节点"); }); it("previews enabled members for a disabled custom proxy group", () => { @@ -245,7 +248,7 @@ describe("ProxyGroupAdvancedPanel", () => { expect(html).toContain("US Source"); expect(html).toContain("Japan Source"); - expect(html).not.toContain("暂无已启用的节点或代理组"); + expect(html).not.toContain("暂无已启用的节点"); }); it("normalizes advanced member helpers without rendering the panel", () => { 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 a635214..603365a 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 @@ -106,6 +106,15 @@ export function withMember(list: readonly ProxyGroupMemberRef[] | undefined, mem return [...withoutMember(list, key), member]; } +function isNodeMember(member: ResolvedMember): boolean { + return member.kind === "node"; +} + +function isRuleGroupMember(member: ResolvedMember): boolean { + // 规则组区域承载所有非节点成员,包括内置/自定义组以及 DIRECT/REJECT。 + return member.kind !== "node"; +} + const PROTECTED_INSERT_KEYS = new Set([ "direct:DIRECT", "reject:REJECT", @@ -275,6 +284,17 @@ export function ProxyGroupAdvancedPanel({ return candidateMembers.filter((member) => !included.has(member.key)); }, [candidateMembers, includedMembers]); + const includedNodeMembers = React.useMemo(() => includedMembers.filter(isNodeMember), [includedMembers]); + const includedRuleGroupMembers = React.useMemo( + () => includedMembers.filter(isRuleGroupMember), + [includedMembers], + ); + const excludedNodeMembers = React.useMemo(() => excludedMembers.filter(isNodeMember), [excludedMembers]); + const excludedRuleGroupMembers = React.useMemo( + () => excludedMembers.filter(isRuleGroupMember), + [excludedMembers], + ); + const sourceOptions = React.useMemo(() => { const sourceIdsInNodes = new Set(); for (const node of activeNodes) { @@ -330,6 +350,110 @@ export function ProxyGroupAdvancedPanel({ [excludedRefs, extraRefs, includedMembers, onChange], ); + const clearMembers = React.useCallback((members: ResolvedMember[]) => { + if (members.length === 0) return; + + const includedKeys = new Set(members.map((member) => member.key)); + const nextExcludedMembers = [...excludedRefs]; + const excludedKeys = new Set(excludedRefs.map(getProxyGroupMemberKey)); + + for (const member of members) { + if (excludedKeys.has(member.key)) continue; + excludedKeys.add(member.key); + nextExcludedMembers.push(member.ref); + } + + onChange({ + // 清空当前列表时同步清掉手动加入和排序,避免旧配置把成员重新带回。 + extraMembers: extraRefs.filter( + (member) => !includedKeys.has(getProxyGroupMemberKey(member)), + ), + excludedMembers: nextExcludedMembers, + memberOrder: normalizeList(advanced.memberOrder).filter( + (member) => !includedKeys.has(getProxyGroupMemberKey(member)), + ), + }); + }, [advanced.memberOrder, excludedRefs, extraRefs, onChange]); + + const clearIncludedNodeMembers = React.useCallback(() => { + clearMembers(includedNodeMembers); + }, [clearMembers, includedNodeMembers]); + + const clearIncludedRuleGroupMembers = React.useCallback(() => { + clearMembers(includedRuleGroupMembers); + }, [clearMembers, includedRuleGroupMembers]); + + const renderIncludedMembers = (members: ResolvedMember[], emptyMessage: string) => ( + members.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( +
+ {members.map((member) => ( +
setDraggingKey(member.key)} + onDragOver={(event) => event.preventDefault()} + onDrop={() => { + if (draggingKey) moveMember(draggingKey, member.key); + setDraggingKey(null); + }} + onDragEnd={() => setDraggingKey(null)} + className={cn( + "grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded border border-white/10 bg-white/[0.04] px-2 py-1.5 text-xs", + draggingKey === member.key && "opacity-50", + )} + > + + + +
+
+ {memberLabel(member)} +
+
{memberKindLabel(member)}
+
+ +
+ ))} +
+ ) + ); + + const renderExcludedMembers = (members: ResolvedMember[], emptyMessage: string) => ( + members.length === 0 ? ( +
{emptyMessage}
+ ) : ( +
+ {members.map((member) => { + return ( + + ); + })} +
+ ) + ); + return (
@@ -407,79 +531,54 @@ export function ProxyGroupAdvancedPanel({
已启用节点
- {includedMembers.length} 个 + + {includedNodeMembers.length} 个
- {includedMembers.length === 0 ? ( -
- 暂无已启用的节点或代理组 + {renderIncludedMembers(includedNodeMembers, "暂无已启用的节点")} + +
+
+
未启用节点
+ {excludedNodeMembers.length} 个
- ) : ( -
- {includedMembers.map((member) => ( -
setDraggingKey(member.key)} - onDragOver={(event) => event.preventDefault()} - onDrop={() => { - if (draggingKey) moveMember(draggingKey, member.key); - setDraggingKey(null); - }} - onDragEnd={() => setDraggingKey(null)} - className={cn( - "grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 rounded border border-white/10 bg-white/[0.04] px-2 py-1.5 text-xs", - draggingKey === member.key && "opacity-50", - )} - > - - - -
-
- {memberLabel(member)} -
-
{memberKindLabel(member)}
-
- -
- ))} + {renderExcludedMembers(excludedNodeMembers, "暂无未启用的节点")} +
+ +
+
+
已启用规则组
+ + {includedRuleGroupMembers.length} 个
- )} + {renderIncludedMembers(includedRuleGroupMembers, "暂无已启用的规则组")} +
-
未启用节点
- {excludedMembers.length} 个 +
未启用规则组
+ {excludedRuleGroupMembers.length} 个
- {excludedMembers.length === 0 ? ( -
暂无未启用的节点或代理组
- ) : ( -
- {excludedMembers.map((member) => { - return ( - - ); - })} -
- )} + {renderExcludedMembers(excludedRuleGroupMembers, "暂无未启用的规则组")}
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 6d23bb1..1fa767b 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 @@ -408,7 +408,7 @@ describe("ProxyGroupsCategories", () => { renderCategories({ 0: new Set(["core"]) }); const card = mocks.captures.moduleCards[0]; - expect(card.nodeCount).toBe(2); + expect(card.memberStats).toEqual({ nodeCount: 1, ruleSetCount: 1 }); expect(card.extraRules).toEqual([ { id: "set-1", name: "Set 1", behavior: "domain", path: "geosite/set-1.mrs", noResolve: true }, ]); @@ -470,7 +470,7 @@ describe("ProxyGroupsCategories", () => { renderCategories({ 0: new Set(["core"]) }); - expect(mocks.captures.moduleCards[0].nodeCount).toBe(0); + expect(mocks.captures.moduleCards[0].memberStats).toEqual({ nodeCount: 0, ruleSetCount: 0 }); expect(mocks.captures.moduleCards[0].extraRules).toEqual([]); }); 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 96b8c29..d7655d4 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 @@ -33,12 +33,26 @@ import { 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 { ProxyGroupsModuleCard, type ProxyGroupMemberStats } from "./proxy-groups-module-card"; 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"; const CUSTOM_CATEGORY_ID = "custom"; +function countProxyGroupMembers(proxies: unknown, nodeNames: Set): ProxyGroupMemberStats { + if (!Array.isArray(proxies)) return { nodeCount: 0, ruleSetCount: 0 }; + + let nodeCount = 0; + let ruleSetCount = 0; + for (const proxyName of proxies) { + if (typeof proxyName !== "string" || !proxyName.trim()) continue; + // 生成后的 proxies 中既可能是真实节点,也可能是内置/自定义规则组;卡片摘要需要拆开显示。 + if (nodeNames.has(proxyName)) nodeCount += 1; + else ruleSetCount += 1; + } + return { nodeCount, ruleSetCount }; +} + export function ProxyGroupsCategories() { const { ruleProviderBaseUrl, @@ -157,8 +171,9 @@ export function ProxyGroupsCategories() { } return grouped; }, [hiddenProxyGroups]); - const generatedProxyGroupNodeCounts = React.useMemo(() => { - if (nodes.length === 0) return new Map(); + const generatedProxyGroupMemberStats = React.useMemo(() => { + if (nodes.length === 0) return new Map(); + const nodeNames = new Set(nodes.map((node) => node.name)); const generated = generateProxyGroups({ nodes, enabledModules: enabledProxyGroups, @@ -174,7 +189,7 @@ export function ProxyGroupsCategories() { return new Map( generated.map((group) => [ group.name, - Array.isArray(group.proxies) ? group.proxies.length : 0, + countProxyGroupMembers(group.proxies, nodeNames), ]), ); }, [ @@ -603,7 +618,7 @@ export function ProxyGroupsCategories() { }) } advancedMode={proxyGroupAdvancedModeEnabled} - nodeCount={generatedProxyGroupNodeCounts.get(display.full) ?? 0} + memberStats={generatedProxyGroupMemberStats.get(display.full)} renderAdvancedContent={(rulesContent, rulesCount) => ( )}
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 62bae9f..c3566b3 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 @@ -146,7 +146,7 @@ describe("ProxyGroupsCustomGroupsPanel card props", () => { renderToStaticMarkup( React.createElement(ProxyGroupsCustomGroupsPanel, { advancedMode: true, - nodeCounts: new Map([["C Custom", 3]]), + memberStats: new Map([["C Custom", { nodeCount: 2, ruleSetCount: 1 }]]), }), ); @@ -154,7 +154,7 @@ describe("ProxyGroupsCustomGroupsPanel card props", () => { expect(card).toMatchObject({ advancedMode: true, isEnabled: false, - nodeCount: 3, + memberStats: { nodeCount: 2, ruleSetCount: 1 }, rulesCountOverride: 2, }); 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 8c7b384..a3d8da3 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 @@ -36,14 +36,14 @@ import { toProxyGroupNameDraft, type ProxyGroupNameDraft, } from "./proxy-group-name-editor"; -import { ProxyGroupsModuleCard } from "./proxy-groups-module-card"; +import { ProxyGroupsModuleCard, type ProxyGroupMemberStats } from "./proxy-groups-module-card"; export function ProxyGroupsCustomGroupsPanel({ advancedMode = false, - nodeCounts, + memberStats, }: { advancedMode?: boolean; - nodeCounts?: Map; + memberStats?: Map; }) { const { enabledProxyGroups, @@ -253,7 +253,7 @@ export function ProxyGroupsCustomGroupsPanel({ ); const totalRules = groupRuleSets.length + manualRules.length; const description = group.description?.trim() || "自定义代理组"; - const nodeCount = nodeCounts?.get(group.name) ?? 0; + const groupMemberStats = memberStats?.get(group.name); const toggleExpand = () => { setExpandedCustomGroups((prev) => { @@ -424,7 +424,7 @@ export function ProxyGroupsCustomGroupsPanel({ } rulesCountOverride={totalRules} advancedMode={advancedMode} - nodeCount={nodeCount} + memberStats={groupMemberStats} renderAdvancedContent={(content, count) => ( { expect(html).toContain("Gemini Display"); expect(html).toContain("AI description"); - expect(html).toContain("3 规则"); + expect(html).toContain("3 分流规则"); expect(html).toContain("Gemini 分流说明"); expect(html).toContain("rules-panel"); expect(mocks.panels[0]).toEqual(expect.objectContaining({ module: baseModule, cnIpNoResolve: true })); @@ -271,15 +271,18 @@ describe("ProxyGroupsModuleCard", () => { props({ module: { ...baseModule, id: "manual", description: "手动选择代理节点" }, display: { full: "手动选择" }, + memberStats: { nodeCount: 1, ruleSetCount: 1 }, }) ) ); expect(html).toContain("手动选择代理节点"); expect(html).toContain('text-indigo-300">手动选择代理节点'); - expect(html).toContain('text-emerald-300">3 规则'); + expect(html).toContain('text-emerald-300">3 分流规则'); + expect(html).toContain("1 节点"); + expect(html).toContain("1 规则集"); expect(html).not.toContain('title="手动选择代理节点'); - expect(html).not.toContain('title="3 规则'); + expect(html).not.toContain('title="3 分流规则'); const overrideHtml = renderToStaticMarkup( React.createElement( @@ -305,7 +308,7 @@ describe("ProxyGroupsModuleCard", () => { }) ) ); - expect(emptyDescriptionHtml).toContain("0 规则"); + expect(emptyDescriptionHtml).toContain("0 分流规则"); expect(emptyDescriptionHtml).not.toContain("AI description"); }); }); 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 cc08d63..828bfdd 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,6 +33,11 @@ import { type ProxyGroupTypeMenuValue, } from "./proxy-group-type-menu"; +export type ProxyGroupMemberStats = { + nodeCount: number; + ruleSetCount: number; +}; + function ModuleHintPopover({ moduleId }: { moduleId: string }) { const isGemini = moduleId === "gemini"; const label = isGemini ? "Gemini 分流说明" : "谷歌学术分流说明"; @@ -141,7 +146,7 @@ export function ProxyGroupsModuleCard({ rulesContentOverride, rulesCountOverride, advancedMode = false, - nodeCount = 0, + memberStats = { nodeCount: 0, ruleSetCount: 0 }, renderAdvancedContent, }: { module: ProxyGroupModule; @@ -191,7 +196,7 @@ export function ProxyGroupsModuleCard({ rulesContentOverride?: React.ReactNode; rulesCountOverride?: number; advancedMode?: boolean; - nodeCount?: number; + memberStats?: ProxyGroupMemberStats; renderAdvancedContent?: (rulesContent: React.ReactNode, rulesCount: number) => React.ReactNode; }) { const effectiveRules = getEffectiveModuleRuleItems(module, ruleSetsByTarget, hiddenPresetRuleIds); @@ -213,8 +218,11 @@ export function ProxyGroupsModuleCard({ : getProxyGroupTypeLabel(effectiveGroupType); const summaryItems = [ { label: description ?? module.description ?? "", tone: "accent" as const }, - { label: `${totalRules} 规则`, tone: "success" as const }, - { label: `${nodeCount} 节点`, tone: "muted" as const }, + { label: `${totalRules} 分流规则`, tone: "success" as const }, + { label: `${memberStats.nodeCount} 节点`, tone: "muted" as const }, + ...(memberStats.ruleSetCount > 0 + ? [{ label: `${memberStats.ruleSetCount} 规则集`, tone: "muted" as const }] + : []), ]; const rulesContent = rulesContentOverride !== undefined ? rulesContentOverride : hasRuleManagement ? (