优化ui
This commit is contained in:
@@ -6,10 +6,25 @@ import { createStableId } from "@/lib/password";
|
||||
import { requireAdminAccess } from "@/lib/session";
|
||||
import {
|
||||
getSubscriptionSettings,
|
||||
saveMainProxyGroupMemberSlugs,
|
||||
saveProxyGroups,
|
||||
saveSubscriptionSettings,
|
||||
} from "@/lib/store";
|
||||
|
||||
function readUniqueMemberSlugs(formData: FormData) {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return formData.getAll("member_slugs").flatMap((value) => {
|
||||
const slug = String(value).trim();
|
||||
if (!slug || seen.has(slug)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seen.add(slug);
|
||||
return [slug];
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveSubscriptionSettingsAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
|
||||
@@ -28,16 +43,7 @@ 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 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];
|
||||
});
|
||||
const memberSlugs = readUniqueMemberSlugs(formData);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
@@ -62,6 +68,13 @@ export async function saveProxyGroupAction(formData: FormData) {
|
||||
revalidatePath("/admin/subscriptions");
|
||||
}
|
||||
|
||||
export async function saveMainProxyGroupAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
|
||||
await saveMainProxyGroupMemberSlugs(readUniqueMemberSlugs(formData));
|
||||
revalidatePath("/admin/subscriptions");
|
||||
}
|
||||
|
||||
export async function deleteProxyGroupAction(formData: FormData) {
|
||||
await requireAdminAccess();
|
||||
|
||||
|
||||
@@ -29,13 +29,10 @@ export default async function AdminSubscriptionsPage() {
|
||||
<main className="page-stack">
|
||||
<section className="hero-panel">
|
||||
<div className="hero-copy">
|
||||
<span className="eyebrow">SUBSCRIPTIONS</span>
|
||||
<span className="eyebrow">订阅</span>
|
||||
<h2>订阅与模板</h2>
|
||||
<p className="page-lede">
|
||||
管理 Clash 基础模板、订阅文件名、代理分组以及管理员默认不限量订阅链接。
|
||||
</p>
|
||||
<div className="badge-row">
|
||||
<span className="status-pill status-pill--ok">动态 proxies</span>
|
||||
<span className="status-pill status-pill--ok">节点 {nodes.length}</span>
|
||||
<span className="status-pill status-pill--ink">分组 {settings.proxyGroups.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,11 +66,20 @@ export default async function AdminSubscriptionsPage() {
|
||||
<section className="panel-card">
|
||||
<div className="section-title section-title--top">
|
||||
<div className="stack-tight">
|
||||
<h3>Proxy Groups</h3>
|
||||
<p className="muted">已选列表从上到下的顺序,就是写入 Clash 配置时的节点顺序。</p>
|
||||
<h3>代理分组</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack-lg">
|
||||
<ProxyGroupEditor
|
||||
mode="main"
|
||||
group={{
|
||||
id: "main",
|
||||
name: settings.profileName,
|
||||
memberSlugs: settings.mainProxyGroupMemberSlugs,
|
||||
}}
|
||||
nodes={proxyGroupNodes}
|
||||
/>
|
||||
|
||||
{settings.proxyGroups.map((group) => (
|
||||
<ProxyGroupEditor key={group.id} group={group} nodes={proxyGroupNodes} />
|
||||
))}
|
||||
@@ -84,7 +90,7 @@ export default async function AdminSubscriptionsPage() {
|
||||
|
||||
<section className="panel-card">
|
||||
<div className="section-title">
|
||||
<h3>Clash 模板</h3>
|
||||
<h3>订阅模板</h3>
|
||||
</div>
|
||||
<form action={saveSubscriptionSettingsAction} className="stack-lg">
|
||||
<label className="field">
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import { useDeferredValue, useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
import { deleteProxyGroupAction, saveProxyGroupAction } from "./actions";
|
||||
import {
|
||||
deleteProxyGroupAction,
|
||||
saveMainProxyGroupAction,
|
||||
saveProxyGroupAction,
|
||||
} from "./actions";
|
||||
|
||||
type ProxyGroupNodeOption = {
|
||||
slug: string;
|
||||
@@ -12,6 +16,7 @@ type ProxyGroupNodeOption = {
|
||||
};
|
||||
|
||||
type ProxyGroupEditorProps = {
|
||||
mode?: "custom" | "main";
|
||||
group?: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -20,11 +25,14 @@ type ProxyGroupEditorProps = {
|
||||
nodes: ProxyGroupNodeOption[];
|
||||
};
|
||||
|
||||
function normalizeSelectedSlugs(initialSlugs: string[], nodes: ProxyGroupNodeOption[]) {
|
||||
function normalizeSelectedSlugs(
|
||||
initialSlugs: string[],
|
||||
nodes: ProxyGroupNodeOption[],
|
||||
includeMissingNodes = false,
|
||||
) {
|
||||
const availableSlugs = new Set(nodes.map((node) => node.slug));
|
||||
const seen = new Set<string>();
|
||||
|
||||
return initialSlugs.filter((slug) => {
|
||||
const selectedSlugs = initialSlugs.filter((slug) => {
|
||||
if (!availableSlugs.has(slug) || seen.has(slug)) {
|
||||
return false;
|
||||
}
|
||||
@@ -32,14 +40,33 @@ function normalizeSelectedSlugs(initialSlugs: string[], nodes: ProxyGroupNodeOpt
|
||||
seen.add(slug);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!includeMissingNodes) {
|
||||
return selectedSlugs;
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!seen.has(node.slug)) {
|
||||
selectedSlugs.push(node.slug);
|
||||
seen.add(node.slug);
|
||||
}
|
||||
}
|
||||
|
||||
return selectedSlugs;
|
||||
}
|
||||
|
||||
function SaveButton({ isEditing }: { isEditing: boolean }) {
|
||||
function SaveButton({ isEditing, isMainGroup }: { isEditing: boolean; isMainGroup: boolean }) {
|
||||
const { pending } = useFormStatus();
|
||||
const label = isMainGroup ? "保存主组顺序" : isEditing ? "保存分组" : "新建分组";
|
||||
const pendingLabel = isMainGroup ? "保存中..." : isEditing ? "保存中..." : "创建中...";
|
||||
|
||||
return (
|
||||
<button className={isEditing ? "ghost-button" : "primary-button"} type="submit" disabled={pending}>
|
||||
{pending ? (isEditing ? "保存中..." : "创建中...") : isEditing ? "保存分组" : "新建分组"}
|
||||
<button
|
||||
className={isEditing && !isMainGroup ? "ghost-button" : "primary-button"}
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? pendingLabel : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -54,12 +81,13 @@ function DeleteButton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
||||
const isEditing = Boolean(group);
|
||||
export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEditorProps) {
|
||||
const isMainGroup = mode === "main";
|
||||
const isEditing = Boolean(group) && !isMainGroup;
|
||||
const [query, setQuery] = useState("");
|
||||
const deferredQuery = useDeferredValue(query);
|
||||
const [selectedSlugs, setSelectedSlugs] = useState(() =>
|
||||
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes),
|
||||
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
|
||||
);
|
||||
const selectedSlugSet = new Set(selectedSlugs);
|
||||
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
|
||||
@@ -135,96 +163,103 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
||||
|
||||
return (
|
||||
<article className="instruction-block proxy-group-card">
|
||||
<form action={saveProxyGroupAction} className="stack-md">
|
||||
{group ? <input type="hidden" name="id" value={group.id} /> : null}
|
||||
<form action={isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction} className="stack-md">
|
||||
{group && !isMainGroup ? <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>
|
||||
<h4 className="proxy-group-title">
|
||||
{isMainGroup ? `默认主分组:${group?.name ?? "FeieProxy"}` : isEditing ? group?.name : "新建分组"}
|
||||
</h4>
|
||||
</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>
|
||||
{!isMainGroup ? (
|
||||
<span className="status-pill status-pill--warm">未加入 {remainingCount}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="field">
|
||||
<span>{isEditing ? "组名" : "新建分组名"}</span>
|
||||
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
|
||||
</label>
|
||||
{!isMainGroup ? (
|
||||
<label className="field">
|
||||
<span>{isEditing ? "组名" : "新建分组名"}</span>
|
||||
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<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) => (
|
||||
<div className={`proxy-group-builder${isMainGroup ? " proxy-group-builder--main" : ""}`}>
|
||||
{!isMainGroup ? (
|
||||
<section className="proxy-group-panel">
|
||||
<div className="proxy-group-panel__header">
|
||||
<div className="stack-tight">
|
||||
<strong>候选节点</strong>
|
||||
</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
|
||||
key={node.slug}
|
||||
className="proxy-node-chip"
|
||||
className="ghost-button proxy-toolbar-button"
|
||||
type="button"
|
||||
onClick={() => addNode(node.slug)}
|
||||
onClick={addAllVisibleNodes}
|
||||
disabled={availableNodes.length === 0}
|
||||
>
|
||||
<span className="proxy-node-chip__copy">
|
||||
<strong>{node.label}</strong>
|
||||
<span>{node.meta}</span>
|
||||
</span>
|
||||
<span className="proxy-node-chip__action">添加</span>
|
||||
{normalizedQuery ? "添加筛选结果" : "全部添加"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted proxy-group-empty">
|
||||
{remainingCount === 0
|
||||
? "所有可用节点都已加入当前分组。"
|
||||
: "没有匹配当前筛选条件的节点。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{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>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
<strong>{isMainGroup ? "节点顺序" : "已选顺序"}</strong>
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button proxy-toolbar-button"
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
disabled={selectedNodes.length === 0}
|
||||
>
|
||||
清空已选
|
||||
</button>
|
||||
{!isMainGroup ? (
|
||||
<button
|
||||
className="ghost-button proxy-toolbar-button"
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
disabled={selectedNodes.length === 0}
|
||||
>
|
||||
清空已选
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{selectedNodes.length > 0 ? (
|
||||
@@ -255,13 +290,15 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
||||
>
|
||||
下移
|
||||
</button>
|
||||
<button
|
||||
className="danger-button proxy-sort-button"
|
||||
type="button"
|
||||
onClick={() => removeNode(node.slug)}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
{!isMainGroup ? (
|
||||
<button
|
||||
className="danger-button proxy-sort-button"
|
||||
type="button"
|
||||
onClick={() => removeNode(node.slug)}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<input type="hidden" name="member_slugs" value={node.slug} />
|
||||
</li>
|
||||
@@ -269,19 +306,18 @@ export function ProxyGroupEditor({ group, nodes }: ProxyGroupEditorProps) {
|
||||
</ol>
|
||||
) : (
|
||||
<p className="muted proxy-group-empty">
|
||||
还没有选择节点。先从左侧添加,再在这里调整最终顺序。
|
||||
{isMainGroup ? "暂无可用节点。" : "还没有选择节点。先从左侧添加,再在这里调整最终顺序。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="action-row">
|
||||
<span className="muted">保存后会立即更新订阅模板里的 proxy-groups。</span>
|
||||
<SaveButton isEditing={isEditing} />
|
||||
<div className="action-row action-row--end">
|
||||
<SaveButton isEditing={isEditing} isMainGroup={isMainGroup} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{group ? (
|
||||
{group && !isMainGroup ? (
|
||||
<form action={deleteProxyGroupAction}>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<DeleteButton />
|
||||
|
||||
Reference in New Issue
Block a user