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({
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");
}
+1
View File
@@ -88,6 +88,7 @@ export default async function AdminSubscriptionsPage() {
id: "main",
name: settings.profileName,
memberSlugs: settings.mainProxyGroupMemberSlugs,
hiddenSlugs: settings.mainProxyGroupHiddenSlugs,
}}
nodes={proxyGroupNodes}
/>
+53 -4
View File
@@ -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({
<strong>{node.label}</strong>
<span className="proxy-sort-item__sub">{node.meta}</span>
</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 && (
<Button
variant="ghost"
@@ -170,15 +186,17 @@ export function ProxyGroupEditor({
const [selectedSlugs, setSelectedSlugs] = useState(() =>
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
);
const [hiddenSlugs, setHiddenSlugs] = useState(group?.hiddenSlugs ?? []);
const [name, setName] = useState(group?.name ?? "");
const [saved, setSaved] = useState(() =>
JSON.stringify([
group?.name ?? "",
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
group?.hiddenSlugs ?? [],
]),
);
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 nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
const selectedNodes = selectedSlugs
@@ -251,7 +269,7 @@ export function ProxyGroupEditor({
await (
isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction
)(formData);
setSaved(JSON.stringify([name, selectedSlugs]));
setSaved(JSON.stringify([name, selectedSlugs, hiddenSlugs]));
setFeedback("已保存");
} catch {
setFeedback("保存失败,请重试。当前修改已保留。");
@@ -260,6 +278,22 @@ export function ProxyGroupEditor({
className="stack-md"
>
<EditorFields>
{isMainGroup && (
<>
<p className="muted">
隐藏仅影响主分组,节点及其他分组仍保留。新节点默认显示;全部隐藏时主分组回退为
DIRECT。
</p>
{hiddenSlugs.map((slug) => (
<input
key={slug}
type="hidden"
name="hidden_slugs"
value={slug}
/>
))}
</>
)}
{group && !isMainGroup ? (
<input type="hidden" name="id" value={group.id} />
) : null}
@@ -431,6 +465,19 @@ export function ProxyGroupEditor({
key={node.slug}
node={node}
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}
onRemove={() => removeNode(node.slug)}
/>
@@ -458,12 +505,14 @@ export function ProxyGroupEditor({
variant="ghost"
size="sm"
onClick={() => {
const [oldName, oldSlugs] = JSON.parse(saved) as [
const [oldName, oldSlugs, oldHidden] = JSON.parse(saved) as [
string,
string[],
string[],
];
setName(oldName);
setSelectedSlugs(oldSlugs);
setHiddenSlugs(oldHidden);
setFeedback("");
}}
>
@@ -471,7 +520,7 @@ export function ProxyGroupEditor({
</Button>
)}
<SubmitButton disabled={!dirty && (isEditing || isMainGroup)}>
{isMainGroup ? "保存顺序" : isEditing ? "保存分组" : "新建分组"}
{isMainGroup ? "保存主分组" : isEditing ? "保存分组" : "新建分组"}
</SubmitButton>
</div>
</EditorFields>
+23 -3
View File
@@ -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() {
+9 -2
View File
@@ -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
+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.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: "新建分组" }) });
+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"][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(
+8
View File
@@ -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",