diff --git a/.gitignore b/.gitignore index b7e4cc9e..b22d5f03 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,11 @@ frontend-web-node_modules-offline-*.tar.gz /.pnpm-store/ /offline-backend-20260512/backend/backend*.zip +# Local offline bundles and scratch tooling +/*.tar.gz +/offline-backend-20260512/*.zip +/tmp/ + .codegraph codegraph.mdc diff --git a/frontend-web/src/components/wiki/MarkdownCodeEditor.tsx b/frontend-web/src/components/wiki/MarkdownCodeEditor.tsx new file mode 100644 index 00000000..efa558fd --- /dev/null +++ b/frontend-web/src/components/wiki/MarkdownCodeEditor.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { languages } from "@codemirror/language-data"; +import { basicLightInit } from "@uiw/codemirror-theme-basic"; +import { monokaiInit } from "@uiw/codemirror-theme-monokai"; +import CodeMirror from "@uiw/react-codemirror"; +import { useTheme } from "next-themes"; +import { useMemo } from "react"; + +import { cn } from "@/lib/utils"; +import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; +import { reasoningPlugins } from "@/core/streamdown"; + +const lightTheme = basicLightInit({ settings: { background: "transparent", fontSize: "14px" } }); +const darkTheme = monokaiInit({ + settings: { background: "transparent", gutterBackground: "transparent", fontSize: "14px" }, +}); + +/** Markdown-aware source editor shared by Wiki create/edit dialogs. */ +export function MarkdownCodeEditor({ + value, + onChange, + className, +}: { + value: string; + onChange: (value: string) => void; + className?: string; +}) { + const { resolvedTheme } = useTheme(); + const extensions = useMemo( + () => [markdown({ base: markdownLanguage, codeLanguages: languages })], + [], + ); + + return ( +
+
+
+ Markdown 编辑 +
+ +
+
+
+ 实时渲染 +
+
+ +
+
+
+ ); +} diff --git a/frontend-web/src/components/wiki/WikiHeader.tsx b/frontend-web/src/components/wiki/WikiHeader.tsx index fe8b0210..1381250b 100644 --- a/frontend-web/src/components/wiki/WikiHeader.tsx +++ b/frontend-web/src/components/wiki/WikiHeader.tsx @@ -77,7 +77,7 @@ export function WikiHeader({
- 问答梳理 + 知识库管理
diff --git a/frontend-web/src/components/wiki/wikiUtils.ts b/frontend-web/src/components/wiki/wikiUtils.ts index 5625ffcc..ac1b26f6 100644 --- a/frontend-web/src/components/wiki/wikiUtils.ts +++ b/frontend-web/src/components/wiki/wikiUtils.ts @@ -204,7 +204,7 @@ export function noteFolderSegments(note: KnowledgeNote): string[] { /** Breadcrumb segments for a note (folders only; the title is appended by the caller). */ export function noteBreadcrumbSegments(note: KnowledgeNote): string[] { - return ["问答梳理", ...noteFolderSegments(note).map(localizeFolderName)]; + return ["知识库管理", ...noteFolderSegments(note).map(localizeFolderName)]; } function sortTree(nodes: TreeNode[]): void { diff --git a/frontend-web/src/components/workspace/artifacts/spreadsheet-preview.tsx b/frontend-web/src/components/workspace/artifacts/spreadsheet-preview.tsx index 9eeac0f2..70227642 100644 --- a/frontend-web/src/components/workspace/artifacts/spreadsheet-preview.tsx +++ b/frontend-web/src/components/workspace/artifacts/spreadsheet-preview.tsx @@ -117,7 +117,7 @@ export function SpreadsheetPreview({ {Array.from({ length: columnCount }, (_, columnIndex) => ( {desensitize(headerRow?.[columnIndex] ?? "")} diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index fc6e08af..7499cc54 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useNavigate } from "react-router-dom"; +import { useMemo } from "react"; import { SidebarGroup, @@ -13,13 +14,18 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; -// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react"; -import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; +import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; import { KnowledgeRagToggle } from "./knowledge-rag-toggle"; import { SidebarCollapseButton } from "./workspace-header"; import { WorkspaceNavStudio } from "./workspace-nav-studio"; import { cn } from "@/lib/utils"; +import { + applyCompactMenuOverrides, + type CompactMenuItem, +} from "@/core/page-layout/compact-menu-overrides"; +import { type MenuItem } from "@/core/page-layout/sidebar-menu"; +import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides"; // 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标 const NAV_ITEM_CLASS = @@ -40,6 +46,99 @@ const NEW_CHAT_COMPACT_CLASS = "hover:bg-[rgb(var(--legacy-primary-rgb)/4%)] hover:text-[var(--primary)] hover:border-[rgb(var(--legacy-primary-rgb)/32%)] " + "data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none"; +export type WorkspaceCompactNavItem = MenuItem & { + match: (pathname: string) => boolean; +}; + +export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [ + { + id: "workspace-new-chat", + label: "新对话", + icon: MessageSquarePlus, + path: "/page/workspace/chats/new", + match: (pathname) => pathname === "/page/workspace/chats/new", + }, + { + id: "workspace-chats", + label: "对话", + icon: MessagesSquare, + path: "/page/workspace/chats", + match: (pathname) => pathname === "/page/workspace/chats", + }, + { + id: "workspace-agents", + label: "智能体", + icon: BotIcon, + path: "/page/workspace/agents", + match: (pathname) => pathname.startsWith("/page/workspace/agents"), + }, + { + id: "workspace-skills", + label: "技能", + icon: SparklesIcon, + path: "/page/workspace/skills", + match: (pathname) => pathname.startsWith("/page/workspace/skills"), + }, + { + id: "workspace-memory", + label: "记忆", + icon: BrainIcon, + path: "/page/workspace/memory", + match: (pathname) => pathname.startsWith("/page/workspace/memory"), + }, + { + id: "workspace-ai-writing", + label: "AI写作", + icon: PenLine, + path: "/page/workspace/ai-writing", + match: (pathname) => pathname.startsWith("/page/workspace/ai-writing"), + }, + { + id: "workspace-deep-research", + label: "深度研究", + icon: Telescope, + path: "/page/workspace/deep-research", + match: (pathname) => pathname.startsWith("/page/workspace/deep-research"), + }, + { + id: "workspace-roundtable-planning", + label: "多智能体规划", + icon: Users, + path: "/page/workspace/roundtable/planning", + match: (pathname) => pathname.startsWith("/page/workspace/roundtable/planning"), + }, + { + id: "workspace-scheduled-tasks", + label: "定时任务", + icon: ListChecks, + path: "/page/workspace/scheduled/tasks", + match: (pathname) => pathname === "/page/workspace/scheduled/tasks", + }, + { + id: "workspace-knowledge", + label: "知识库管理", + icon: BookOpen, + path: "/page/workspace/knowledge", + match: (pathname) => pathname.startsWith("/page/workspace/knowledge"), + }, +]; + +export type EffectiveWorkspaceCompactNavItem = CompactMenuItem & WorkspaceCompactNavItem; + +export function useWorkspaceCompactNavItems(): { + sidebarItems: EffectiveWorkspaceCompactNavItem[]; + settingsItems: EffectiveWorkspaceCompactNavItem[]; +} { + const { data: overrides = [] } = useCompactMenuOverrides(); + return useMemo(() => { + const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides); + return { + sidebarItems: applied.sidebarItems as EffectiveWorkspaceCompactNavItem[], + settingsItems: applied.settingsItems as EffectiveWorkspaceCompactNavItem[], + }; + }, [overrides]); +} + export function WorkspaceNavChatList({ showCollapseTrigger = false, compact = false, @@ -50,6 +149,8 @@ export function WorkspaceNavChatList({ const { t } = useI18n(); const pathname = usePathname(); const navigate = useNavigate(); + const { sidebarItems } = useWorkspaceCompactNavItems(); + const newChatItem = sidebarItems.find((item) => item.id === "workspace-new-chat"); // 「新对话」点击时,如果已经在 /chats/new 路径,直接 navigate 会被 react-router // 当成同路径 no-op,不更新 location.key,挂在 location.key 上的下游 hook // (useThreadChat)就感知不到「用户想重开一个对话」,UI 不刷新。 @@ -68,171 +169,72 @@ export function WorkspaceNavChatList({ <> - - {compact ? ( + {(showCollapseTrigger || newChatItem) && ( + + {compact ? ( // 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。 -
- - - - {t.sidebar.newChat} - - - {showCollapseTrigger && ( - - )} -
- ) : ( -
- - + - - {t.sidebar.newChat} - - - {showCollapseTrigger && ( - - )} -
- )} -
+ + + {newChatItem?.label ?? t.sidebar.newChat} + + + {showCollapseTrigger && ( + + )} + + ) : ( +
+ + + + {newChatItem?.label ?? t.sidebar.newChat} + + + {showCollapseTrigger && ( + + )} +
+ )} +
+ )} {!showCollapseTrigger && ( <> - - - - - {t.sidebar.chats} - - - - - - - - {t.sidebar.agents} - - - - - - - - 技能 - - - - - - - - 记忆 - - - - - - - - AI写作 - - - - - - - - 深度研究 - - - - - - - - 多智能体规划 - - - - {/* - - - - 岗位会商 - - - */} - - - - - 定时任务 - - - + {sidebarItems + .filter((item) => item.id !== "workspace-new-chat") + .map((item) => { + const Icon = item.icon; + return ( + + + + + {item.label} + + + + ); + })} - - - - - 问答梳理 - - - )}
diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 8bc0e1a1..25865950 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -46,6 +46,7 @@ import { NotificationDialog } from "./notification-dialog"; import { useNavigate } from "react-router-dom"; import { SettingsDialog } from "./settings"; +import { useWorkspaceCompactNavItems } from "./workspace-nav-chat-list"; function NavMenuButtonContent({ isSidebarOpen, @@ -80,6 +81,7 @@ export function WorkspaceNavMenu() { const { open: isSidebarOpen } = useSidebar(); const { t } = useI18n(); const { data: unreadCount = 0 } = useUnreadCount(); + const { settingsItems } = useWorkspaceCompactNavItems(); useEffect(() => { setMounted(true); @@ -129,6 +131,18 @@ export function WorkspaceNavMenu() { sideOffset={4} > + {settingsItems.map((item) => { + const Icon = item.icon; + return ( + item.path && navigate(item.path)} + > + + {item.label} + + ); + })} setNotificationOpen(true)}> {t.notifications.title} diff --git a/frontend-web/src/core/api/markdown-docx-export.ts b/frontend-web/src/core/api/markdown-docx-export.ts index 053a1963..5b7f10f7 100644 --- a/frontend-web/src/core/api/markdown-docx-export.ts +++ b/frontend-web/src/core/api/markdown-docx-export.ts @@ -78,7 +78,9 @@ export async function downloadMarkdownDocxFromBackend(options: { }), }); - if (response.status === 404) { + // Older/offline gateways mount /api/writing but do not provide export/docx; + // FastAPI then answers 404 or 405. The browser converter is feature-equivalent. + if (response.status === 404 || response.status === 405) { await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title); writeYLog({ optType: OptType.export, diff --git a/frontend-web/src/core/page-layout/compact-menu-overrides.ts b/frontend-web/src/core/page-layout/compact-menu-overrides.ts new file mode 100644 index 00000000..5aee6fc0 --- /dev/null +++ b/frontend-web/src/core/page-layout/compact-menu-overrides.ts @@ -0,0 +1,72 @@ +import type { MenuItem } from "./sidebar-menu"; + +export type CompactMenuPlacement = "sidebar" | "settings"; + +export interface CompactMenuOverride { + nodeId: string; + placement: CompactMenuPlacement; + sortOrder: number; + label?: string | null; +} + +export interface CompactMenuItem extends MenuItem { + compactPlacement: CompactMenuPlacement; + compactOrder: number; +} + +function firstRoutePath(items?: MenuItem[]): string | undefined { + for (const item of items ?? []) { + if (item.path) return item.path; + const nested = firstRoutePath(item.children); + if (nested) return nested; + } + return undefined; +} + +function isNavigable(item: MenuItem): boolean { + return Boolean(item.path ?? firstRoutePath(item.children)); +} + +export function applyCompactMenuOverrides( + items: MenuItem[], + overrides: CompactMenuOverride[], +): { sidebarItems: CompactMenuItem[]; settingsItems: CompactMenuItem[] } { + const byId = new Map(overrides.map((override) => [override.nodeId, override])); + const enhanced = items.filter(isNavigable).map((item, index) => { + const override = byId.get(item.id); + return { + ...item, + label: override?.label != null && override.label !== "" ? override.label : item.label, + compactPlacement: override?.placement ?? "sidebar", + compactOrder: override?.sortOrder ?? index, + }; + }); + + const sortItems = (list: CompactMenuItem[]) => + list.slice().sort((a, b) => a.compactOrder - b.compactOrder || a.id.localeCompare(b.id)); + + return { + sidebarItems: sortItems(enhanced.filter((item) => item.compactPlacement === "sidebar")), + settingsItems: sortItems(enhanced.filter((item) => item.compactPlacement === "settings")), + }; +} + +export function diffCompactMenuOverrides(items: CompactMenuEditorItem[]): CompactMenuOverride[] { + return items.flatMap((item, index) => { + const renamed = item.label !== item.defaultLabel; + const moved = item.compactPlacement !== "sidebar"; + const reordered = index !== item.defaultIndex || item.compactOrder !== item.defaultIndex; + if (!renamed && !moved && !reordered) return []; + return { + nodeId: item.id, + placement: item.compactPlacement, + sortOrder: index, + label: renamed ? item.label : null, + }; + }); +} + +export interface CompactMenuEditorItem extends CompactMenuItem { + defaultLabel: string; + defaultIndex: number; +} diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 7c6a1566..8f87e2be 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -250,6 +250,12 @@ export const sidebarMenuItems: MenuItem[] = [ }, { id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" }, { id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" }, + { + id: "resource-markdown-docx-editor", + label: "md转word", + icon: FileText, + path: "/page/workspace/knowledge/markdown-editor", + }, { id: "mgmt-tasks", label: "定时任务", @@ -313,16 +319,10 @@ export const sidebarMenuItems: MenuItem[] = [ // }, { id: "resource-knowledge-native", - label: "问答梳理", + label: "知识库管理", icon: BookOpen, path: "/page/workspace/knowledge", }, - { - id: "resource-markdown-docx-editor", - label: "Markdown 转 Word", - icon: FileText, - path: "/page/workspace/knowledge/markdown-editor", - }, { id: "resource-knowledge-base", label: "知识库资源管理", diff --git a/frontend-web/src/core/page-layout/use-sidebar-menu.ts b/frontend-web/src/core/page-layout/use-sidebar-menu.ts index fbbc10eb..64633b58 100644 --- a/frontend-web/src/core/page-layout/use-sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/use-sidebar-menu.ts @@ -69,6 +69,14 @@ function desensitizeLabels(items: MenuItem[], fn: (text: string) => string): Men })); } +function useDesensitizeLabel() { + const { data: words = [] } = useSensitiveWords(); + return useMemo( + () => (words.length > 0 ? makeDesensitizer(wordsToMap(words)) : desensitize), + [words], + ); +} + /** * 返回合并了 iframe 轻应用、套用了后端「菜单覆盖层」(改名/停用/排序/层级)、 * 按 admin 角色过滤、并对标签做**展示层脱敏**后的菜单。 @@ -82,11 +90,7 @@ function desensitizeLabels(items: MenuItem[], fn: (text: string) => string): Men export function useSidebarMenuItems(isAdmin: boolean): MenuItem[] { const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true }); const { data: overrides = [] } = useMenuOverrides(); - const { data: words = [] } = useSensitiveWords(); - const desensitizeLabel = useMemo( - () => (words.length > 0 ? makeDesensitizer(wordsToMap(words)) : desensitize), - [words], - ); + const desensitizeLabel = useDesensitizeLabel(); return useMemo( () => desensitizeLabels( diff --git a/frontend-web/src/pages/MarkdownDocxEditorPage.tsx b/frontend-web/src/pages/MarkdownDocxEditorPage.tsx index 6fbd4a6b..bd169f04 100644 --- a/frontend-web/src/pages/MarkdownDocxEditorPage.tsx +++ b/frontend-web/src/pages/MarkdownDocxEditorPage.tsx @@ -116,7 +116,7 @@ export default function MarkdownDocxEditorPage() {
-

Markdown 转 Word

+

md转word

上传 md 文件后在线编辑,确认内容后下载为 Word。

diff --git a/frontend-web/src/pages/MenuManagementPage.tsx b/frontend-web/src/pages/MenuManagementPage.tsx index 150e7c04..4a5944e6 100644 --- a/frontend-web/src/pages/MenuManagementPage.tsx +++ b/frontend-web/src/pages/MenuManagementPage.tsx @@ -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 { 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(); + 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(); + 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( + () => [ + { + 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 ; + const modeTabs = ( +
+ + +
+ ); + + if (mode === "compact") { + return ( +
+
+ {modeTabs} + +
+
+ ); + } + return (
+ {modeTabs} {/* 顶部信息卡 */}
@@ -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(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(); + 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) => + 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 ( + <> +
+
+ +
+
+
+
+ + + 仅简介模式生效 + +

+ 简介菜单管理 + + {rows.length} 个项目 + +

+
+
+ + +
+
+

+ + 这里配置的是简介模式工作区左侧菜单:可改显示名称、调整顺序,或迁移到「设置和更多」下拉中;全量模式菜单不受影响。 +

+
+
+ +
+ {isLoading ? ( +

加载中…

+ ) : ( + grouped.map((group) => ( +
+

{group.label}

+
+ {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 ( +
+
+ + + + updateRow(row.id, { label: e.target.value })} + className="bg-white" + /> +
+ +
+ + +
+
+ ); + })} +
+
+ )) + )} +
+ + ); +} + // ── 编辑弹窗 ───────────────────────────────────────────────────────────── interface EditMenuDialogProps { diff --git a/frontend-web/src/strategy-components/api/compact-menu-overrides.ts b/frontend-web/src/strategy-components/api/compact-menu-overrides.ts new file mode 100644 index 00000000..dc3bd0bc --- /dev/null +++ b/frontend-web/src/strategy-components/api/compact-menu-overrides.ts @@ -0,0 +1,92 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; +import { OptType, writeYLog } from "@/core/log/y-log"; +import type { CompactMenuOverride } from "@/core/page-layout/compact-menu-overrides"; + +const BASE = () => `${getBackendBaseURL()}/api/compact-menu-overrides`; + +interface ServerCompactOverride { + node_id: string; + placement: "sidebar" | "settings"; + sort_order: number; + label: string | null; +} + +function toOverride(s: ServerCompactOverride): CompactMenuOverride { + return { + nodeId: s.node_id, + placement: s.placement === "settings" ? "settings" : "sidebar", + sortOrder: s.sort_order ?? 0, + label: s.label ?? null, + }; +} + +function toBody(o: CompactMenuOverride): ServerCompactOverride { + return { + node_id: o.nodeId, + placement: o.placement, + sort_order: o.sortOrder, + label: o.label ?? null, + }; +} + +async function readJson(res: Response, ctx: string): Promise { + if (!res.ok) { + const data = await res.json().catch(() => null as unknown); + const detail = + data && typeof data === "object" && "detail" in data + ? String((data as { detail: unknown }).detail) + : `${ctx} failed (${res.status})`; + throw new Error(detail); + } + return (await res.json()) as T; +} + +export async function listCompactMenuOverrides(): Promise { + const res = await apiFetch(BASE(), { method: "GET" }); + const data = await readJson<{ overrides: ServerCompactOverride[] }>(res, "list compact menu overrides"); + return (data.overrides ?? []).map(toOverride); +} + +export async function replaceCompactMenuOverrides( + overrides: CompactMenuOverride[], +): Promise { + const res = await apiFetch(BASE(), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ overrides: overrides.map(toBody) }), + }); + const data = await readJson<{ overrides: ServerCompactOverride[] }>(res, "save compact menu overrides"); + const saved = (data.overrides ?? []).map(toOverride); + writeYLog({ + optType: OptType.edit, + optDetail: "CM助手-简介-菜单管理-保存", + url: "/api/compact-menu-overrides", + reqParam: { overrides }, + returnParam: saved, + }); + return saved; +} + +const COMPACT_MENU_OVERRIDES_KEY = "compact-menu-overrides"; + +export function useCompactMenuOverrides() { + return useQuery({ + queryKey: [COMPACT_MENU_OVERRIDES_KEY], + queryFn: listCompactMenuOverrides, + refetchOnWindowFocus: false, + staleTime: 60_000, + }); +} + +export function useCompactMenuOverrideActions() { + const queryClient = useQueryClient(); + return { + save: useMutation({ + mutationFn: replaceCompactMenuOverrides, + onSuccess: () => queryClient.invalidateQueries({ queryKey: [COMPACT_MENU_OVERRIDES_KEY] }), + }), + }; +} diff --git a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx index 1cb13253..9f2ae5b4 100644 --- a/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx +++ b/frontend-web/src/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { MarkdownCodeEditor } from "@/components/wiki/MarkdownCodeEditor"; import { getBackendBaseURL } from "@/core/config"; import { createLlmWikiWeKnoraEmbedSession, @@ -435,7 +436,7 @@ function CurrentWikiArticleActions({ 删除 - + 编辑此文章 @@ -457,10 +458,10 @@ function CurrentWikiArticleActions({
-