From 6ed0307ea9ad840f1a4285333dd13167b60292eb Mon Sep 17 00:00:00 2001 From: feiezha Date: Mon, 5 Oct 2026 10:00:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=B8=BB=E5=88=86?= =?UTF-8?q?=E7=BB=84=E9=9A=90=E8=97=8F=E8=8A=82=E7=82=B9=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/subscriptions/actions.ts | 16 ++++-- app/admin/subscriptions/page.tsx | 1 + .../subscriptions/proxy-group-editor.tsx | 57 +++++++++++++++++-- lib/store.ts | 26 ++++++++- lib/subscription.ts | 11 +++- tests/ui.spec.ts | 12 +++- tests/unit/custom-proxy.test.ts | 7 +++ tests/unit/store-custom.test.ts | 8 +++ 8 files changed, 124 insertions(+), 14 deletions(-) diff --git a/app/admin/subscriptions/actions.ts b/app/admin/subscriptions/actions.ts index f78621c..a9aabc4 100644 --- a/app/admin/subscriptions/actions.ts +++ b/app/admin/subscriptions/actions.ts @@ -31,7 +31,8 @@ export async function saveSubscriptionSettingsAction(formData: FormData) { await saveSubscriptionSettings({ template: String(formData.get("template") ?? ""), extraProxyGroups: String(formData.get("extra_proxy_groups") ?? ""), - profileName: String(formData.get("profile_name") ?? "").trim() || "FeieProxy", + profileName: + String(formData.get("profile_name") ?? "").trim() || "FeieProxy", }); revalidatePath("/admin/subscriptions"); @@ -55,7 +56,9 @@ export async function saveProxyGroupAction(formData: FormData) { type: "select", memberSlugs, } as const; - const existingIndex = settings.proxyGroups.findIndex((group) => group.id === id); + const existingIndex = settings.proxyGroups.findIndex( + (group) => group.id === id, + ); const proxyGroups = [...settings.proxyGroups]; if (existingIndex === -1) { @@ -71,7 +74,10 @@ export async function saveProxyGroupAction(formData: FormData) { export async function saveMainProxyGroupAction(formData: FormData) { await requireAdminAccess(); - await saveMainProxyGroupMemberSlugs(readUniqueMemberSlugs(formData)); + await saveMainProxyGroupMemberSlugs( + readUniqueMemberSlugs(formData), + formData.getAll("hidden_slugs").map(String), + ); revalidatePath("/admin/subscriptions"); } @@ -80,6 +86,8 @@ export async function deleteProxyGroupAction(formData: FormData) { const settings = await getSubscriptionSettings(); const id = String(formData.get("id") ?? "").trim(); - await saveProxyGroups(settings.proxyGroups.filter((group) => group.id !== id)); + await saveProxyGroups( + settings.proxyGroups.filter((group) => group.id !== id), + ); revalidatePath("/admin/subscriptions"); } diff --git a/app/admin/subscriptions/page.tsx b/app/admin/subscriptions/page.tsx index 598b072..65b17a8 100644 --- a/app/admin/subscriptions/page.tsx +++ b/app/admin/subscriptions/page.tsx @@ -88,6 +88,7 @@ export default async function AdminSubscriptionsPage() { id: "main", name: settings.profileName, memberSlugs: settings.mainProxyGroupMemberSlugs, + hiddenSlugs: settings.mainProxyGroupHiddenSlugs, }} nodes={proxyGroupNodes} /> diff --git a/app/admin/subscriptions/proxy-group-editor.tsx b/app/admin/subscriptions/proxy-group-editor.tsx index 7dff350..5b50db1 100644 --- a/app/admin/subscriptions/proxy-group-editor.tsx +++ b/app/admin/subscriptions/proxy-group-editor.tsx @@ -42,6 +42,7 @@ type ProxyGroupEditorProps = { id: string; name: string; memberSlugs: string[]; + hiddenSlugs?: string[]; }; nodes: ProxyGroupNodeOption[]; }; @@ -90,11 +91,15 @@ function SortableNode({ index, removable, onRemove, + hidden, + onToggleHidden, }: { node: ProxyGroupNodeOption; index: number; removable: boolean; onRemove: () => void; + hidden?: boolean; + onToggleHidden?: () => void; }) { const { attributes, @@ -135,6 +140,17 @@ function SortableNode({ {node.label} {node.meta} + {onToggleHidden && ( + + )} {removable && ( )} - {isMainGroup ? "保存顺序" : isEditing ? "保存分组" : "新建分组"} + {isMainGroup ? "保存主分组" : isEditing ? "保存分组" : "新建分组"} diff --git a/lib/store.ts b/lib/store.ts index 6140161..97ffd19 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -77,6 +77,7 @@ export type SubscriptionSettings = { extraProxyGroups: string; profileName: string; mainProxyGroupMemberSlugs: string[]; + mainProxyGroupHiddenSlugs: string[]; proxyGroups: ProxyGroupConfig[]; }; @@ -1193,6 +1194,9 @@ export async function getSubscriptionSettings() { mainProxyGroupMemberSlugs: JSON.parse( map.get("main_proxy_group_member_slugs_json") ?? "[]", ) as string[], + mainProxyGroupHiddenSlugs: JSON.parse( + map.get("main_proxy_group_hidden_slugs_json") ?? "[]", + ) as string[], proxyGroups: JSON.parse( map.get("proxy_groups_json") ?? "[]", ) as ProxyGroupConfig[], @@ -1234,10 +1238,13 @@ export async function saveProxyGroups(proxyGroups: ProxyGroupConfig[]) { ).run("proxy_groups_json", JSON.stringify(proxyGroups), now); } -export async function saveMainProxyGroupMemberSlugs(memberSlugs: string[]) { +export async function saveMainProxyGroupMemberSlugs( + memberSlugs: string[], + hiddenSlugs?: string[], +) { const db = await ensureDb(); const now = dbNow(); - db.prepare( + const upsert = db.prepare( ` INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) @@ -1245,7 +1252,20 @@ export async function saveMainProxyGroupMemberSlugs(memberSlugs: string[]) { value = excluded.value, updated_at = excluded.updated_at `, - ).run("main_proxy_group_member_slugs_json", JSON.stringify(memberSlugs), now); + ); + db.transaction(() => { + upsert.run( + "main_proxy_group_member_slugs_json", + JSON.stringify(memberSlugs), + now, + ); + if (hiddenSlugs !== undefined) + upsert.run( + "main_proxy_group_hidden_slugs_json", + JSON.stringify([...new Set(hiddenSlugs)]), + now, + ); + })(); } export async function listSubscriptionNodes() { diff --git a/lib/subscription.ts b/lib/subscription.ts index b2a23fe..e4e652e 100644 --- a/lib/subscription.ts +++ b/lib/subscription.ts @@ -13,6 +13,7 @@ type ClashSettings = { extraProxyGroups: string; profileName: string; mainProxyGroupMemberSlugs: string[]; + mainProxyGroupHiddenSlugs?: string[]; proxyGroups: ProxyGroupConfig[]; }; @@ -225,14 +226,20 @@ export function buildSubscriptionConfig(input: { const nodeNameBySlug = new Map( eligibleNodes.map((node) => [node.slug, getNodeProxyName(node)]), ); - const proxyNames = orderedEligibleNodes.map((node) => getNodeProxyName(node)); + const hidden = new Set(input.settings.mainProxyGroupHiddenSlugs ?? []); + const proxyNames = orderedEligibleNodes + .filter((node) => !hidden.has(node.slug)) + .map((node) => getNodeProxyName(node)); const proxiesBlock = orderedEligibleNodes .map((node) => renderSubscriptionProxy(node, authString, input.user)) .join("\n"); const defaultTemplate = input.settings.template.trimEnd() || readDefaultTemplate(); const renderedGroups = [ - renderGroupEntry(input.settings.profileName || "FeieProxy", proxyNames), + renderGroupEntry( + input.settings.profileName || "FeieProxy", + proxyNames.length ? proxyNames : ["DIRECT"], + ), ...input.settings.proxyGroups .map((group) => { const members = group.memberSlugs diff --git a/tests/ui.spec.ts b/tests/ui.spec.ts index ca9b415..e5f4563 100644 --- a/tests/ui.spec.ts +++ b/tests/ui.spec.ts @@ -78,11 +78,21 @@ test("compact management flows, drag persistence, filters and mobile layout", as await page.mouse.move(to!.x + 15, to!.y + 15, { steps: 12 }); await page.mouse.up(); await expect.poll(labels).toEqual([initial[1], initial[2], initial[0]]); - await main.getByRole("button", { name: "保存顺序", exact: true }).click(); + await main.getByRole("button", { name: "保存主分组", exact: true }).click(); await expect(main.locator(".action-row [role=status]")).toHaveText("已保存"); await page.reload(); await expect.poll(labels).toEqual([initial[1], initial[2], initial[0]]); + const hidden = main.getByRole("checkbox", { name: `主分组隐藏 ${initial[0]}`, exact: true }); + await hidden.check(); + await main.getByRole("button", { name: "保存主分组", exact: true }).click(); + await expect(main.locator(".action-row [role=status]")).toHaveText("已保存"); + await page.reload(); + await expect(hidden).toBeChecked(); + await hidden.uncheck(); + await main.getByRole("button", { name: "撤销修改" }).click(); + await expect(hidden).toBeChecked(); + const newGroup = page .locator("details") .filter({ has: page.locator("summary", { hasText: "新建分组" }) }); diff --git a/tests/unit/custom-proxy.test.ts b/tests/unit/custom-proxy.test.ts index 08e20ab..8a88715 100644 --- a/tests/unit/custom-proxy.test.ts +++ b/tests/unit/custom-proxy.test.ts @@ -107,6 +107,13 @@ test("mixed subscriptions preserve order, names, group references, credentials a assert.equal(result["proxy-groups"][0].name, settings.profileName); assert.equal(result["proxy-groups"][1].name, settings.proxyGroups[0].name); assert.equal(result.proxies.length, 2); + const hiddenResult = parse(buildSubscriptionConfig({user, nodes: [hy2, custom], settings: {...settings, mainProxyGroupHiddenSlugs: ["hy2"]}})); + assert.deepEqual(hiddenResult["proxy-groups"][0].proxies, [proxy.name]); + assert.deepEqual(hiddenResult["proxy-groups"][1].proxies, [proxy.name, hy2.client_name]); + assert.equal(hiddenResult.proxies.length, 2); + const emptyResult = parse(buildSubscriptionConfig({user, nodes: [hy2, custom], settings: {...settings, mainProxyGroupHiddenSlugs: ["hy2", "custom"]}})); + assert.deepEqual(emptyResult["proxy-groups"][0].proxies, ["DIRECT"]); + // Custom-only subscriptions do not require HY2 credentials. assert.deepEqual( parse( diff --git a/tests/unit/store-custom.test.ts b/tests/unit/store-custom.test.ts index 8f81055..c595910 100644 --- a/tests/unit/store-custom.test.ts +++ b/tests/unit/store-custom.test.ts @@ -47,6 +47,14 @@ test("existing v4 boots and custom nodes stay outside HY2 polling/auth while ret [91], ); assert.equal((await store.listSubscriptionNodes()).length, 2); + assert.deepEqual((await store.getSubscriptionSettings()).mainProxyGroupHiddenSlugs, []); + await store.saveMainProxyGroupMemberSlugs(["existing-hy2"], ["existing-hy2"]); + assert.deepEqual((await store.getSubscriptionSettings()).mainProxyGroupHiddenSlugs, ["existing-hy2"]); + await store.saveMainProxyGroupMemberSlugs(["existing-custom"]); + assert.deepEqual((await store.getSubscriptionSettings()).mainProxyGroupHiddenSlugs, ["existing-hy2"]); + await store.saveMainProxyGroupMemberSlugs([], []); + assert.deepEqual((await store.getSubscriptionSettings()).mainProxyGroupHiddenSlugs, []); + assert.equal( (await verifyNodeToken("existing-custom", "test-original-auth")).reason, "node_not_hy2",