feat: 支持主分组隐藏节点并保留排序
This commit is contained in:
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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: "新建分组" }) });
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user