Files
hy2-panel/app/admin/subscriptions/proxy-group-editor.tsx
T

543 lines
17 KiB
TypeScript

"use client";
import { useDeferredValue, useState, useId, type ReactNode } from "react";
import {
DndContext,
closestCenter,
PointerSensor,
KeyboardSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ActionForm } from "@/components/action-form";
import { SubmitButton } from "@/components/submit-button";
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[];
hiddenSlugs?: 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 EditorFields({ children }: { children: ReactNode }) {
const { pending } = useFormStatus();
return (
<fieldset disabled={pending} className="stack-md min-w-0 border-0 p-0 m-0">
{children}
</fieldset>
);
}
function SortableNode({
node,
index,
removable,
onRemove,
hidden,
onToggleHidden,
}: {
node: ProxyGroupNodeOption;
index: number;
removable: boolean;
onRemove: () => void;
hidden?: boolean;
onToggleHidden?: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: node.slug });
return (
<li
ref={setNodeRef}
className="proxy-sort-item"
style={{
transform: CSS.Transform.toString(transform),
transition,
position: "relative",
zIndex: isDragging ? 2 : undefined,
background: isDragging ? "#f4f4f5" : undefined,
}}
>
<Button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
aria-label={`拖拽排序 ${node.label}`}
title="拖拽排序;键盘按空格开始,方向键移动,空格放下"
type="button"
variant="ghost"
size="icon-sm"
className="drag-handle"
>
<GripVertical size={16} />
</Button>
<span className="proxy-sort-item__index">{index + 1}</span>
<div className="stack-tight min-w-0 flex-1">
<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"
size="icon-sm"
type="button"
aria-label={`移除 ${node.label}`}
onClick={onRemove}
>
<X size={14} />
</Button>
)}
<input type="hidden" name="member_slugs" value={node.slug} />
</li>
);
}
export function ProxyGroupEditor({
mode = "custom",
group,
nodes,
}: ProxyGroupEditorProps) {
const dndId = useId();
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
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 [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, hiddenSlugs]);
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) {
setFeedback("");
setSelectedSlugs((current) => {
if (current.includes(slug)) {
return current;
}
return [...current, slug];
});
}
function removeNode(slug: string) {
setFeedback("");
setSelectedSlugs((current) =>
current.filter((currentSlug) => currentSlug !== slug),
);
}
function addAllVisibleNodes() {
setFeedback("");
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() {
setFeedback("");
setSelectedSlugs([]);
}
return (
<article className="instruction-block proxy-group-card">
<form
action={async (formData) => {
setFeedback("");
try {
await (
isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction
)(formData);
setSaved(JSON.stringify([name, selectedSlugs, hiddenSlugs]));
setFeedback("已保存");
} catch {
setFeedback("保存失败,请重试。当前修改已保留。");
}
}}
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}
<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"
value={name}
onChange={(event) => {
setName(event.target.value);
setFeedback("");
}}
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
aria-label="搜索候选节点"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
}
}}
placeholder="搜索节点名称 / slug"
/>
</label>
<Button
size="sm"
variant="outline"
type="button"
onClick={addAllVisibleNodes}
disabled={availableNodes.length === 0}
>
{normalizedQuery ? "添加筛选结果" : "全部添加"}
</Button>
</div>
</div>
{availableNodes.length > 0 ? (
<div className="proxy-node-grid">
{availableNodes.map((node) => (
<Button
variant="ghost"
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>
<span className="muted text-xs">拖动手柄排序 · 支持键盘</span>
</div>
{!isMainGroup ? (
<Button
size="sm"
variant="outline"
type="button"
onClick={clearSelection}
disabled={selectedNodes.length === 0}
>
清空已选
</Button>
) : null}
</div>
{selectedNodes.length > 0 ? (
<DndContext
id={dndId}
sensors={sensors}
collisionDetection={closestCenter}
accessibility={{
screenReaderInstructions: {
draggable:
"按空格开始拖拽,使用方向键调整顺序,再按空格放下,Escape 取消。",
},
announcements: {
onDragStart: ({ active }) =>
`开始移动 ${nodesBySlug.get(String(active.id))?.label ?? active.id}`,
onDragOver: ({ over }) =>
over
? `目标位置 ${selectedSlugs.indexOf(String(over.id)) + 1}`
: "已离开列表",
onDragEnd: ({ over }) =>
over
? `已放到第 ${selectedSlugs.indexOf(String(over.id)) + 1} 位,请保存顺序`
: "顺序未变",
onDragCancel: () => "已取消排序",
},
}}
onDragEnd={({ active, over }) => {
if (over && active.id !== over.id) {
setSelectedSlugs((current) =>
arrayMove(
current,
current.indexOf(String(active.id)),
current.indexOf(String(over.id)),
),
);
setFeedback("");
}
}}
>
<SortableContext
items={selectedSlugs}
strategy={verticalListSortingStrategy}
>
<ol className="proxy-sort-list">
{selectedNodes.map((node, index) => (
<SortableNode
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)}
/>
))}
</ol>
</SortableContext>
</DndContext>
) : (
<p className="muted proxy-group-empty">
{isMainGroup
? "暂无可用节点。"
: "还没有选择节点。先从左侧添加,再在这里调整最终顺序。"}
</p>
)}
</section>
</div>
<div className="action-row action-row--end">
<span role="status" className="muted">
{feedback || (dirty ? "有未保存的修改" : "")}
</span>
{dirty && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const [oldName, oldSlugs, oldHidden] = JSON.parse(saved) as [
string,
string[],
string[],
];
setName(oldName);
setSelectedSlugs(oldSlugs);
setHiddenSlugs(oldHidden);
setFeedback("");
}}
>
撤销修改
</Button>
)}
<SubmitButton disabled={!dirty && (isEditing || isMainGroup)}>
{isMainGroup ? "保存主分组" : isEditing ? "保存分组" : "新建分组"}
</SubmitButton>
</div>
</EditorFields>
</form>
{group && !isMainGroup ? (
<ActionForm action={deleteProxyGroupAction}>
<input type="hidden" name="id" value={group.id} />
<SubmitButton
variant="destructive"
confirm={`将删除分组「${group.name}」,节点本身不受影响。`}
>
删除分组
</SubmitButton>
</ActionForm>
) : null}
</article>
);
}