543 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|