深度优化 ui
This commit is contained in:
@@ -1,6 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useDeferredValue, useState } from "react";
|
||||
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 {
|
||||
@@ -55,33 +76,93 @@ function normalizeSelectedSlugs(
|
||||
return selectedSlugs;
|
||||
}
|
||||
|
||||
function SaveButton({ isEditing, isMainGroup }: { isEditing: boolean; isMainGroup: boolean }) {
|
||||
function EditorFields({ children }: { children: ReactNode }) {
|
||||
const { pending } = useFormStatus();
|
||||
const label = isMainGroup ? "保存主组顺序" : isEditing ? "保存分组" : "新建分组";
|
||||
const pendingLabel = isMainGroup ? "保存中..." : isEditing ? "保存中..." : "创建中...";
|
||||
|
||||
return (
|
||||
<button
|
||||
className={isEditing && !isMainGroup ? "ghost-button" : "primary-button"}
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
<fieldset disabled={pending} className="stack-md min-w-0 border-0 p-0 m-0">
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableNode({
|
||||
node,
|
||||
index,
|
||||
removable,
|
||||
onRemove,
|
||||
}: {
|
||||
node: ProxyGroupNodeOption;
|
||||
index: number;
|
||||
removable: boolean;
|
||||
onRemove: () => 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,
|
||||
}}
|
||||
>
|
||||
{pending ? pendingLabel : label}
|
||||
</button>
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
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 dndId = useId();
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEditorProps) {
|
||||
const isMainGroup = mode === "main";
|
||||
const isEditing = Boolean(group) && !isMainGroup;
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -89,6 +170,15 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
||||
const [selectedSlugs, setSelectedSlugs] = useState(() =>
|
||||
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
|
||||
);
|
||||
const [name, setName] = useState(group?.name ?? "");
|
||||
const [saved, setSaved] = useState(() =>
|
||||
JSON.stringify([
|
||||
group?.name ?? "",
|
||||
normalizeSelectedSlugs(group?.memberSlugs ?? [], nodes, isMainGroup),
|
||||
]),
|
||||
);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const dirty = saved !== JSON.stringify([name, selectedSlugs]);
|
||||
const selectedSlugSet = new Set(selectedSlugs);
|
||||
const nodesBySlug = new Map(nodes.map((node) => [node.slug, node]));
|
||||
const selectedNodes = selectedSlugs
|
||||
@@ -111,6 +201,7 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
||||
});
|
||||
|
||||
function addNode(slug: string) {
|
||||
setFeedback("");
|
||||
setSelectedSlugs((current) => {
|
||||
if (current.includes(slug)) {
|
||||
return current;
|
||||
@@ -121,25 +212,14 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
setFeedback("");
|
||||
setSelectedSlugs((current) =>
|
||||
current.filter((currentSlug) => currentSlug !== slug),
|
||||
);
|
||||
}
|
||||
|
||||
function addAllVisibleNodes() {
|
||||
setFeedback("");
|
||||
setSelectedSlugs((current) => {
|
||||
const next = [...current];
|
||||
const seen = new Set(current);
|
||||
@@ -158,170 +238,255 @@ export function ProxyGroupEditor({ mode = "custom", group, nodes }: ProxyGroupEd
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setFeedback("");
|
||||
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>
|
||||
<form
|
||||
action={async (formData) => {
|
||||
setFeedback("");
|
||||
try {
|
||||
await (
|
||||
isMainGroup ? saveMainProxyGroupAction : saveProxyGroupAction
|
||||
)(formData);
|
||||
setSaved(JSON.stringify([name, selectedSlugs]));
|
||||
setFeedback("已保存");
|
||||
} catch {
|
||||
setFeedback("保存失败,请重试。当前修改已保留。");
|
||||
}
|
||||
}}
|
||||
className="stack-md"
|
||||
>
|
||||
<EditorFields>
|
||||
{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>
|
||||
<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">
|
||||
<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>候选节点</strong>
|
||||
<strong>{isMainGroup ? "节点顺序" : "已选顺序"}</strong>
|
||||
<span className="muted text-xs">拖动手柄排序 · 支持键盘</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"
|
||||
{!isMainGroup ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={addAllVisibleNodes}
|
||||
disabled={availableNodes.length === 0}
|
||||
onClick={clearSelection}
|
||||
disabled={selectedNodes.length === 0}
|
||||
>
|
||||
{normalizedQuery ? "添加筛选结果" : "全部添加"}
|
||||
</button>
|
||||
</div>
|
||||
清空已选
|
||||
</Button>
|
||||
) : null}
|
||||
</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>
|
||||
{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}
|
||||
removable={!isMainGroup}
|
||||
onRemove={() => removeNode(node.slug)}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<p className="muted proxy-group-empty">
|
||||
{remainingCount === 0
|
||||
? "所有可用节点都已加入当前分组。"
|
||||
: "没有匹配当前筛选条件的节点。"}
|
||||
{isMainGroup
|
||||
? "暂无可用节点。"
|
||||
: "还没有选择节点。先从左侧添加,再在这里调整最终顺序。"}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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] = JSON.parse(saved) as [
|
||||
string,
|
||||
string[],
|
||||
];
|
||||
setName(oldName);
|
||||
setSelectedSlugs(oldSlugs);
|
||||
setFeedback("");
|
||||
}}
|
||||
>
|
||||
撤销修改
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="action-row action-row--end">
|
||||
<SaveButton isEditing={isEditing} isMainGroup={isMainGroup} />
|
||||
</div>
|
||||
<SubmitButton disabled={!dirty && (isEditing || isMainGroup)}>
|
||||
{isMainGroup ? "保存顺序" : isEditing ? "保存分组" : "新建分组"}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</EditorFields>
|
||||
</form>
|
||||
|
||||
{group && !isMainGroup ? (
|
||||
<form action={deleteProxyGroupAction}>
|
||||
<ActionForm action={deleteProxyGroupAction}>
|
||||
<input type="hidden" name="id" value={group.id} />
|
||||
<DeleteButton />
|
||||
</form>
|
||||
<SubmitButton
|
||||
variant="destructive"
|
||||
confirm={`将删除分组「${group.name}」,节点本身不受影响。`}
|
||||
>
|
||||
删除分组
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user