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:
董宏凯andClaude Opus 4.8 committed 2026-06-17 13:11:27 +08:00
1 parent 1c8b5b23a1
commit ef1a12ab74
4 files changed
+472 -149

No files matched your search

+372 -101
View File
@@ -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}