feat: update knowledge workspace and menu configuration
This commit is contained in:
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 {
|
||||
|
||||
Reference in new issue
Block a user