feat: improve admin experience and ignore local data
This commit is contained in:
@@ -28,22 +28,35 @@ export async function saveProxyGroupAction(formData: FormData) {
|
||||
const settings = await getSubscriptionSettings();
|
||||
const id = String(formData.get("id") ?? "").trim() || createStableId();
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
const memberSlugs = formData
|
||||
.getAll("member_slugs")
|
||||
.map((value) => String(value))
|
||||
.filter(Boolean);
|
||||
const seen = new Set<string>();
|
||||
const memberSlugs = formData.getAll("member_slugs").flatMap((value) => {
|
||||
const slug = String(value).trim();
|
||||
if (!slug || seen.has(slug)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seen.add(slug);
|
||||
return [slug];
|
||||
});
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyGroups = settings.proxyGroups.filter((group) => group.id !== id);
|
||||
proxyGroups.push({
|
||||
const nextGroup = {
|
||||
id,
|
||||
name,
|
||||
type: "select",
|
||||
memberSlugs,
|
||||
});
|
||||
} as const;
|
||||
const existingIndex = settings.proxyGroups.findIndex((group) => group.id === id);
|
||||
const proxyGroups = [...settings.proxyGroups];
|
||||
|
||||
if (existingIndex === -1) {
|
||||
proxyGroups.push(nextGroup);
|
||||
} else {
|
||||
proxyGroups[existingIndex] = nextGroup;
|
||||
}
|
||||
|
||||
await saveProxyGroups(proxyGroups);
|
||||
revalidatePath("/admin/subscriptions");
|
||||
|
||||
@@ -6,11 +6,8 @@ import {
|
||||
listSubscriptionNodes,
|
||||
} from "@/lib/store";
|
||||
|
||||
import {
|
||||
deleteProxyGroupAction,
|
||||
saveProxyGroupAction,
|
||||
saveSubscriptionSettingsAction,
|
||||
} from "./actions";
|
||||
import { saveSubscriptionSettingsAction } from "./actions";
|
||||
import { ProxyGroupEditor } from "./proxy-group-editor";
|
||||
|
||||
export default async function AdminSubscriptionsPage() {
|
||||
const [settings, adminUser, nodes, origin] = await Promise.all([
|
||||
@@ -19,6 +16,14 @@ export default async function AdminSubscriptionsPage() {
|
||||
listSubscriptionNodes(),
|
||||
getRequestOrigin(),
|
||||
]);
|
||||
const proxyGroupNodes = nodes.map((node) => ({
|
||||
slug: node.slug,
|
||||
label: node.client_name || node.name,
|
||||
meta:
|
||||
node.client_name && node.client_name !== node.name
|
||||
? `${node.name} / ${node.slug}`
|
||||
: node.slug,
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="page-stack">
|
||||
@@ -62,65 +67,18 @@ export default async function AdminSubscriptionsPage() {
|
||||
</section>
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>Proxy Groups</h3>
|
||||
<div className="section-title section-title--top">
|
||||
<div className="stack-tight">
|
||||
<h3>Proxy Groups</h3>
|
||||
<p className="muted">已选列表从上到下的顺序,就是写入 Clash 配置时的节点顺序。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack-lg">
|
||||
{settings.proxyGroups.map((group) => (
|
||||
<article key={group.id} className="instruction-block">
|
||||
<form action={saveProxyGroupAction} className="stack-md">
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<label className="field">
|
||||
<span>组名</span>
|
||||
<input name="name" defaultValue={group.name} required />
|
||||
</label>
|
||||
<div className="checkbox-grid">
|
||||
{nodes.map((node) => (
|
||||
<label key={`${group.id}-${node.slug}`} className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="member_slugs"
|
||||
value={node.slug}
|
||||
defaultChecked={group.memberSlugs.includes(node.slug)}
|
||||
/>
|
||||
<span>{node.client_name || node.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button className="ghost-button" type="submit">
|
||||
保存分组
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form action={deleteProxyGroupAction}>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<button className="danger-button" type="submit">
|
||||
删除分组
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
<ProxyGroupEditor key={group.id} group={group} nodes={proxyGroupNodes} />
|
||||
))}
|
||||
|
||||
<article className="instruction-block">
|
||||
<form action={saveProxyGroupAction} className="stack-md">
|
||||
<label className="field">
|
||||
<span>新建分组名</span>
|
||||
<input name="name" placeholder="ChatGPT" required />
|
||||
</label>
|
||||
<div className="checkbox-grid">
|
||||
{nodes.map((node) => (
|
||||
<label key={`new-${node.slug}`} className="checkbox">
|
||||
<input type="checkbox" name="member_slugs" value={node.slug} />
|
||||
<span>{node.client_name || node.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button className="primary-button" type="submit">
|
||||
新建分组
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
<ProxyGroupEditor key={`new-${settings.proxyGroups.length}`} nodes={proxyGroupNodes} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { useDeferredValue, useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
import { deleteProxyGroupAction, saveProxyGroupAction } from "./actions";
|
||||
|
||||
type ProxyGroupNodeOption = {
|
||||
slug: string;
|
||||
label: string;
|
||||
meta: string;
|
||||
};
|
||||
|
||||
type ProxyGroupEditorProps = {
|
||||
group?: {
|
||||
id: string;
|
||||
name: string;
|
||||
memberSlugs: string[];
|
||||
};
|
||||
nodes: ProxyGroupNodeOption[];
|
||||
};
|
||||
|
||||
function normalizeSelectedSlugs(initialSlugs: string[], nodes: ProxyGroupNodeOption[]) {
|
||||
const availableSlugs = new Set(nodes.map((node) => node.slug));
|
||||
const seen = new Set<string>();
|
||||
|
||||
return initialSlugs.filter((slug) => {
|
||||
if (!availableSlugs.has(slug) || seen.has(slug)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(slug);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function SaveButton({ isEditing }: { isEditing: boolean }) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<button className={isEditing ? "ghost-button" : "primary-button"} type="submit" disabled={pending}>
|
||||
{pending ? (isEditing ? "保存中..." : "创建中...") : isEditing ? "保存分组" : "新建分组"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<button className="danger-button" type="submit" disabled={pending}>
|
||||
{pending ? "删除中..." : "删除分组"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
||||
const isEditing = Boolean(group);
|
||||
const [query, setQuery] = useState("");
|
||||
const deferredQuery = useDeferredValue(query);
|
||||
const [selectedSlugs, setSelectedSlugs] = useState(() =>
|
||||
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes),
|
||||
);
|
||||
const selectedSlugSet = new Set(selectedSlugs);
|
||||
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
|
||||
const selectedNodes = selectedSlugs
|
||||
.map((slug) => nodesBySlug.get(slug))
|
||||
.filter((node): node is ProxyGroupNodeOption => Boolean(node));
|
||||
const normalizedQuery = deferredQuery.trim().toLowerCase();
|
||||
const remainingCount = nodes.length - selectedNodes.length;
|
||||
const availableNodes = nodes.filter((node) => {
|
||||
if (selectedSlugSet.has(node.slug)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [node.label, node.meta, node.slug].some((value) =>
|
||||
value.toLowerCase().includes(normalizedQuery),
|
||||
);
|
||||
});
|
||||
|
||||
function addNode(slug: string) {
|
||||
setSelectedSlugs((current) => {
|
||||
if (current.includes(slug)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, slug];
|
||||
});
|
||||
}
|
||||
|
||||
function removeNode(slug: string) {
|
||||
setSelectedSlugs((current) => current.filter((currentSlug) => currentSlug !== slug));
|
||||
}
|
||||
|
||||
function moveNode(slug: string, direction: -1 | 1) {
|
||||
setSelectedSlugs((current) => {
|
||||
const index = current.indexOf(slug);
|
||||
const nextIndex = index + direction;
|
||||
|
||||
if (index === -1 || nextIndex < 0 || nextIndex >= current.length) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next = [...current];
|
||||
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function addAllVisibleNodes() {
|
||||
setSelectedSlugs((current) => {
|
||||
const next = [...current];
|
||||
const seen = new Set(current);
|
||||
|
||||
for (const node of availableNodes) {
|
||||
if (seen.has(node.slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
next.push(node.slug);
|
||||
seen.add(node.slug);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setSelectedSlugs([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="instruction-block proxy-group-card">
|
||||
<form action={saveProxyGroupAction} className="stack-md">
|
||||
{group ? <input type="hidden" name="id" value={group.id} /> : null}
|
||||
<div className="section-title section-title--top">
|
||||
<div className="stack-tight">
|
||||
<h4 className="proxy-group-title">{isEditing ? group?.name : "新建分组"}</h4>
|
||||
<p className="muted proxy-group-caption">右侧顺序就是最终写入 Clash 的节点顺序。</p>
|
||||
</div>
|
||||
<div className="badge-row badge-row--end">
|
||||
<span className="status-pill status-pill--ink">已选 {selectedNodes.length}</span>
|
||||
<span className="status-pill status-pill--warm">未加入 {remainingCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span>{isEditing ? "组名" : "新建分组名"}</span>
|
||||
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
|
||||
</label>
|
||||
|
||||
<div className="proxy-group-builder">
|
||||
<section className="proxy-group-panel">
|
||||
<div className="proxy-group-panel__header">
|
||||
<div className="stack-tight">
|
||||
<strong>候选节点</strong>
|
||||
<span className="muted">筛选后点击添加,新节点会追加到分组末尾。</span>
|
||||
</div>
|
||||
<div className="proxy-group-toolbar">
|
||||
<label className="proxy-group-search">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
placeholder="搜索节点名称 / slug"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="ghost-button proxy-toolbar-button"
|
||||
type="button"
|
||||
onClick={addAllVisibleNodes}
|
||||
disabled={availableNodes.length === 0}
|
||||
>
|
||||
{normalizedQuery ? "添加筛选结果" : "全部添加"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availableNodes.length > 0 ? (
|
||||
<div className="proxy-node-grid">
|
||||
{availableNodes.map((node) => (
|
||||
<button
|
||||
key={node.slug}
|
||||
className="proxy-node-chip"
|
||||
type="button"
|
||||
onClick={() => addNode(node.slug)}
|
||||
>
|
||||
<span className="proxy-node-chip__copy">
|
||||
<strong>{node.label}</strong>
|
||||
<span>{node.meta}</span>
|
||||
</span>
|
||||
<span className="proxy-node-chip__action">添加</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted proxy-group-empty">
|
||||
{remainingCount === 0
|
||||
? "所有可用节点都已加入当前分组。"
|
||||
: "没有匹配当前筛选条件的节点。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="proxy-group-panel proxy-group-panel--selected">
|
||||
<div className="proxy-group-panel__header">
|
||||
<div className="stack-tight">
|
||||
<strong>已选顺序</strong>
|
||||
<span className="muted">上移和下移会直接改变最终输出顺序。</span>
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button proxy-toolbar-button"
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
disabled={selectedNodes.length === 0}
|
||||
>
|
||||
清空已选
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedNodes.length > 0 ? (
|
||||
<ol className="proxy-sort-list">
|
||||
{selectedNodes.map((node, index) => (
|
||||
<li key={node.slug} className="proxy-sort-item">
|
||||
<div className="proxy-sort-item__meta">
|
||||
<span className="proxy-sort-item__index">{String(index + 1).padStart(2, "0")}</span>
|
||||
<div className="stack-tight">
|
||||
<strong>{node.label}</strong>
|
||||
<span className="muted mono proxy-sort-item__sub">{node.meta}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="proxy-sort-item__actions">
|
||||
<button
|
||||
className="ghost-button proxy-sort-button"
|
||||
type="button"
|
||||
onClick={() => moveNode(node.slug, -1)}
|
||||
disabled={index === 0}
|
||||
>
|
||||
上移
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button proxy-sort-button"
|
||||
type="button"
|
||||
onClick={() => moveNode(node.slug, 1)}
|
||||
disabled={index === selectedNodes.length - 1}
|
||||
>
|
||||
下移
|
||||
</button>
|
||||
<button
|
||||
className="danger-button proxy-sort-button"
|
||||
type="button"
|
||||
onClick={() => removeNode(node.slug)}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" name="member_slugs" value={node.slug} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="muted proxy-group-empty">
|
||||
还没有选择节点。先从左侧添加,再在这里调整最终顺序。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="action-row">
|
||||
<span className="muted">保存后会立即更新订阅模板里的 proxy-groups。</span>
|
||||
<SaveButton isEditing={isEditing} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{group ? (
|
||||
<form action={deleteProxyGroupAction}>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<DeleteButton />
|
||||
</form>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user