feat: 支持主分组隐藏节点并保留排序
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ export default async function AdminSubscriptionsPage() {
|
||||
id: "main",
|
||||
name: settings.profileName,
|
||||
memberSlugs: settings.mainProxyGroupMemberSlugs,
|
||||
hiddenSlugs: settings.mainProxyGroupHiddenSlugs,
|
||||
}}
|
||||
nodes={proxyGroupNodes}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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: "新建分组" }) });
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user