diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 660465b4..0c191a09 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -99,11 +99,15 @@ export function App() { } /> } + element={} /> } + element={} + /> + } /> void; +}; + +type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about"; + +export function PageSidebar({ + collapsed, + onToggleCollapsed, +}: PageSidebarProps) { + const { theme } = useTheme(); + const isDark = theme === "dark"; + const isAdmin = getStoredAuth()?.system_role === "admin"; + const visibleMenuItems = useSidebarMenuItems(!!isAdmin); + const { t } = useI18n(); + const { data: unreadCount = 0 } = useUnreadCount(); + // defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变) + const [expandedIds, setExpandedIds] = useState>(() => new Set(defaultExpandedIds)); + const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsDefaultSection, setSettingsDefaultSection] = + useState("appearance"); + const [notificationOpen, setNotificationOpen] = useState(false); + const [mounted, setMounted] = useState(false); + const navigate = useNavigate(); + const { pathname } = useLocation(); + + useEffect(() => { + setMounted(true); + }, []); + + const openSettings = (section: SettingsSection) => { + setSettingsDefaultSection(section); + setSettingsOpen(true); + }; + + const iconButtonClass = isDark + ? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white" + : "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600"; + const panelClass = isDark + ? "ml-4 space-y-1 border-l border-blue-800/30 pl-4" + : "ml-4 space-y-1 border-l border-red-100 pl-4"; + const labelClass = (compact: boolean, small = false) => + `overflow-hidden whitespace-nowrap transition-all duration-300 ease-out ${ + compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100" + } ${small ? "text-xs" : "text-sm"} flex-1 font-medium`; + const menuButtonClass = (isActive: boolean, depth = 0) => + `group relative flex w-full items-center rounded-[2px] py-2 text-left transition-all duration-300 ease-out theme-transition ${ + collapsed && depth === 0 ? "justify-center px-2" : "gap-3 px-3" + } ${ + isActive + ? isDark + ? "bg-blue-500/15 text-cyan-300 font-semibold" + : "bg-red-50 text-red-600 font-semibold" + : isDark + ? "text-slate-400 hover:bg-slate-800/50 hover:text-white" + : "text-gray-600 hover:bg-red-50 hover:text-red-600" + }`; + const footerButtonClass = menuButtonClass(false, 0); + + const toggleExpanded = (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const renderLeaf = (item: MenuItem, depth = 0) => { + const Icon = item.icon; + const isActive = !!item.path && pathname === item.path; + + return ( + + ); + }; + + const renderBranch = (item: MenuItem, depth = 0) => { + const expanded = expandedIds.has(item.id); + const isTopLevel = depth === 0; + const Icon = depth > 0 && item.icon === Folder && expanded ? FolderOpen : item.icon; + + return ( +
+ + + {(!collapsed || !isTopLevel) && item.children && ( +
+ {item.children.map((child) => + child.children ? renderBranch(child, depth + 1) : renderLeaf(child, depth + 1), + )} +
+ )} +
+ ); + }; + + return ( + <> + + + + + ); +} diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 58e59a72..42b556fe 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -22,14 +22,18 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog"; import { SettingsDialog } from "@/components/workspace/settings"; import { useTheme } from "@/contexts/ThemeContext"; import { getStoredAuth } from "@/core/auth"; +import { isWujieEnv } from "@/core/embed/wujie"; import { useI18n } from "@/core/i18n/hooks"; import { useUnreadCount } from "@/core/notifications"; import { defaultExpandedIds, + getActiveThirdLevelItems, type MenuItem, } from "@/core/page-layout/sidebar-menu"; import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu"; +import "@/components/page-sdebar.css"; + type PageSidebarProps = { collapsed: boolean; onToggleCollapsed: () => void; @@ -37,7 +41,201 @@ type PageSidebarProps = { type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about"; -export function PageSidebar({ +const hasActiveChild = (item: MenuItem, pathname: string): boolean => { + if (item.path && pathname === item.path) { + return true; + } + return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false; +}; + +const 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; +}; + +/** + * 浅色侧边栏入口。 + * + * - 普通桌面环境:渲染「二级导航图标卡片」(一级在顶栏、二级在侧边栏,参考 导航-1.png)。 + * - wujie 嵌入环境:顶栏被宿主隐藏,一级导航无处安放,故回落到原手风琴整树菜单 + * (保证嵌入模式下所有层级仍可达,零回归)。 + */ +export function PageSidebar({ collapsed, onToggleCollapsed }: PageSidebarProps) { + if (isWujieEnv()) { + return ( + + ); + } + return ; +} + +/** + * 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。 + * 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。 + */ +function SecondaryNavSidebar() { + const isAdmin = getStoredAuth()?.system_role === "admin"; + const visibleMenuItems = useSidebarMenuItems(!!isAdmin); + const { t } = useI18n(); + const { data: unreadCount = 0 } = useUnreadCount(); + const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsDefaultSection, setSettingsDefaultSection] = + useState("appearance"); + const [notificationOpen, setNotificationOpen] = useState(false); + const [mounted, setMounted] = useState(false); + const navigate = useNavigate(); + const { pathname } = useLocation(); + + useEffect(() => { + setMounted(true); + }, []); + + const openSettings = (section: SettingsSection) => { + setSettingsDefaultSection(section); + setSettingsOpen(true); + }; + + // 当前激活的一级菜单(与顶栏一级导航联动);其 children 即侧边栏要渲染的二级导航。 + const activeTopItem = + visibleMenuItems.find((item) => hasActiveChild(item, pathname)) ?? + visibleMenuItems[0]; + const secondaryItems = activeTopItem?.children ?? []; + + const isItemActive = (item: MenuItem) => + item.path ? pathname === item.path : hasActiveChild(item, pathname); + + const handleItemClick = (item: MenuItem) => { + const path = item.path ?? findFirstRoutePath(item.children); + if (path) { + navigate(path); + } + }; + + // 当前激活二级项的三级子项;非空时侧边栏在右侧展开「三级列」并整体变宽(见 css 过渡)。 + const thirdItems = getActiveThirdLevelItems(visibleMenuItems, pathname); + const expanded = thirdItems.length > 0; + + return ( + <> + + + + + ); +} + +/** + * 旧版浅色整树手风琴侧边栏。仅用于 wujie 嵌入环境(顶栏被宿主隐藏时保证所有层级可达)。 + */ +function LegacyAccordionSidebar({ collapsed, onToggleCollapsed, }: PageSidebarProps) { @@ -66,9 +264,6 @@ export function PageSidebar({ setSettingsOpen(true); }; - const iconButtonClass = isDark - ? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white" - : "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600"; const panelClass = isDark ? "ml-4 space-y-1 border-l border-blue-800/30 pl-4" : "ml-4 space-y-1 border-l border-red-100 pl-4"; diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index d8e4cfdd..5188d8d5 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -121,12 +121,19 @@ export function ArtifactFileDetail({ filepath: filepathFromProps, threadId, onClose, + allowVirtualMarkdownEdit = false, }: { className?: string; filepath: string; threadId: string; /** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */ onClose?: () => void; + /** + * 圆桌沙箱(Step2/Step3)用:流式结束后,把 `write-file:` 虚拟 md 预览切回真实 + * artifacts API(可加载 + PATCH 保存),从而支持用户编辑(对齐主聊天页 md 可编辑)。 + * 默认 false —— 主聊天页等其它调用方行为不变。 + */ + allowVirtualMarkdownEdit?: boolean; }) { const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); @@ -141,10 +148,6 @@ export function ArtifactFileDetail({ } return filepathFromProps; }, [filepathFromProps, isWriteFileVirtual]); - /** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API。 */ - const useWriteFileStreaming = useMemo(() => { - return isWriteFileVirtual && !isImageFile(filepath); - }, [filepath, isWriteFileVirtual]); const isSkillFile = useMemo(() => { return filepath.endsWith(".skill"); }, [filepath]); @@ -153,6 +156,24 @@ export function ArtifactFileDetail({ const isSpreadsheetFile = useMemo(() => { return /\.xlsx$/i.test(filepath); }, [filepath]); + // 圆桌沙箱:write_file 虚拟预览的 md 在流式结束(thread 非 loading)后,切回真实 + // artifacts API 加载并允许编辑/保存。流式进行中仍走内存 stub 只读预览;仅 + // allowVirtualMarkdownEdit 开启的调用方(Step2/Step3 沙箱)生效,主聊天页不受影响。 + const editVirtualMarkdown = useMemo(() => { + return ( + allowVirtualMarkdownEdit && + isWriteFileVirtual && + !isImageFile(filepath) && + !isMock && + !thread.isLoading && + checkCodeFile(filepath).language === "markdown" + ); + }, [allowVirtualMarkdownEdit, isWriteFileVirtual, filepath, isMock, thread.isLoading]); + /** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API; + * 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */ + const useWriteFileStreaming = useMemo(() => { + return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown; + }, [filepath, isWriteFileVirtual, editVirtualMarkdown]); const { isCodeFile, language } = useMemo(() => { if (useWriteFileStreaming) { let nextLanguage = checkCodeFile(filepath).language; @@ -173,7 +194,10 @@ export function ArtifactFileDetail({ }, [language]); const { content, isApplyingEdit } = useArtifactContent({ threadId, - filepath: filepathFromProps, + // editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟 + // URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容 + // 不会被旧的内存 stub 值覆盖回退。 + filepath: editVirtualMarkdown ? filepath : filepathFromProps, enabled: isCodeFile && !useWriteFileStreaming, }); diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index 83c984c5..ae309232 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -26,6 +26,7 @@ import { useLocalSettings } from "@/core/settings"; import { useAppearanceSettings, useCitationDisplaySettings, + useLayoutMode, useUpdateAppearanceSettings, useUpdateCitationDisplaySettings, } from "@/core/system-settings/hooks"; @@ -57,6 +58,9 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { const globalDefault = appearance?.default_theme ?? "light"; const globalSkillView = appearance?.default_skill_view ?? "list"; const globalAgentView = appearance?.default_agent_view ?? "card"; + const globalLayoutMode = appearance?.default_layout_mode ?? "full"; + // 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。 + const [effectiveLayoutMode, setLayoutMode] = useLayoutMode(); const cleanupWords = citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; @@ -74,6 +78,18 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { } }; + const handleSetDefaultLayoutMode = async (value: "full" | "compact") => { + if (value === globalLayoutMode) return; + try { + await updateAppearance.mutateAsync({ default_layout_mode: value }); + toast.success( + `已将系统默认界面模式设置为${value === "full" ? "全量模式" : "简洁模式"}`, + ); + } catch (e) { + toast.error(e instanceof Error ? e.message : "保存失败"); + } + }; + const handleSetDefaultView = async ( kind: "skill" | "agent", value: "card" | "list", @@ -156,10 +172,10 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
+ {isAdmin && ( + <> + + +
+ + +

+ 当前系统默认: + {globalLayoutMode === "full" ? "全量模式" : "简洁模式"} +

+
+
+ + )} + {isAdmin && ( <> diff --git a/frontend-web/src/core/artifacts/hooks.ts b/frontend-web/src/core/artifacts/hooks.ts index 45419ff3..0e9818ac 100644 --- a/frontend-web/src/core/artifacts/hooks.ts +++ b/frontend-web/src/core/artifacts/hooks.ts @@ -133,8 +133,18 @@ export function useUpdateArtifactContent() { return useMutation({ mutationFn: updateArtifactContent, onSuccess: (content, variables) => { - queryClient.setQueryData( - ["artifact", variables.filepath, variables.threadId, variables.isMock], + // useArtifactContent 的查询键带第 5 个元素 publicShareCode,这里用前缀 + // 过滤(setQueriesData)匹配,避免精确键缺少 publicShareCode 写到幽灵缓存、 + // 导致保存后内容被旧缓存重置回原值。 + queryClient.setQueriesData( + { + queryKey: [ + "artifact", + variables.filepath, + variables.threadId, + variables.isMock, + ], + }, { content, url: undefined, diff --git a/frontend-web/src/core/auth/yuser-login.ts b/frontend-web/src/core/auth/yuser-login.ts index 27e2419c..f118d245 100644 --- a/frontend-web/src/core/auth/yuser-login.ts +++ b/frontend-web/src/core/auth/yuser-login.ts @@ -31,15 +31,44 @@ export async function loginByYUserId( { yuserId: yUserId }, )) as UserDetailResponse; - const result = res.data; - if (!result?.id) { - throw new Error("无法根据 yUserId 获取用户信息。"); - } - - localStorage.setItem(EXTERNAL_USER_ID_STORAGE_KEY, result.id); - localStorage.setItem(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(result)); + // consumer 返回 { accessToken, gateDepartName, typeName, userName }。 + // 一并存入路由参数 yUserId,使「应用管理」里 source=yUserId 的路由参数能取到(与 + // source=userName/gateDepartName/typeName 直接取接口值统一走 userInfo[source])。 + // 注意:接口也会返回 accessToken,但 XK/PG 及 source=accessToken 要用路由透传的 + // access_token,故登录流程随后会用 persistAccessToken 覆盖 userInfo.accessToken。 + const result = res.data ?? {}; + localStorage.setItem( + EXTERNAL_USER_INFO_STORAGE_KEY, + JSON.stringify({ ...result, yUserId }), + ); } export function persistToolsToken(token: string): void { localStorage.setItem(TOOLS_TOKEN_STORAGE_KEY, token); } + +/** + * 把内网登录路由参数 access_token 合并写入 userInfo.accessToken(覆盖接口返回的同名值)。 + * 据此拼参的有:XK/PG 中任务 iframe(XK 用 accessToken、PG 用 access_token,二者均读 + * external-links.getAccessToken() = userInfo.accessToken);以及「应用管理」里 + * source=accessToken 的轻应用路由参数(resolveRouteParamValue 取 userInfo.accessToken)。 + * 用合并而非覆盖整对象,避免抹掉 loginByYUserId 从 consumer 拿到的用户信息。 + */ +export function persistAccessToken(token: string): void { + const trimmed = token?.trim(); + if (!trimmed) return; + let info: Record = {}; + try { + const raw = localStorage.getItem(EXTERNAL_USER_INFO_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + info = parsed as Record; + } + } + } catch { + info = {}; + } + info.accessToken = trimmed; + localStorage.setItem(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info)); +} diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 908e596a..7e3ccbf5 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -9,15 +9,16 @@ */ import type { ElementType } from "react"; import { + Activity, BookOpen, Bot, Brain, CalendarClock, CircleHelp, + ClipboardList, Database, FileBox, FileText, - Folder, Gauge, Grid3X3, HelpCircle, @@ -29,6 +30,7 @@ import { ListChecks, ListTodo, Network, + Radar, Settings, Sparkles, Target, @@ -77,7 +79,7 @@ export const sidebarMenuItems: MenuItem[] = [ { id: "xk-center-task", label: "XK中任务", - icon: Folder, + icon: ClipboardList, children: [ { id: "xk-task-jc", @@ -96,7 +98,7 @@ export const sidebarMenuItems: MenuItem[] = [ { id: "coh-center-task", label: "COH中任务", - icon: Folder, + icon: Activity, children: [ { id: "coh-task-3qfx", @@ -121,7 +123,7 @@ export const sidebarMenuItems: MenuItem[] = [ { id: "pg-center-task", label: "PG中任务", - icon: Folder, + icon: Gauge, children: [ { id: "pg-task-pg", @@ -146,7 +148,7 @@ export const sidebarMenuItems: MenuItem[] = [ { id: "ty-center-task", label: "TY中任务", - icon: Folder, + icon: Radar, children: [ { id: "ty-task-xdty", @@ -296,6 +298,31 @@ export const defaultExpandedIds: ReadonlySet = new Set([ "comprehensive-management", ]); +/** 该菜单项自身或任意后代的 path 是否等于当前路由 */ +export function menuItemContainsPath(item: MenuItem, pathname: string): boolean { + if (item.path && item.path === pathname) { + return true; + } + return item.children?.some((child) => menuItemContainsPath(child, pathname)) ?? false; +} + +/** + * 在「一级 → 二级 → 三级」菜单中,取当前路由所在的「激活二级项」的三级子项。 + * 浅色侧边栏据此在二级右侧展开三级列;无三级则返回空数组(侧边栏保持窄栏)。 + */ +export function getActiveThirdLevelItems( + items: MenuItem[], + pathname: string, +): MenuItem[] { + const activeTop = + items.find((item) => menuItemContainsPath(item, pathname)) ?? items[0]; + const secondary = activeTop?.children ?? []; + const activeSecond = secondary.find( + (item) => (item.children?.length ?? 0) > 0 && menuItemContainsPath(item, pathname), + ); + return activeSecond?.children ?? []; +} + /** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项及其子项 */ export function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] { return items 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 1f2ec6b0..006c767e 100644 --- a/frontend-web/src/core/page-layout/use-sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/use-sidebar-menu.ts @@ -8,12 +8,15 @@ * 浅色 PageSidebar 与深色 PageSidebarDark 共用此 hook,保证两套皮肤同步。 */ import { useMemo } from "react"; +import { useLocation } from "react-router-dom"; import { Layout } from "lucide-react"; +import { getStoredAuth } from "@/core/auth"; import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps"; import { filterMenuItemsByRole, + getActiveThirdLevelItems, sidebarMenuItems, type MenuItem, } from "./sidebar-menu"; @@ -61,3 +64,14 @@ export function useSidebarMenuItems(isAdmin: boolean): MenuItem[] { [iframeApps, isAdmin], ); } + +/** + * 当前路由所在「激活二级项」的三级子项(浅色侧边栏右侧列用)。 + * 供 PageRoutes(主内容左内边距随展开自适应)与浅色侧边栏共用同一份判定逻辑。 + */ +export function useActiveThirdLevel(): MenuItem[] { + const isAdmin = getStoredAuth()?.system_role === "admin"; + const items = useSidebarMenuItems(!!isAdmin); + const { pathname } = useLocation(); + return useMemo(() => getActiveThirdLevelItems(items, pathname), [items, pathname]); +} diff --git a/frontend-web/src/core/roundtable-draft-shares/api.ts b/frontend-web/src/core/roundtable-draft-shares/api.ts index 544ef588..3535cf1c 100644 --- a/frontend-web/src/core/roundtable-draft-shares/api.ts +++ b/frontend-web/src/core/roundtable-draft-shares/api.ts @@ -37,11 +37,17 @@ export interface PublicDraftShareSnapshot { consensus_text: string; /** 各席位最终交付. */ seat_deliveries: PublicDraftSeatDelivery[]; - /** Self-contained Step 3 result report HTML (empty when no report exists). */ + /** Self-contained Step 3 result report HTML — 经典 HTML 看板 (empty when none). */ html: string; summary: string | null; generated_at: string | null; has_report: boolean; + /** 方案总结报告 (Markdown,roundtable-summary 产出). */ + summary_report_md: string; + has_summary_report: boolean; + /** 大屏多页 (模式二) 结构化数据 (report-json 字符串);前端按主题渲染成自包含 HTML。 */ + dashboard_json: string | null; + has_dashboard: boolean; created_at: string | null; } diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index 864801f1..20fd57f1 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -137,17 +137,20 @@ export async function updatePublishSquares( export type ThemeMode = "light" | "dark" | "lightblue"; export type ViewMode = "card" | "list"; +export type LayoutMode = "full" | "compact"; export interface AppearanceSettings { default_theme: ThemeMode; default_skill_view: ViewMode; default_agent_view: ViewMode; + default_layout_mode: LayoutMode; } export interface AppearanceUpdate { default_theme?: ThemeMode; default_skill_view?: ViewMode; default_agent_view?: ViewMode; + default_layout_mode?: LayoutMode; } export async function loadAppearanceSettings(): Promise { diff --git a/frontend-web/src/core/system-settings/hooks.ts b/frontend-web/src/core/system-settings/hooks.ts index 5ab38951..05bf91a0 100644 --- a/frontend-web/src/core/system-settings/hooks.ts +++ b/frontend-web/src/core/system-settings/hooks.ts @@ -21,6 +21,7 @@ import { type PrivateSquareUpdate, type PromptPrefixUpdate, type PublishSquaresUpdate, + type LayoutMode, type ViewMode, } from "./api"; @@ -143,6 +144,27 @@ export function useViewMode( return [mode, setMode]; } +/** + * Effective interface layout mode (full chrome vs compact). A user's own choice + * (persisted in local settings) wins; otherwise the admin-configured system + * default applies; otherwise `undefined` (callers fall back to the session-level + * `sidebarEnabled` flag set by portal / yUserId login). `setMode` persists the + * user's override locally. + */ +export function useLayoutMode(): [ + LayoutMode | undefined, + (mode: LayoutMode) => void, +] { + const [settings, setSettings] = useLocalSettings(); + const { data: appearance } = useAppearanceSettings(); + const userOverride = settings.display.layoutMode; + const adminDefault = appearance?.default_layout_mode; + const mode = userOverride ?? adminDefault; + const setMode = (next: LayoutMode) => + setSettings("display", { layoutMode: next }); + return [mode, setMode]; +} + export function useCitationDisplaySettings() { return useQuery({ queryKey: CITATION_DISPLAY_KEY, diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index e39c5f58..930dac06 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -42,10 +42,10 @@ import { getLastAgentId, saveLastAgentId, useKnowledgeRagEnabled, - useLocalSettings, useThreadSettings, } from "@/core/settings"; import { useThreadStream } from "@/core/threads/hooks"; +import { useLayoutMode } from "@/core/system-settings/hooks"; import { useRouteBase } from "@/core/route-base"; import { textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; @@ -96,7 +96,8 @@ export default function ChatPage() { const { open: historySidebarOpen, toggleSidebar: toggleHistorySidebar } = useSidebar(); const { t } = useI18n(); - const [localSettings] = useLocalSettings(); + // 有效界面模式:用户自选 > 管理员系统默认 > (下方再回退会话级 sidebarEnabled) + const [effectiveLayoutMode] = useLayoutMode(); const [showFollowups, setShowFollowups] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false); const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = @@ -418,7 +419,7 @@ export default function ChatPage() { // 优先看外观设置的「全量/简洁」,未设置过才回退到会话级 sidebarEnabled。否则用户显式 // 选「全量」时 PageRoutes 已挂出顶栏+侧栏,这里却还按旧 sidebarEnabled 判定,导致深蓝 // 主题底图(workspace-chat-bg)不生效。非紧凑(嵌入/standalone)保持原样。 - const layoutMode = localSettings.display.layoutMode; + const layoutMode = effectiveLayoutMode; const chromeByPreference = layoutMode === "full" ? true @@ -589,11 +590,6 @@ export default function ChatPage() { )} /> )} - {compactLanding && ( -
- CM Assistant V1.2 · 深度推理可能会涉及数据同步 · 信息由AI自动生成,请审慎参考 -
- )} {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
{t.common.notAvailableInDemoMode} diff --git a/frontend-web/src/pages/LightAppListPage.tsx b/frontend-web/src/pages/LightAppListPage.tsx index b5145b83..43328404 100644 --- a/frontend-web/src/pages/LightAppListPage.tsx +++ b/frontend-web/src/pages/LightAppListPage.tsx @@ -37,6 +37,8 @@ import { cn } from "@/lib/utils"; import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps"; import { buildLightAppUrl } from "@/strategy-components/lib/light-app"; +import "./light-app-pages.css"; + const PAGE_SIZE = 12; /** 卡片图标池(按 appId 哈希取一个,仅作视觉区分)。 */ @@ -107,7 +109,7 @@ export default function LightAppListPage() { }; return ( -
+
{/* 标题 */}
diff --git a/frontend-web/src/pages/LightAppManagementPage.tsx b/frontend-web/src/pages/LightAppManagementPage.tsx index 99c6ed77..0180389d 100644 --- a/frontend-web/src/pages/LightAppManagementPage.tsx +++ b/frontend-web/src/pages/LightAppManagementPage.tsx @@ -1,3 +1,8 @@ +/** + * 应用管理页 —— 注册 / 编辑轻应用(iframe 与 window.open)。 + * + * 深蓝主题适配见 `./light-app-management.css`(《深蓝色 UI 规范》tokens.css)。 + */ import { useMemo, useState } from "react"; import { AlertCircle, @@ -43,6 +48,8 @@ import { stringifyThemeMap, } from "@/strategy-components/lib/light-app"; +import "./light-app-management.css"; + interface FormState { name: string; description: string; @@ -63,7 +70,10 @@ const PARAM_TYPE_OPTIONS: { label: string; value: "login" | "theme" }[] = [ { label: "主题参数", value: "theme" }, ]; -const DIALOG_SELECT_POPUP = { attach: () => document.body }; +const DIALOG_SELECT_POPUP = { + attach: () => document.body, + overlayClassName: "lam-select-popup", +}; const emptyForm = (): FormState => ({ name: "", @@ -213,14 +223,14 @@ export default function LightAppManagementPage() { const tableApps = useMemo(() => apps, [apps]); return ( -
+
-

+

应用管理

-

+

注册轻应用。 window.open @@ -235,14 +245,14 @@ export default function LightAppManagementPage() {

-
+
setSearch(e.target.value)} placeholder="搜索应用名称 / 描述..." - className="w-full rounded-[5px] border border-neutral-100 bg-neutral-50/60 py-2 pl-10 pr-10 text-xs text-neutral-800 placeholder-neutral-400 transition-all hover:bg-white focus:border-[#F5222D]/40 focus:outline-none focus:ring-4 focus:ring-[#F5222D]/10" + className="lam-search-input w-full rounded-[5px] border border-neutral-100 bg-neutral-50/60 py-2 pl-10 pr-10 text-xs text-neutral-800 placeholder-neutral-400 transition-all hover:bg-white focus:border-[#F5222D]/40 focus:outline-none focus:ring-4 focus:ring-[#F5222D]/10" /> {search && (
-
- 搜寻到 {tableApps.length}{" "} +
+ 搜寻到 {tableApps.length}{" "} 个注册完成的子模块轻应用
{viewMode === "table" ? ( -
- +
+
@@ -310,19 +320,19 @@ export default function LightAppManagementPage() { {tableApps.map((app) => (
名称
- + {app.name} {app.appType === "window_open" ? ( - + 新页跳转 ) : ( - + iframe 嵌套 @@ -330,7 +340,7 @@ export default function LightAppManagementPage() { - {app.appType === "iframe" && app.mountLocation ? ( - - + + {mountLabel(app.mountLocation)} ) : ( @@ -370,7 +380,7 @@ export default function LightAppManagementPage() { activeParamPopover === app.appId ? null : app.appId ) } - className="inline-flex cursor-pointer items-center space-x-1.5 rounded-[5px] border border-neutral-200/60 bg-neutral-50/60 px-2.5 py-1 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-100" + className="lam-param-btn inline-flex cursor-pointer items-center space-x-1.5 rounded-[5px] border border-neutral-200/60 bg-neutral-50/60 px-2.5 py-1 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-100" > {app.routeParams.length}个 {activeParamPopover === app.appId ? ( @@ -389,14 +399,14 @@ export default function LightAppManagementPage() { className="fixed inset-0 z-40" onClick={() => setActiveParamPopover(null)} /> -
-
+
+
注入的路由参数 @@ -405,11 +415,11 @@ export default function LightAppManagementPage() { {app.routeParams.map((p, idx) => (
- {p.key} - + {p.key} + {getParamSourceLabel(p)}
@@ -427,15 +437,15 @@ export default function LightAppManagementPage() { {app.enabled ? ( <> - - + + - 已启用 + 已启用 ) : ( <> - - 已停用 + + 已停用 )}
@@ -445,14 +455,14 @@ export default function LightAppManagementPage() {
-
+
-

+

{isLoading ? "加载中..." : "暂无匹配的组件应用"}

{!isLoading && ( -

+

{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}

)} @@ -487,15 +497,15 @@ export default function LightAppManagementPage() {
) : tableApps.length === 0 ? ( -
-
+
+
-

+

{isLoading ? "加载中..." : "暂无匹配的组件应用"}

{!isLoading && ( -

+

{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}

)} @@ -505,7 +515,7 @@ export default function LightAppManagementPage() { {tableApps.map((app) => (
{app.appType === "window_open" ? ( - + 新页跳转 ) : ( - + iframe 嵌套 )} -

+

{app.name}

{app.enabled ? ( - - + + 已启用 ) : ( - - + + 已停用 )}
-

+

{app.description || "暂无应用详细描述说明。"}

-
+
{app.url} @@ -568,7 +578,7 @@ export default function LightAppManagementPage() { {app.routeParams.map((p, idx) => ( {p.key} @@ -577,24 +587,24 @@ export default function LightAppManagementPage() { )} {app.appType === "iframe" && app.mountLocation && ( -
+
挂载分类: {mountLabel(app.mountLocation)}
)}
-
+
{viewMode === "table" && ( -
-
+
+

智能多维挂载规则:

-
    +
    • - • + • 当您注册 iframe 嵌套{" "} 应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。
    • - • + • 当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "} 真实 Iframe 预览器 @@ -636,23 +646,23 @@ export default function LightAppManagementPage() { {dialogOpen && (
      setDialogOpen(false)} /> -
      -
      +
      +
      - + -

      +

      {editing ? "修改轻应用" : "注册应用"}

      @@ -660,8 +670,8 @@ export default function LightAppManagementPage() {
      -
      -
      -
      {form.appType === "iframe" && (
      -