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

@@ -0,0 +1,35 @@
/**
* 智能体图标底色 —— 按 id 稳定散列到一组主题色,复刻参考图的彩色方块。
*
* 浅色模式沿用原高彩度彩色(不改);深蓝主题改用《深蓝 UI 规范》§1.1.7/§1.1.8 的蓝-青系
* (docs/ui规范-深蓝,克制、不刺眼),仅深蓝主题下生效,互不影响。
*
* 全量版通用问答左栏(GeneralQaSidebar)与 zzJC/zzXD 模块左栏(AgentModuleSidebar)共用,
* 保证彩色卡片配色完全一致。
*/
const AGENT_COLORS_LIGHT = [
{ from: "#fb7185", to: "#ef4444" },
{ from: "#60a5fa", to: "#3b82f6" },
{ from: "#fbbf24", to: "#f59e0b" },
{ from: "#34d399", to: "#10b981" },
{ from: "#a78bfa", to: "#8b5cf6" },
] as const;
const AGENT_COLORS_DARK = [
{ from: "#4096FF", to: "#1677FF" }, // 蓝(链接蓝 → 主色)
{ from: "#36CFC9", to: "#13C2C2" }, // 青(辅色)
{ from: "#6388FF", to: "#0958D9" }, // 紫蓝(装饰紫 → 主色深一档)
{ from: "#5CDBD3", to: "#08979C" }, // 浅青 → 深青
{ from: "#69B1FF", to: "#003EB3" }, // 浅蓝 → 主色按下
] as const;
/** 按 agent id 稳定散列出一对渐变色(from → to)。`isDark` 切换深蓝/浅色调色板。 */
export function agentColor(id: string, isDark: boolean) {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
const palette = isDark ? AGENT_COLORS_DARK : AGENT_COLORS_LIGHT;
return palette[hash % palette.length]!;
}
@@ -5,6 +5,8 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
import { agentColor } from "@/components/workspace/agent-icon-color";
import { useTheme } from "@/contexts/ThemeContext";
import { useAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { useAgentModuleConfig } from "@/core/system-settings/hooks";
@@ -125,6 +127,9 @@ export function AgentModuleSidebar({
}) {
const routeBase = useRouteBase();
const pathname = usePathname();
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
const { theme } = useTheme();
const isDark = theme === "dark";
const { agents } = useAgents();
const { data: threads = [] } = useThreads();
const { data: moduleConfig } = useAgentModuleConfig(moduleKey);
@@ -165,7 +170,8 @@ export function AgentModuleSidebar({
});
return (
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-card/60 backdrop-blur-sm">
// 背景按需求固定白色(不走 sidebar token),与全量版 GeneralQaSidebar 的卡片视觉看齐。
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground">
{onOpenSettings && (
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-xs font-semibold text-foreground/80">智能体</span>
@@ -174,22 +180,22 @@ export function AgentModuleSidebar({
onClick={onOpenSettings}
title="模块设置"
aria-label="模块设置"
className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors hover:bg-sidebar-accent/60"
className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors hover:bg-accent/60"
>
<Settings className="size-4" />
</button>
</div>
)}
{/* 上半:按类型分组的智能体 */}
{/* 上半:按类型分组的智能体(彩色卡片,与全量版看齐) */}
<div className="flex min-h-0 flex-[3] flex-col overflow-y-auto px-2 py-3">
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
<h2 className="mb-1 px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 select-none">
智能体类型
</h2>
{groups.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无智能体</p>
<p className="px-3 py-1.5 text-xs text-muted-foreground/70">暂无智能体</p>
) : (
<div className="flex flex-col gap-0.5">
<div className="flex flex-col gap-1.5">
{groups.map((group) => {
const isCollapsed = collapsed.has(group.key);
return (
@@ -197,11 +203,11 @@ export function AgentModuleSidebar({
<button
type="button"
onClick={() => toggleGroup(group.key)}
className="flex w-full items-center justify-between gap-1 rounded-lg px-2 py-1.5 text-xs font-semibold text-foreground/80 hover:bg-sidebar-accent/60"
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
>
<span className="flex items-center gap-1.5 truncate">
<span className="truncate">{group.label}</span>
<span className="text-muted-foreground/60 text-[10px] font-normal tabular-nums">
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{group.agents.length}
</span>
</span>
@@ -213,22 +219,48 @@ export function AgentModuleSidebar({
/>
</button>
{!isCollapsed && (
<div className="mt-0.5 flex flex-col gap-0.5 pl-1">
<div className="mt-1 flex flex-col gap-1">
{group.agents.map((agent) => {
const href = `${routeBase}/agents/${agent.id}/chats/new`;
const isActive = agent.id === activeAgentId;
const { from, to } = agentColor(agent.id, isDark);
return (
<Link
key={`${group.key}:${agent.id}`}
href={href}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60",
isActive &&
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors",
isActive
? "border-primary/40 bg-primary/5"
: "border-transparent hover:border-border hover:bg-accent/50",
)}
>
<Bot className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{desensitize(agent.name)}</span>
<span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
style={{
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
}}
>
<Bot className="size-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[13px] font-semibold",
isActive ? "text-primary" : "text-foreground",
)}
>
{desensitize(agent.name)}
</span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
{agent.description
? desensitize(agent.description)
: "智能体助手"}
</span>
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</Link>
);
})}
@@ -241,13 +273,13 @@ export function AgentModuleSidebar({
)}
</div>
{/* 下半:聊天记录(仅本模块智能体会话) */}
{/* 下半:聊天记录(仅本模块智能体会话),简单列表、配色与全量版统一 */}
<div className="flex min-h-0 flex-[2] flex-col overflow-y-auto border-t border-border/60 px-2 py-3">
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
<h2 className="mb-1 px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 select-none">
聊天记录
</h2>
{moduleThreads.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无聊天记录</p>
<p className="px-3 py-1.5 text-xs text-muted-foreground/70">暂无聊天记录</p>
) : (
<div className="flex flex-col gap-0.5">
{moduleThreads.map((thread) => {
@@ -258,15 +290,24 @@ export function AgentModuleSidebar({
key={thread.thread_id}
href={href}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60",
isActive &&
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
"relative flex h-9 min-w-0 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
isActive
? "border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground/70" />
<MessageSquare
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
<span className="truncate">
{desensitize(titleOfThread(thread, "未命名对话"))}
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</Link>
);
})}
@@ -86,6 +86,7 @@ import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { agentColor } from "./agent-icon-color";
import { SidebarCollapseButton } from "./workspace-header";
const NEW_CHAT_PATH = "/page/workspace/chats/new";
@@ -96,33 +97,8 @@ const MORE_CHATS_HREF = "/page/workspace/chats";
/** 常用智能体最多展示个数。 */
const MAX_FREQUENT = 4;
// 智能体图标统一用 Bot 机器人图标;底色按 id 稳定散列到一组主题色,复刻参考图的彩色方块。
// 浅色模式沿用原高彩度彩色(不改);深蓝主题改用《深蓝 UI 规范》§1.1.7/§1.1.8 的蓝-青系
// (docs/ui规范-深蓝,克制、不刺眼),仅深蓝主题下生效,互不影响。
const AGENT_COLORS_LIGHT = [
{ from: "#fb7185", to: "#ef4444" },
{ from: "#60a5fa", to: "#3b82f6" },
{ from: "#fbbf24", to: "#f59e0b" },
{ from: "#34d399", to: "#10b981" },
{ from: "#a78bfa", to: "#8b5cf6" },
] as const;
const AGENT_COLORS_DARK = [
{ from: "#4096FF", to: "#1677FF" }, // 蓝(链接蓝 → 主色)
{ from: "#36CFC9", to: "#13C2C2" }, // 青(辅色)
{ from: "#6388FF", to: "#0958D9" }, // 紫蓝(装饰紫 → 主色深一档)
{ from: "#5CDBD3", to: "#08979C" }, // 浅青 → 深青
{ from: "#69B1FF", to: "#003EB3" }, // 浅蓝 → 主色按下
] as const;
function agentColor(id: string, isDark: boolean) {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
const palette = isDark ? AGENT_COLORS_DARK : AGENT_COLORS_LIGHT;
return palette[hash % palette.length]!;
}
// 智能体图标配色(按 id 稳定散列、分主题)抽到共享模块 ./agent-icon-color,
// 与 zzJC/zzXD 模块左栏(AgentModuleSidebar)共用,保证彩色卡片配色完全一致。
/** 折叠区块标题(带 chevron 与可选计数徽标)。 */
function SectionLabel({
+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}