329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useDeferredValue, useState } from "react";
|
|
import { useFormStatus } from "react-dom";
|
|
|
|
import {
|
|
deleteProxyGroupAction,
|
|
saveMainProxyGroupAction,
|
|
saveProxyGroupAction,
|
|
} from "./actions";
|
|
|
|
type ProxyGroupNodeOption = {
|
|
slug: string;
|
|
label: string;
|
|
meta: string;
|
|
};
|
|
|
|
type ProxyGroupEditorProps = {
|
|
mode?: "custom" | "main";
|
|
group?: {
|
|
id: string;
|
|
name: string;
|
|
memberSlugs: 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>();
|
|
const selectedSlugs = initialSlugs.filter((slug) => {
|
|
if (!availableSlugs.has(slug) || seen.has(slug)) {
|
|
return false;
|
|
}
|
|
|
|
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, isMainGroup }: { isEditing: boolean; isMainGroup: boolean }) {
|
|
const { pending } = useFormStatus();
|
|
const label = isMainGroup ? "保存主组顺序" : isEditing ? "保存分组" : "新建分组";
|
|
const pendingLabel = isMainGroup ? "保存中..." : isEditing ? "保存中..." : "创建中...";
|
|
|
|
return (
|
|
<button
|
|
className={isEditing && !isMainGroup ? "ghost-button" : "primary-button"}
|
|
type="submit"
|
|
disabled={pending}
|
|
>
|
|
{pending ? pendingLabel : label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function DeleteButton() {
|
|
const { pending } = useFormStatus();
|
|
|
|
return (
|
|
<button className="danger-button" type="submit" disabled={pending}>
|
|
{pending ? "删除中..." : "删除分组"}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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, isMainGroup),
|
|
);
|
|
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={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">
|
|
{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>
|
|
{!isMainGroup ? (
|
|
<span className="status-pill status-pill--warm">未加入 {remainingCount}</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{!isMainGroup ? (
|
|
<label className="field">
|
|
<span>{isEditing ? "组名" : "新建分组名"}</span>
|
|
<input name="name" defaultValue={group?.name ?? ""} placeholder="ChatGPT" required />
|
|
</label>
|
|
) : null}
|
|
|
|
<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
|
|
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>
|
|
) : null}
|
|
|
|
<section className="proxy-group-panel proxy-group-panel--selected">
|
|
<div className="proxy-group-panel__header">
|
|
<div className="stack-tight">
|
|
<strong>{isMainGroup ? "节点顺序" : "已选顺序"}</strong>
|
|
</div>
|
|
{!isMainGroup ? (
|
|
<button
|
|
className="ghost-button proxy-toolbar-button"
|
|
type="button"
|
|
onClick={clearSelection}
|
|
disabled={selectedNodes.length === 0}
|
|
>
|
|
清空已选
|
|
</button>
|
|
) : null}
|
|
</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>
|
|
{!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>
|
|
))}
|
|
</ol>
|
|
) : (
|
|
<p className="muted proxy-group-empty">
|
|
{isMainGroup ? "暂无可用节点。" : "还没有选择节点。先从左侧添加,再在这里调整最终顺序。"}
|
|
</p>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
<div className="action-row action-row--end">
|
|
<SaveButton isEditing={isEditing} isMainGroup={isMainGroup} />
|
|
</div>
|
|
</form>
|
|
|
|
{group && !isMainGroup ? (
|
|
<form action={deleteProxyGroupAction}>
|
|
<input type="hidden" name="id" value={group.id} />
|
|
<DeleteButton />
|
|
</form>
|
|
) : null}
|
|
</article>
|
|
);
|
|
}
|