feat: zzJC/zzXD 左栏智能体列表对齐全量版彩色卡片样式
- 抽出共享配色助手 agent-icon-color.ts(按 id 散列、分浅色/深蓝主题), general-qa-sidebar 与 agent-module-sidebar 共用 - AgentModuleSidebar:背景改白底、去毛玻璃;智能体项改为彩色渐变图标卡片 (图标+名称+两行简介+选中态主色描边/右侧主色条);分组标题与聊天记录配色统一 - 保留按类型分组/折叠与会话过滤逻辑 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
1c8b5b23a1
commit
ef1a12ab74
4 files changed
+472
-149
No files matched your search
@@ -33,15 +33,21 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ChevronUp,
|
||||
GripVertical,
|
||||
Info,
|
||||
Menu,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -191,17 +197,45 @@ interface SortableRowProps {
|
||||
depth: number;
|
||||
canUp: boolean;
|
||||
canDown: boolean;
|
||||
canIndent: boolean;
|
||||
canOutdent: boolean;
|
||||
dragEnabled: boolean;
|
||||
onUp: () => void;
|
||||
onDown: () => void;
|
||||
onIndent: () => void;
|
||||
onOutdent: () => void;
|
||||
onToggle: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
function SortableRow({ row, depth, canUp, canDown, onUp, onDown, onEdit }: SortableRowProps) {
|
||||
/** 一级 / 二级 / 三级 的层级色标。 */
|
||||
const LEVEL_BADGE = [
|
||||
"bg-blue-50 text-blue-600 border border-blue-100",
|
||||
"bg-purple-50 text-purple-600 border border-purple-100",
|
||||
"bg-indigo-50 text-indigo-600 border border-indigo-100",
|
||||
];
|
||||
|
||||
function SortableRow({
|
||||
row,
|
||||
depth,
|
||||
canUp,
|
||||
canDown,
|
||||
canIndent,
|
||||
canOutdent,
|
||||
dragEnabled,
|
||||
onUp,
|
||||
onDown,
|
||||
onIndent,
|
||||
onOutdent,
|
||||
onToggle,
|
||||
onEdit,
|
||||
}: SortableRowProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: row.id,
|
||||
});
|
||||
const Icon = row.source.icon;
|
||||
const levelLabel = ["一级", "二级", "三级"][depth - 1] ?? `${depth}级`;
|
||||
const typeLabel = row.isLightApp ? "应用" : "内置";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -209,47 +243,131 @@ function SortableRow({ row, depth, canUp, canDown, onUp, onDown, onEdit }: Sorta
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
paddingLeft: (depth - 1) * INDENT,
|
||||
marginLeft: (depth - 1) * INDENT,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-md border border-border/60 bg-card px-2 py-1.5"
|
||||
className={`group relative flex flex-col gap-3 rounded-2xl border p-3.5 transition-all md:flex-row md:items-center md:justify-between ${
|
||||
row.disabled
|
||||
? "border-slate-100 bg-slate-50/40"
|
||||
: "border-slate-200/70 bg-white shadow-[0_1px_4px_-1px_rgba(0,0,0,0.04)] hover:border-slate-300 hover:shadow-[0_4px_16px_-4px_rgba(0,0,0,0.08)]"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
|
||||
title="拖拽调整顺序 / 层级"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
<Icon className={`size-4 ${row.disabled ? "text-muted-foreground/50" : "text-foreground/70"}`} />
|
||||
<span className={`truncate text-sm ${row.disabled ? "text-muted-foreground line-through" : ""}`}>
|
||||
{row.label}
|
||||
</span>
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
{levelLabel}
|
||||
</Badge>
|
||||
{row.isLightApp && (
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||
应用
|
||||
</Badge>
|
||||
)}
|
||||
{row.disabled && (
|
||||
<Badge variant="outline" className="shrink-0 border-amber-400 text-[10px] text-amber-600">
|
||||
已停用
|
||||
</Badge>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="size-7" disabled={!canUp} onClick={onUp} title="上移">
|
||||
<ChevronUp className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" disabled={!canDown} onClick={onDown} title="下移">
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={onEdit} title="编辑">
|
||||
{/* 左侧:拖拽柄 + 图标 + 名称/路径 */}
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={`shrink-0 rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 ${
|
||||
dragEnabled ? "cursor-grab touch-none active:cursor-grabbing" : "cursor-not-allowed opacity-40"
|
||||
}`}
|
||||
title={dragEnabled ? "拖动调整顺序与层级" : "筛选状态下不可拖动"}
|
||||
{...(dragEnabled ? attributes : {})}
|
||||
{...(dragEnabled ? listeners : {})}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
|
||||
{depth > 1 && (
|
||||
<ChevronRight className="hidden size-3.5 shrink-0 stroke-[2.5] text-slate-300 sm:block" />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className={`shrink-0 rounded-xl p-2.5 transition-all ${
|
||||
row.disabled
|
||||
? "bg-slate-100 text-slate-400"
|
||||
: "bg-red-50 text-red-600 hover:scale-105 hover:bg-red-100"
|
||||
}`}
|
||||
title="点击修改菜单详情"
|
||||
>
|
||||
<Icon className="size-[18px]" />
|
||||
</button>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className={`truncate text-sm font-semibold transition-colors hover:text-red-600 ${
|
||||
row.disabled ? "text-slate-400 line-through" : "text-slate-800"
|
||||
}`}
|
||||
>
|
||||
{row.label}
|
||||
</button>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${LEVEL_BADGE[depth - 1] ?? LEVEL_BADGE[2]}`}
|
||||
>
|
||||
{levelLabel}
|
||||
</span>
|
||||
<span className="rounded border border-slate-200/60 bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] font-medium text-slate-500">
|
||||
{typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:层级/排序按钮组 + 状态 + 编辑 */}
|
||||
<div className="flex items-center justify-between gap-2.5 border-t border-slate-100 pt-2.5 md:justify-end md:border-t-0 md:pt-0">
|
||||
<div className="flex items-center rounded-xl border border-slate-200/70 bg-slate-50/80 p-0.5 text-slate-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOutdent}
|
||||
disabled={!canOutdent}
|
||||
className="rounded-lg p-1.5 transition-colors hover:bg-white hover:text-slate-800 disabled:opacity-20 disabled:hover:bg-transparent"
|
||||
title="升一级"
|
||||
>
|
||||
<ChevronsLeft className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onIndent}
|
||||
disabled={!canIndent}
|
||||
className="rounded-lg p-1.5 transition-colors hover:bg-white hover:text-slate-800 disabled:opacity-20 disabled:hover:bg-transparent"
|
||||
title="降一级"
|
||||
>
|
||||
<ChevronsRight className="size-3.5" />
|
||||
</button>
|
||||
<div className="mx-1 h-4 w-px bg-slate-200" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUp}
|
||||
disabled={!canUp}
|
||||
className="rounded-lg p-1.5 transition-colors hover:bg-white hover:text-slate-800 disabled:opacity-20 disabled:hover:bg-transparent"
|
||||
title="上移"
|
||||
>
|
||||
<ChevronUp className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDown}
|
||||
disabled={!canDown}
|
||||
className="rounded-lg p-1.5 transition-colors hover:bg-white hover:text-slate-800 disabled:opacity-20 disabled:hover:bg-transparent"
|
||||
title="下移"
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`inline-flex select-none items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-semibold transition-all ${
|
||||
row.disabled
|
||||
? "bg-slate-100 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
||||
: "border border-emerald-200/50 bg-emerald-50 text-emerald-600 hover:bg-emerald-100"
|
||||
}`}
|
||||
title="点击切换启用 / 停用"
|
||||
>
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${row.disabled ? "bg-slate-400" : "bg-emerald-500"}`} />
|
||||
{row.disabled ? "停用中" : "启用中"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -277,6 +395,8 @@ export default function MenuManagementPage() {
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
|
||||
|
||||
// 数据首次/变化加载时,用服务端值重置工作副本(避免覆盖用户未保存的编辑:以来源 key 判定)。
|
||||
const sourceKey = useMemo(
|
||||
@@ -308,6 +428,19 @@ export default function MenuManagementPage() {
|
||||
|
||||
const editingRow = rows.find((r) => r.id === editId) ?? null;
|
||||
|
||||
const isFiltering = query.trim() !== "" || statusFilter !== "all";
|
||||
const visibleRows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return rows.filter((r) => {
|
||||
const matchQ = !q || r.label.toLowerCase().includes(q);
|
||||
const matchS =
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "active" && !r.disabled) ||
|
||||
(statusFilter === "inactive" && r.disabled);
|
||||
return matchQ && matchS;
|
||||
});
|
||||
}, [rows, query, statusFilter]);
|
||||
|
||||
// ── 行操作 ──
|
||||
const siblingsOf = (parentId: string | null) => rows.filter((r) => (r.parentId ?? null) === (parentId ?? null));
|
||||
|
||||
@@ -331,6 +464,44 @@ export default function MenuManagementPage() {
|
||||
if (i >= 0 && i < sibs.length - 1) reorderBefore(sibs[i + 1].id, row.id);
|
||||
};
|
||||
|
||||
/** 降一级(缩进):成为「上一个同级兄弟」的末位子节点。 */
|
||||
const indentRow = (row: FlatRow) => {
|
||||
const sibs = siblingsOf(row.parentId);
|
||||
const i = sibs.findIndex((s) => s.id === row.id);
|
||||
if (i <= 0) return;
|
||||
const newParent = sibs[i - 1].id;
|
||||
if (wouldExceedDepth(row.id, newParent, mergedNow)) {
|
||||
toast.error(`移动后将超过 ${MAX_MENU_DEPTH} 级,已阻止`);
|
||||
return;
|
||||
}
|
||||
setRows((prev) => {
|
||||
const next = prev.map((r) => (r.id === row.id ? { ...r, parentId: newParent } : r));
|
||||
const idx = next.findIndex((r) => r.id === row.id);
|
||||
const [moved] = next.splice(idx, 1);
|
||||
next.push(moved);
|
||||
return normalizeDfs(next);
|
||||
});
|
||||
};
|
||||
|
||||
/** 升一级(移位):移动到「父级的父级」下,紧跟原父级之后。 */
|
||||
const outdentRow = (row: FlatRow) => {
|
||||
if (row.parentId == null) return;
|
||||
const parentRow = rows.find((r) => r.id === row.parentId);
|
||||
if (!parentRow) return;
|
||||
const newParent = parentRow.parentId ?? null;
|
||||
setRows((prev) => {
|
||||
const next = prev.map((r) => (r.id === row.id ? { ...r, parentId: newParent } : r));
|
||||
const idx = next.findIndex((r) => r.id === row.id);
|
||||
const [moved] = next.splice(idx, 1);
|
||||
const pIdx = next.findIndex((r) => r.id === parentRow.id);
|
||||
next.splice(pIdx + 1, 0, moved);
|
||||
return normalizeDfs(next);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRow = (row: FlatRow) =>
|
||||
setRows((prev) => prev.map((r) => (r.id === row.id ? { ...r, disabled: !r.disabled } : r)));
|
||||
|
||||
const applyEdit = (label: string, disabled: boolean, newParentId: string | null) => {
|
||||
if (!editingRow) return;
|
||||
const id = editingRow.id;
|
||||
@@ -457,72 +628,172 @@ export default function MenuManagementPage() {
|
||||
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-3xl flex-col gap-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">菜单管理</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
拖拽调整顺序与层级(横向拖动改变层级,最多 {MAX_MENU_DEPTH} 级);或用「编辑」改名、停用、移动到指定父级。
|
||||
</p>
|
||||
<div className="h-full overflow-auto bg-slate-50/60 px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* 顶部信息卡 */}
|
||||
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="pointer-events-none absolute right-0 top-0 select-none p-8 opacity-[0.03]">
|
||||
<Menu className="size-44 text-slate-900" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="mb-4 flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
|
||||
<Sparkles className="size-3" />
|
||||
系统集成版
|
||||
</span>
|
||||
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
|
||||
菜单管理
|
||||
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
|
||||
{rows.length} 个项目
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-600 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 hover:text-slate-800"
|
||||
title="恢复为默认菜单布局"
|
||||
>
|
||||
<RotateCcw className="size-[15px]" />
|
||||
重置全部
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || save.isPending}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-red-600 bg-red-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-red-700 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{save.isPending ? (
|
||||
<>
|
||||
<div className="size-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
保存中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-[15px]" />
|
||||
保存修改
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||
<span>
|
||||
<strong>拖拽调整</strong> 菜单顺序与层级(按住拖拽地标 <span className="font-mono">::</span>{" "}
|
||||
上下排序,横向拖动改变层级);也可点击卡片上的<strong> 缩进 / 移位按钮 </strong>
|
||||
精确调整层级(最多 {MAX_MENU_DEPTH} 级)、启停与编辑。
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="size-4" />
|
||||
重置全部
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!dirty || save.isPending}>
|
||||
<Save className="size-4" />
|
||||
{save.isPending ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
|
||||
{/* 搜索 + 状态筛选栏 */}
|
||||
<div className="mb-6 flex flex-col items-center justify-between gap-4 rounded-2xl border border-slate-200/60 bg-white p-4 shadow-sm md:flex-row md:p-5">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索菜单名称…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-4 text-sm transition-all hover:border-slate-300 focus:border-red-500 focus:outline-none focus:ring-4 focus:ring-red-500/5"
|
||||
/>
|
||||
</div>
|
||||
<div className="inline-flex items-center rounded-xl border border-slate-200/40 bg-slate-100/80 p-1 text-xs font-medium">
|
||||
{(
|
||||
[
|
||||
["all", "全部"],
|
||||
["active", "已启用"],
|
||||
["inactive", "已停用"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(key)}
|
||||
className={`cursor-pointer rounded-lg px-3.5 py-1.5 transition-all ${
|
||||
statusFilter === key
|
||||
? "bg-white font-semibold text-slate-800 shadow-sm"
|
||||
: "text-slate-500 hover:text-slate-800"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 菜单列表卡 */}
|
||||
<div className="min-h-[400px] rounded-3xl border border-slate-200/60 bg-white p-4 shadow-sm md:p-6">
|
||||
{isLoading ? (
|
||||
<p className="py-20 text-center text-sm text-slate-400">加载中…</p>
|
||||
) : visibleRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="mb-4 rounded-full bg-slate-100 p-4 text-slate-400">
|
||||
<Menu className="size-8" />
|
||||
</div>
|
||||
<h4 className="mb-1 text-base font-semibold text-slate-700">无符合条件的菜单项</h4>
|
||||
<p className="max-w-xs text-sm text-slate-400">尝试清空搜索或筛选条件以查看全部菜单。</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={() => {
|
||||
setActiveId(null);
|
||||
setOffsetX(0);
|
||||
}}
|
||||
>
|
||||
<SortableContext
|
||||
items={visibleRows.map((r) => r.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{visibleRows.map((row) => {
|
||||
const sibs = siblingsOf(row.parentId);
|
||||
const i = sibs.findIndex((s) => s.id === row.id);
|
||||
const canIndent = i > 0 && !wouldExceedDepth(row.id, sibs[i - 1].id, mergedNow);
|
||||
return (
|
||||
<SortableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
depth={depthById.get(row.id) ?? 1}
|
||||
canUp={i > 0}
|
||||
canDown={i >= 0 && i < sibs.length - 1}
|
||||
canIndent={canIndent}
|
||||
canOutdent={row.parentId != null}
|
||||
dragEnabled={!isFiltering}
|
||||
onUp={() => moveUp(row)}
|
||||
onDown={() => moveDown(row)}
|
||||
onIndent={() => indentRow(row)}
|
||||
onOutdent={() => outdentRow(row)}
|
||||
onToggle={() => toggleRow(row)}
|
||||
onEdit={() => setEditId(row.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeId ? (
|
||||
<div className="rounded-2xl border border-red-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-800 shadow-lg">
|
||||
{rows.find((r) => r.id === activeId)?.label}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">加载中…</p>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={() => {
|
||||
setActiveId(null);
|
||||
setOffsetX(0);
|
||||
}}
|
||||
>
|
||||
<SortableContext items={rows.map((r) => r.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{rows.map((row) => {
|
||||
const sibs = siblingsOf(row.parentId);
|
||||
const i = sibs.findIndex((s) => s.id === row.id);
|
||||
return (
|
||||
<SortableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
depth={depthById.get(row.id) ?? 1}
|
||||
canUp={i > 0}
|
||||
canDown={i >= 0 && i < sibs.length - 1}
|
||||
onUp={() => moveUp(row)}
|
||||
onDown={() => moveDown(row)}
|
||||
onEdit={() => setEditId(row.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeId ? (
|
||||
<div className="rounded-md border bg-card px-3 py-1.5 text-sm shadow-lg">
|
||||
{rows.find((r) => r.id === activeId)?.label}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingRow && (
|
||||
<EditMenuDialog
|
||||
key={editingRow.id}
|
||||
|
||||
Reference in new issue
Block a user