feat: update knowledge workspace and menu configuration

This commit is contained in:
吉鑫升 committed 2026-08-18 15:23:14 +08:00
1 parent 64a5305bbf
commit 8af4fed89d
28 files changed
+1250 -219

No files matched your search

@@ -116,7 +116,7 @@ export default function MarkdownDocxEditorPage() {
<header className="shrink-0 border-b px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h1 className="text-xl font-semibold">Markdown 转 Word</h1>
<h1 className="text-xl font-semibold">md转word</h1>
<p className="mt-1 text-sm text-muted-foreground">
上传 md 文件后在线编辑,确认内容后下载为 Word。
</p>
@@ -41,6 +41,7 @@ import {
Info,
Menu,
Pencil,
Settings,
RotateCcw,
Save,
Search,
@@ -65,6 +66,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { WORKSPACE_COMPACT_NAV_ITEMS } from "@/components/workspace/workspace-nav-chat-list";
import { getStoredAuth } from "@/core/auth";
import {
MAX_MENU_DEPTH,
@@ -76,8 +78,16 @@ import {
type MenuOverride,
type MergedMenuNode,
} from "@/core/page-layout/menu-overrides";
import type {
CompactMenuOverride,
CompactMenuPlacement,
} from "@/core/page-layout/compact-menu-overrides";
import { sidebarMenuItems, type MenuItem } from "@/core/page-layout/sidebar-menu";
import { injectLightApps } from "@/core/page-layout/use-sidebar-menu";
import {
useCompactMenuOverrideActions,
useCompactMenuOverrides,
} from "@/strategy-components/api/compact-menu-overrides";
import { useLightApps } from "@/strategy-components/api/light-apps";
import {
useMenuOverrideActions,
@@ -99,6 +109,19 @@ interface FlatRow {
isLightApp: boolean;
}
interface CompactRow {
id: string;
topId: string;
topIndex: number;
topLabel: string;
label: string;
defaultLabel: string;
defaultIndex: number;
order: number;
placement: CompactMenuPlacement;
source: MenuItem;
}
// ── 扁平行工具 ───────────────────────────────────────────────────────────
/** 按 parentId 分组、DFS 重排,使每个节点的子节点紧跟其后(同级保持相遇顺序)。 */
@@ -190,6 +213,101 @@ function depthByIdOf(rows: FlatRow[]): Map<string, number> {
return cache;
}
function findFirstRoutePath(items?: MenuItem[]): string | null {
if (!items?.length) {
return null;
}
for (const child of items) {
if (child.path) {
return child.path;
}
const nested = findFirstRoutePath(child.children);
if (nested) {
return nested;
}
}
return null;
}
function hasRoute(item: MenuItem): boolean {
return Boolean(item.path ?? findFirstRoutePath(item.children));
}
function compactRowsFromTree(tree: MenuItem[], overrides: CompactMenuOverride[]): CompactRow[] {
const byId = new Map(overrides.map((override) => [override.nodeId, override]));
const rows: CompactRow[] = [];
tree.forEach((top, topIndex) => {
(top.children ?? [])
.filter(hasRoute)
.forEach((item, index) => {
const override = byId.get(item.id);
rows.push({
id: item.id,
topId: top.id,
topIndex,
topLabel: top.label,
label: override?.label != null && override.label !== "" ? override.label : item.label,
defaultLabel: item.label,
defaultIndex: index,
order: override?.sortOrder ?? index,
placement: override?.placement ?? "sidebar",
source: item,
});
});
});
return normalizeCompactRows(rows);
}
function normalizeCompactRows(rows: CompactRow[]): CompactRow[] {
return rows
.slice()
.sort(
(a, b) =>
a.topIndex - b.topIndex ||
Number(a.placement === "settings") - Number(b.placement === "settings") ||
a.order - b.order ||
a.id.localeCompare(b.id),
);
}
function compactOverridesFromRows(rows: CompactRow[]): CompactMenuOverride[] {
const grouped = new Map<string, CompactRow[]>();
for (const row of rows) {
const key = `${row.topId}:${row.placement}`;
const list = grouped.get(key) ?? [];
list.push(row);
grouped.set(key, list);
}
const orderById = new Map<string, number>();
for (const list of grouped.values()) {
list.forEach((row, index) => orderById.set(row.id, index));
}
return rows.flatMap((row) => {
const sortOrder = orderById.get(row.id) ?? row.defaultIndex;
const renamed = row.label !== row.defaultLabel;
const moved = row.placement !== "sidebar";
const reordered = row.placement === "sidebar" && sortOrder !== row.defaultIndex;
if (!renamed && !moved && !reordered) return [];
return {
nodeId: row.id,
placement: row.placement,
sortOrder,
label: renamed ? row.label : null,
};
});
}
function sameOverrides(a: CompactMenuOverride[], b: CompactMenuOverride[]): boolean {
const norm = (list: CompactMenuOverride[]) =>
JSON.stringify(
list
.map((o) => ({ ...o, label: o.label ?? null }))
.sort((x, y) => x.nodeId.localeCompare(y.nodeId)),
);
return norm(a) === norm(b);
}
// ── 可排序行 ─────────────────────────────────────────────────────────────
interface SortableRowProps {
@@ -377,12 +495,24 @@ function SortableRow({
export default function MenuManagementPage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [mode, setMode] = useState<"full" | "compact">("compact");
// 与侧边栏一致:只取「启用的 iframe 轻应用」作为合并基树,避免管理树与实际渲染脱节。
const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true });
const { data: overrides = [], isLoading } = useMenuOverrides();
const { save } = useMenuOverrideActions();
const baseTree = useMemo(() => injectLightApps(sidebarMenuItems, iframeApps), [iframeApps]);
const compactWorkspaceTree = useMemo<MenuItem[]>(
() => [
{
id: "workspace-compact-nav",
label: "简介模式菜单",
icon: Settings,
children: WORKSPACE_COMPACT_NAV_ITEMS,
},
],
[],
);
// 初始扁平行(套用已保存覆盖层,含停用项)。
const initialRows = useMemo(
@@ -627,9 +757,44 @@ export default function MenuManagementPage() {
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
const modeTabs = (
<div className="mb-6 inline-flex rounded-2xl border border-slate-200 bg-white p-1 text-sm font-semibold shadow-sm">
<button
type="button"
onClick={() => setMode("full")}
className={`rounded-xl px-4 py-2 transition-all ${
mode === "full" ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`}
>
全量模式
</button>
<button
type="button"
onClick={() => setMode("compact")}
className={`rounded-xl px-4 py-2 transition-all ${
mode === "compact" ? "bg-red-600 text-white shadow-sm" : "text-slate-500 hover:text-slate-800"
}`}
>
简介模式
</button>
</div>
);
if (mode === "compact") {
return (
<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">
{modeTabs}
<CompactMenuManagementPanel baseTree={compactWorkspaceTree} />
</div>
</div>
);
}
return (
<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">
{modeTabs}
{/* 顶部信息卡 */}
<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]">
@@ -809,6 +974,189 @@ export default function MenuManagementPage() {
);
}
function CompactMenuManagementPanel({ baseTree }: { baseTree: MenuItem[] }) {
const { data: overrides = [], isLoading } = useCompactMenuOverrides();
const { save } = useCompactMenuOverrideActions();
const initialRows = useMemo(() => compactRowsFromTree(baseTree, overrides), [baseTree, overrides]);
const [rows, setRows] = useState<CompactRow[]>(initialRows);
const [loadedKey, setLoadedKey] = useState("");
const sourceKey = useMemo(
() =>
JSON.stringify({
tree: baseTree.flatMap((top) =>
(top.children ?? []).map((child) => [top.id, child.id, child.label]),
),
overrides,
}),
[baseTree, overrides],
);
useEffect(() => {
if (sourceKey !== loadedKey) {
setRows(initialRows);
setLoadedKey(sourceKey);
}
}, [sourceKey, loadedKey, initialRows]);
const currentOverrides = useMemo(() => compactOverridesFromRows(rows), [rows]);
const dirty = useMemo(() => !sameOverrides(currentOverrides, overrides), [currentOverrides, overrides]);
const grouped = useMemo(() => {
const groups = new Map<string, { label: string; rows: CompactRow[] }>();
for (const row of rows) {
const group = groups.get(row.topId) ?? { label: row.topLabel, rows: [] };
group.rows.push(row);
groups.set(row.topId, group);
}
return Array.from(groups.entries()).map(([id, group]) => ({ id, ...group }));
}, [rows]);
const updateRow = (id: string, patch: Partial<CompactRow>) =>
setRows((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row)));
const moveRow = (row: CompactRow, direction: -1 | 1) => {
setRows((prev) => {
const sameLane = prev.filter((item) => item.topId === row.topId && item.placement === row.placement);
const laneIndex = sameLane.findIndex((item) => item.id === row.id);
const swapWith = sameLane[laneIndex + direction];
if (!swapWith) return prev;
const next = prev.slice();
const a = next.findIndex((item) => item.id === row.id);
const b = next.findIndex((item) => item.id === swapWith.id);
[next[a], next[b]] = [next[b], next[a]];
return next;
});
};
const handleSave = async () => {
try {
await save.mutateAsync(currentOverrides);
toast.success("简介菜单配置已保存");
} catch (err) {
toast.error(err instanceof Error ? err.message : "保存失败");
}
};
const handleReset = () => {
if (!confirm("确定要重置简介模式菜单为默认布局吗?保存后将清除简介模式自定义配置。")) return;
setRows(compactRowsFromTree(baseTree, []));
};
return (
<>
<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]">
<Settings 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 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"
>
<RotateCcw className="size-[15px]" />
重置全部
</button>
<button
type="button"
onClick={handleSave}
disabled={!dirty || save.isPending}
className="inline-flex 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 disabled:cursor-not-allowed disabled:opacity-50"
>
<Save className="size-[15px]" />
{save.isPending ? "保存中…" : "保存修改"}
</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>这里配置的是简介模式工作区左侧菜单:可改显示名称、调整顺序,或迁移到「设置和更多」下拉中;全量模式菜单不受影响。</span>
</p>
</div>
</div>
<div className="space-y-5">
{isLoading ? (
<p className="rounded-3xl border border-slate-200/60 bg-white py-20 text-center text-sm text-slate-400">加载中…</p>
) : (
grouped.map((group) => (
<section key={group.id} className="rounded-3xl border border-slate-200/60 bg-white p-4 shadow-sm md:p-5">
<h2 className="mb-4 text-base font-bold text-slate-800">{group.label}</h2>
<div className="flex flex-col gap-3">
{group.rows.map((row) => {
const lane = group.rows.filter((item) => item.placement === row.placement);
const laneIndex = lane.findIndex((item) => item.id === row.id);
const Icon = row.source.icon;
return (
<div key={row.id} className="grid gap-3 rounded-2xl border border-slate-200/70 bg-slate-50/50 p-3 md:grid-cols-[1fr_160px_auto] md:items-center">
<div className="flex min-w-0 items-center gap-3">
<span className="shrink-0 rounded-xl bg-red-50 p-2.5 text-red-600">
<Icon className="size-[18px]" />
</span>
<Input
value={row.label}
onChange={(e) => updateRow(row.id, { label: e.target.value })}
className="bg-white"
/>
</div>
<Select
value={row.placement}
onValueChange={(value) => updateRow(row.id, { placement: value as CompactMenuPlacement })}
>
<SelectTrigger className="bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sidebar">左侧菜单</SelectItem>
<SelectItem value="settings">设置和更多</SelectItem>
</SelectContent>
</Select>
<div className="flex justify-end gap-1.5">
<button
type="button"
onClick={() => moveRow(row, -1)}
disabled={laneIndex <= 0}
className="rounded-lg border border-slate-200 bg-white p-2 text-slate-500 transition-colors hover:text-slate-800 disabled:opacity-30"
title="上移"
>
<ChevronUp className="size-4" />
</button>
<button
type="button"
onClick={() => moveRow(row, 1)}
disabled={laneIndex < 0 || laneIndex >= lane.length - 1}
className="rounded-lg border border-slate-200 bg-white p-2 text-slate-500 transition-colors hover:text-slate-800 disabled:opacity-30"
title="下移"
>
<ChevronDown className="size-4" />
</button>
</div>
</div>
);
})}
</div>
</section>
))
)}
</div>
</>
);
}
// ── 编辑弹窗 ─────────────────────────────────────────────────────────────
interface EditMenuDialogProps {