feat: 支持主分组隐藏节点并保留排序

This commit is contained in:
2026-10-05 10:00:06 +08:00
parent b6aa2bf30d
commit 6ed0307ea9
8 changed files with 124 additions and 14 deletions
+12 -4
View File
@@ -31,7 +31,8 @@ export async function saveSubscriptionSettingsAction(formData: FormData) {
await saveSubscriptionSettings({ await saveSubscriptionSettings({
template: String(formData.get("template") ?? ""), template: String(formData.get("template") ?? ""),
extraProxyGroups: String(formData.get("extra_proxy_groups") ?? ""), 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"); revalidatePath("/admin/subscriptions");
@@ -55,7 +56,9 @@ export async function saveProxyGroupAction(formData: FormData) {
type: "select", type: "select",
memberSlugs, memberSlugs,
} as const; } as const;
const existingIndex = settings.proxyGroups.findIndex((group) => group.id === id); const existingIndex = settings.proxyGroups.findIndex(
(group) => group.id === id,
);
const proxyGroups = [...settings.proxyGroups]; const proxyGroups = [...settings.proxyGroups];
if (existingIndex === -1) { if (existingIndex === -1) {
@@ -71,7 +74,10 @@ export async function saveProxyGroupAction(formData: FormData) {
export async function saveMainProxyGroupAction(formData: FormData) { export async function saveMainProxyGroupAction(formData: FormData) {
await requireAdminAccess(); await requireAdminAccess();
await saveMainProxyGroupMemberSlugs(readUniqueMemberSlugs(formData)); await saveMainProxyGroupMemberSlugs(
readUniqueMemberSlugs(formData),
formData.getAll("hidden_slugs").map(String),
);
revalidatePath("/admin/subscriptions"); revalidatePath("/admin/subscriptions");
} }
@@ -80,6 +86,8 @@ export async function deleteProxyGroupAction(formData: FormData) {
const settings = await getSubscriptionSettings(); const settings = await getSubscriptionSettings();
const id = String(formData.get("id") ?? "").trim(); 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"); revalidatePath("/admin/subscriptions");
} }
+1
View File
@@ -88,6 +88,7 @@ export default async function AdminSubscriptionsPage() {
id: "main", id: "main",
name: settings.profileName, name: settings.profileName,
memberSlugs: settings.mainProxyGroupMemberSlugs, memberSlugs: settings.mainProxyGroupMemberSlugs,
hiddenSlugs: settings.mainProxyGroupHiddenSlugs,
}} }}
nodes={proxyGroupNodes} nodes={proxyGroupNodes}
/> />
+53 -4
View File
@@ -42,6 +42,7 @@ type ProxyGroupEditorProps = {
id: string; id: string;
name: string; name: string;
memberSlugs: string[]; memberSlugs: string[];
hiddenSlugs?: string[];
}; };
nodes: ProxyGroupNodeOption[]; nodes: ProxyGroupNodeOption[];
}; };
@@ -90,11 +91,15 @@ function SortableNode({
index, index,
removable, removable,
onRemove, onRemove,
hidden,
onToggleHidden,
}: { }: {
node: ProxyGroupNodeOption; node: ProxyGroupNodeOption;
index: number; index: number;
removable: boolean; removable: boolean;
onRemove: () => void; onRemove: () => void;
hidden?: boolean;
onToggleHidden?: () => void;
}) { }) {
const { const {
attributes, attributes,
@@ -135,6 +140,17 @@ function SortableNode({
<strong>{node.label}</strong> <strong>{node.label}</strong>
<span className="proxy-sort-item__sub">{node.meta}</span> <span className="proxy-sort-item__sub">{node.meta}</span>
</div> </div>
{onToggleHidden && (
<label className="inline-flex items-center gap-2 text-sm whitespace-nowrap">
<input
type="checkbox"
aria-label={`主分组隐藏 ${node.label}`}
checked={hidden}
onChange={onToggleHidden}
/>
主分组隐藏
</label>
)}
{removable && ( {removable && (
<Button <Button
variant="ghost" variant="ghost"
@@ -170,15 +186,17 @@ export function ProxyGroupEditor({
const [selectedSlugs, setSelectedSlugs] = useState(() => const [selectedSlugs, setSelectedSlugs] = useState(() =>
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup), normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
); );
const [hiddenSlugs, setHiddenSlugs] = useState(group?.hiddenSlugs ?? []);
const [name, setName] = useState(group?.name ?? ""); const [name, setName] = useState(group?.name ?? "");
const [saved, setSaved] = useState(() => const [saved, setSaved] = useState(() =>
JSON.stringify([ JSON.stringify([
group?.name ?? "", group?.name ?? "",
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup), normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
group?.hiddenSlugs ?? [],
]), ]),
); );
const [feedback, setFeedback] = useState(""); const [feedback, setFeedback] = useState("");
const dirty = saved !== JSON.stringify([name, selectedSlugs]); const dirty = saved !== JSON.stringify([name, selectedSlugs, hiddenSlugs]);
const selectedSlugSet = new Set(selectedSlugs); const selectedSlugSet = new Set(selectedSlugs);
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node])); const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
const selectedNodes = selectedSlugs const selectedNodes = selectedSlugs
@@ -251,7 +269,7 @@ export function ProxyGroupEditor({
await ( await (
isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction
)(formData); )(formData);
setSaved(JSON.stringify([name, selectedSlugs])); setSaved(JSON.stringify([name, selectedSlugs, hiddenSlugs]));
setFeedback("已保存"); setFeedback("已保存");
} catch { } catch {
setFeedback("保存失败,请重试。当前修改已保留。"); setFeedback("保存失败,请重试。当前修改已保留。");
@@ -260,6 +278,22 @@ export function ProxyGroupEditor({
className="stack-md" className="stack-md"
> >
<EditorFields> <EditorFields>
{isMainGroup && (
<>
<p className="muted">
隐藏仅影响主分组,节点及其他分组仍保留。新节点默认显示;全部隐藏时主分组回退为
DIRECT。
</p>
{hiddenSlugs.map((slug) => (
<input
key={slug}
type="hidden"
name="hidden_slugs"
value={slug}
/>
))}
</>
)}
{group && !isMainGroup ? ( {group && !isMainGroup ? (
<input type="hidden" name="id" value={group.id} /> <input type="hidden" name="id" value={group.id} />
) : null} ) : null}
@@ -431,6 +465,19 @@ export function ProxyGroupEditor({
key={node.slug} key={node.slug}
node={node} node={node}
index={index} index={index}
hidden={hiddenSlugs.includes(node.slug)}
onToggleHidden={
isMainGroup
? () => {
setHiddenSlugs((current) =>
current.includes(node.slug)
? current.filter((s) => s !== node.slug)
: [...current, node.slug],
);
setFeedback("");
}
: undefined
}
removable={!isMainGroup} removable={!isMainGroup}
onRemove={() => removeNode(node.slug)} onRemove={() => removeNode(node.slug)}
/> />
@@ -458,12 +505,14 @@ export function ProxyGroupEditor({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
const [oldName, oldSlugs] = JSON.parse(saved) as [ const [oldName, oldSlugs, oldHidden] = JSON.parse(saved) as [
string, string,
string[], string[],
string[],
]; ];
setName(oldName); setName(oldName);
setSelectedSlugs(oldSlugs); setSelectedSlugs(oldSlugs);
setHiddenSlugs(oldHidden);
setFeedback(""); setFeedback("");
}} }}
> >
@@ -471,7 +520,7 @@ export function ProxyGroupEditor({
</Button> </Button>
)} )}
<SubmitButton disabled={!dirty && (isEditing || isMainGroup)}> <SubmitButton disabled={!dirty && (isEditing || isMainGroup)}>
{isMainGroup ? "保存顺序" : isEditing ? "保存分组" : "新建分组"} {isMainGroup ? "保存主分组" : isEditing ? "保存分组" : "新建分组"}
</SubmitButton> </SubmitButton>
</div> </div>
</EditorFields> </EditorFields>
+23 -3
View File
@@ -77,6 +77,7 @@ export type SubscriptionSettings = {
extraProxyGroups: string; extraProxyGroups: string;
profileName: string; profileName: string;
mainProxyGroupMemberSlugs: string[]; mainProxyGroupMemberSlugs: string[];
mainProxyGroupHiddenSlugs: string[];
proxyGroups: ProxyGroupConfig[]; proxyGroups: ProxyGroupConfig[];
}; };
@@ -1193,6 +1194,9 @@ export async function getSubscriptionSettings() {
mainProxyGroupMemberSlugs: JSON.parse( mainProxyGroupMemberSlugs: JSON.parse(
map.get("main_proxy_group_member_slugs_json") ?? "[]", map.get("main_proxy_group_member_slugs_json") ?? "[]",
) as string[], ) as string[],
mainProxyGroupHiddenSlugs: JSON.parse(
map.get("main_proxy_group_hidden_slugs_json") ?? "[]",
) as string[],
proxyGroups: JSON.parse( proxyGroups: JSON.parse(
map.get("proxy_groups_json") ?? "[]", map.get("proxy_groups_json") ?? "[]",
) as ProxyGroupConfig[], ) as ProxyGroupConfig[],
@@ -1234,10 +1238,13 @@ export async function saveProxyGroups(proxyGroups: ProxyGroupConfig[]) {
).run("proxy_groups_json", JSON.stringify(proxyGroups), now); ).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 db = await ensureDb();
const now = dbNow(); const now = dbNow();
db.prepare( const upsert = db.prepare(
` `
INSERT INTO app_settings (key, value, updated_at) INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?) VALUES (?, ?, ?)
@@ -1245,7 +1252,20 @@ export async function saveMainProxyGroupMemberSlugs(memberSlugs: string[]) {
value = excluded.value, value = excluded.value,
updated_at = excluded.updated_at 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() { export async function listSubscriptionNodes() {
+9 -2
View File
@@ -13,6 +13,7 @@ type ClashSettings = {
extraProxyGroups: string; extraProxyGroups: string;
profileName: string; profileName: string;
mainProxyGroupMemberSlugs: string[]; mainProxyGroupMemberSlugs: string[];
mainProxyGroupHiddenSlugs?: string[];
proxyGroups: ProxyGroupConfig[]; proxyGroups: ProxyGroupConfig[];
}; };
@@ -225,14 +226,20 @@ export function buildSubscriptionConfig(input: {
const nodeNameBySlug = new Map( const nodeNameBySlug = new Map(
eligibleNodes.map((node) => [node.slug, getNodeProxyName(node)]), 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 const proxiesBlock = orderedEligibleNodes
.map((node) => renderSubscriptionProxy(node, authString, input.user)) .map((node) => renderSubscriptionProxy(node, authString, input.user))
.join("\n"); .join("\n");
const defaultTemplate = const defaultTemplate =
input.settings.template.trimEnd() || readDefaultTemplate(); input.settings.template.trimEnd() || readDefaultTemplate();
const renderedGroups = [ const renderedGroups = [
renderGroupEntry(input.settings.profileName || "FeieProxy", proxyNames), renderGroupEntry(
input.settings.profileName || "FeieProxy",
proxyNames.length ? proxyNames : ["DIRECT"],
),
...input.settings.proxyGroups ...input.settings.proxyGroups
.map((group) => { .map((group) => {
const members = group.memberSlugs const members = group.memberSlugs
+11 -1
View File
@@ -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.move(to!.x + 15, to!.y + 15, { steps: 12 });
await page.mouse.up(); await page.mouse.up();
await expect.poll(labels).toEqual([initial[1], initial[2], initial[0]]); 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 expect(main.locator(".action-row [role=status]")).toHaveText("已保存");
await page.reload(); await page.reload();
await expect.poll(labels).toEqual([initial[1], initial[2], initial[0]]); 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 const newGroup = page
.locator("details") .locator("details")
.filter({ has: page.locator("summary", { hasText: "新建分组" }) }); .filter({ has: page.locator("summary", { hasText: "新建分组" }) });
+7
View File
@@ -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"][0].name, settings.profileName);
assert.equal(result["proxy-groups"][1].name, settings.proxyGroups[0].name); assert.equal(result["proxy-groups"][1].name, settings.proxyGroups[0].name);
assert.equal(result.proxies.length, 2); 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. // Custom-only subscriptions do not require HY2 credentials.
assert.deepEqual( assert.deepEqual(
parse( parse(
+8
View File
@@ -47,6 +47,14 @@ test("existing v4 boots and custom nodes stay outside HY2 polling/auth while ret
[91], [91],
); );
assert.equal((await store.listSubscriptionNodes()).length, 2); 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( assert.equal(
(await verifyNodeToken("existing-custom", "test-original-auth")).reason, (await verifyNodeToken("existing-custom", "test-original-auth")).reason,
"node_not_hy2", "node_not_hy2",