Merge branch 'dhk-1' into 'master'

fix(sandbox): 修复 Markdown 编辑保存后内容被重置回原值

See merge request knowledge-base-bj/deerflow-server!121
This commit is contained in:
董宏凯 committed 2026-06-15 09:11:09 +00:00
commit 5dc1ed5685
30 files changed
+2646 -959

No files matched your search

+6 -2
View File
@@ -99,11 +99,15 @@ export function App() {
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage />}
element={<PublicRoundtableSharePage view="landing" />}
/>
<Route
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage report />}
element={<PublicRoundtableSharePage view="report" />}
/>
<Route
path="/roundtable-share/:shareCode/dashboard"
element={<PublicRoundtableSharePage view="dashboard" />}
/>
<Route
path="/public/scheduled-tasks/:taskId"
+245
View File
@@ -498,3 +498,248 @@
background: linear-gradient(90deg, rgba(62, 178, 255, 0) 0%, #4ee6ff 50%, rgba(62, 178, 255, 0) 100%);
box-shadow: 0 0 10px rgba(78, 230, 255, 0.72);
}
/* ══════════════════════════════════════════════════════════════════════
浅色主题导航(红/蓝两套,颜色经 --brand-* 变量自动切换)
逻辑同深色:一级导航在顶栏、二级导航在侧边栏、三级导航在顶栏下方横条。
但一级导航不做深色那种围绕基座的特殊处理,正常横排即可。
══════════════════════════════════════════════════════════════════════ */
/* ── 顶栏一级导航(白字落在 red/blue-header banner 上)──────────────────── */
.light-topnav {
display: flex;
align-items: center;
height: 64px;
min-width: 0;
gap: 6px;
/* 整体往右移(用百分比,随容器宽度自适应;约等于宽屏下的 200px 量级) */
margin-left: 10%;
overflow-x: auto;
overflow-y: hidden;
}
.light-topnav-item {
position: relative;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
height: 60px;
padding: 0 18px;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.85);
/* 复用全局字体栈(与「多智能体会商」等页面一致):Source Han Sans SC 在 Windows 上
常按字重分家族,放栈首会导致 font-weight 选不到粗体;用 --font-sans(Noto Sans SC
领先)才能正常加粗。 */
font-family: var(--font-sans);
font-size: 16px;
/* 普通项用 500(雅黑兜底下渲染为常规字面),选中项 700 才能看出粗体对比 */
font-weight: 500;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: color 0.18s ease;
}
.light-topnav-item:hover {
color: #ffffff;
}
/* 选中态:不改背景,文字加粗变亮 + 贴近导航栏底部一条粗白线 */
.light-topnav-item--active,
.light-topnav-item--active:hover {
color: #ffffff;
font-weight: 700;
}
.light-topnav-item--active::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: 0;
height: 4px;
border-radius: 3px 3px 0 0;
background: #ffffff;
}
/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
.light-sidebar {
position: fixed;
left: 0;
top: 4rem;
z-index: 40;
width: 96px;
height: calc(100vh - 4rem);
display: flex;
flex-direction: row;
background: #ffffff;
border-right: 1px solid var(--border);
box-sizing: border-box;
overflow: hidden;
/* 展开三级列时整体变宽的过渡动画 */
transition: width 0.3s ease;
}
/* 展开三级列:96(二级) + 140(三级) = 236px */
.light-sidebar--expanded {
width: 236px;
}
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
.light-sidebar__primary {
flex: 0 0 96px;
width: 96px;
height: 100%;
display: flex;
flex-direction: column;
/* 横向 padding 置 0:让选中态浅红背景能铺满与列宽一致 */
padding: 10px 0;
box-sizing: border-box;
}
/* 三级列:固定 140px,随侧边栏展开淡入 */
.light-sidebar__secondary {
flex: 0 0 140px;
width: 140px;
height: 100%;
border-left: 1px solid var(--border);
/* 横向 padding 置 0:让三级选中背景能铺满与列宽一致(文字内缩由 subitem 自身 padding 保证) */
padding: 12px 0;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
background: #ffffff;
opacity: 0;
transition: opacity 0.2s ease;
}
.light-sidebar--expanded .light-sidebar__secondary {
opacity: 1;
}
.light-sidebar__list {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
overflow-x: hidden;
}
.light-sidebar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 14px 4px;
border: 0;
border-radius: 0;
background: transparent;
color: #222222;
/* 复用全局字体栈,确保加粗生效(详见 .light-topnav-item 注释) */
font-family: var(--font-sans);
font-size: 16px;
font-weight: 500;
line-height: 1.3;
text-align: center;
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.light-sidebar-item__icon {
flex-shrink: 0;
/* 图标统一用主题色(红主题红、蓝主题蓝) */
color: var(--brand-900);
transition: color 0.18s ease, transform 0.18s ease;
}
.light-sidebar-item__label {
display: block;
width: 100%;
word-break: break-word;
}
.light-sidebar-item:hover {
background: var(--brand-200);
}
.light-sidebar-item:hover .light-sidebar-item__icon {
color: var(--brand-900);
transform: scale(1.05);
}
.light-sidebar-item--active {
background: var(--brand-200);
color: var(--brand-900);
font-weight: 600;
/* 选中态:去圆角去边框,浅红底铺满父元素宽度,右侧一条粗主题色边(用 inset 阴影避免挤压内容) */
box-shadow: inset -4px 0 0 0 var(--brand-900);
}
.light-sidebar-item--active .light-sidebar-item__icon {
color: var(--brand-900);
}
/* 侧边栏底部:设置入口(浅色模式不支持收起,故无收起按钮) */
.light-sidebar__footer {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
/* ── 三级列条目(左对齐图标 + 文字,参考 xiugai-1.png)─────────────────────── */
.light-sidebar-subnav {
display: flex;
flex-direction: column;
gap: 2px;
}
.light-sidebar-subitem {
position: relative;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px;
border: 0;
background: transparent;
color: #222222;
font-family: var(--font-sans);
font-size: 14px;
font-weight: 500;
line-height: 1.3;
text-align: left;
cursor: pointer;
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.light-sidebar-subitem__icon {
flex-shrink: 0;
/* 图标用主题色(红主题红、蓝主题蓝) */
color: var(--brand-900);
}
.light-sidebar-subitem__label {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.light-sidebar-subitem:hover {
background: var(--brand-200);
}
.light-sidebar-subitem--active {
background: var(--brand-200);
color: var(--brand-900);
font-weight: 700;
box-shadow: inset -4px 0 0 0 var(--brand-900);
}
@@ -0,0 +1,303 @@
import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Bell,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
Folder,
FolderOpen,
Settings,
User,
Wrench,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { SettingsDialog } from "@/components/workspace/settings";
import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import {
defaultExpandedIds,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
type PageSidebarProps = {
collapsed: boolean;
onToggleCollapsed: () => 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<Set<string>>(() => new Set(defaultExpandedIds));
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] =
useState<SettingsSection>("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 (
<button
key={item.id}
type="button"
title={collapsed && depth === 0 ? item.label : undefined}
onClick={() => item.path && navigate(item.path)}
className={menuButtonClass(isActive, depth)}
>
{isActive && (
<span
className={`absolute left-0 inset-y-0 w-[3px] rounded-r ${
isDark ? "bg-cyan-300" : "bg-red-600"
}`}
/>
)}
<Icon className={`${depth === 0 ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105`} />
{(!collapsed || depth > 0) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
</span>
)}
</button>
);
};
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 (
<div key={item.id} className="space-y-1">
<button
type="button"
onClick={() => {
if (collapsed && isTopLevel) {
onToggleCollapsed();
return;
}
toggleExpanded(item.id);
}}
title={collapsed && isTopLevel ? item.label : undefined}
className={menuButtonClass(false, depth)}
>
<Icon
className={`${isTopLevel ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105 ${
expanded && depth > 0 ? "scale-105" : ""
}`}
/>
{(!collapsed || !isTopLevel) && (
<span className={labelClass(false, depth > 0)}>
{item.label}
</span>
)}
{(!collapsed || !isTopLevel) && (
<ChevronRight
className={`h-4 w-4 transition-transform duration-300 ease-out ${expanded ? "rotate-90" : ""}`}
/>
)}
</button>
{(!collapsed || !isTopLevel) && item.children && (
<div
className={`${panelClass} overflow-hidden transition-all duration-300 ease-out ${
expanded ? "max-h-[1200px] opacity-100" : "max-h-0 opacity-0"
}`}
>
{item.children.map((child) =>
child.children ? renderBranch(child, depth + 1) : renderLeaf(child, depth + 1),
)}
</div>
)}
</div>
);
};
return (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<aside
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white"
} ${collapsed ? "w-16" : "w-64"}`}
>
<button
type="button"
onClick={onToggleCollapsed}
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
className={`absolute -right-3 top-6 z-50 flex h-6 w-6 items-center justify-center rounded-full shadow-md theme-transition ${
isDark
? "bg-slate-700 text-slate-300 hover:bg-slate-600"
: "bg-white text-gray-500 hover:bg-red-50 hover:text-red-600 border border-red-100"
}`}
>
{collapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronLeft className="h-3 w-3" />}
</button>
<nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
{visibleMenuItems.map((item) =>
item.children ? renderBranch(item) : renderLeaf(item),
)}
</nav>
<div
className={`mt-auto shrink-0 theme-transition ${
isDark ? "border-t border-blue-800/30" : "border-t border-red-100"
} ${collapsed ? "p-2" : "p-4"}`}
>
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title={collapsed ? t.workspace.settingsAndMore : undefined}
className={footerButtonClass}
>
<Settings className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<>
<span className={labelClass(false)}>
{t.workspace.settingsAndMore}
</span>
<ChevronsUpDown className="h-4 w-4 flex-shrink-0" />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={collapsed ? "start" : "end"}
side={collapsed ? "right" : "top"}
sideOffset={4}
className="min-w-56 rounded-lg"
>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<Bell className="size-4" />
{t.notifications.title}
{unreadCount > 0 && (
<span
className={`ml-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium leading-none text-white ${
isDark ? "bg-cyan-500" : "bg-red-500"
}`}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("tools")}>
<Wrench className="size-4" />
{t.settings.sections.tools}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className={`${footerButtonClass} pointer-events-none`}
>
<Settings className="h-5 w-5 flex-shrink-0" />
{!collapsed && (
<span className={labelClass(false)}>
{t.workspace.settingsAndMore}
</span>
)}
</button>
)}
</div>
<div
className={`pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full blur-3xl theme-transition ${
isDark ? "bg-blue-500/10" : "bg-red-500/5"
}`}
/>
<div
className={`pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full blur-3xl theme-transition ${
isDark ? "bg-cyan-400/5" : "bg-red-400/5"
}`}
/>
</aside>
</>
);
}
+199 -4
View File
@@ -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 (
<LegacyAccordionSidebar
collapsed={collapsed}
onToggleCollapsed={onToggleCollapsed}
/>
);
}
return <SecondaryNavSidebar />;
}
/**
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --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<SettingsSection>("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 (
<>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<aside className={`light-sidebar ${expanded ? "light-sidebar--expanded" : ""}`}>
{/* 一级列:二级图标卡片 + 底部设置 */}
<div className="light-sidebar__primary">
<nav className="light-sidebar__list" aria-label="二级菜单">
{secondaryItems.map((item) => {
const Icon = item.icon;
const active = isItemActive(item);
return (
<button
key={item.id}
type="button"
title={item.label}
onClick={() => handleItemClick(item)}
className={`light-sidebar-item ${active ? "light-sidebar-item--active" : ""}`}
>
<Icon className="light-sidebar-item__icon h-7 w-7" />
<span className="light-sidebar-item__label">{item.label}</span>
</button>
);
})}
</nav>
<div className="light-sidebar__footer">
{mounted ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
title={t.workspace.settingsAndMore}
className="light-sidebar-item"
>
<Settings className="light-sidebar-item__icon h-7 w-7" />
<span className="light-sidebar-item__label">设置</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="right"
sideOffset={8}
className="min-w-56 rounded-lg"
>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<Bell className="size-4" />
{t.notifications.title}
{unreadCount > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--brand-900)] px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("tools")}>
<Wrench className="size-4" />
{t.settings.sections.tools}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openSettings("account")}>
<User className="size-4" />
个人信息
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
{/* 二级列:三级导航(激活二级项有三级子项时展开,整体动画见 css width transition) */}
<div className="light-sidebar__secondary" aria-hidden={!expanded}>
<nav className="light-sidebar-subnav" aria-label="三级菜单">
{thirdItems.map((child) => {
const ChildIcon = child.icon;
const childActive = child.path
? pathname === child.path
: hasActiveChild(child, pathname);
return (
<button
key={child.id}
type="button"
title={child.label}
onClick={() => handleItemClick(child)}
className={`light-sidebar-subitem ${childActive ? "light-sidebar-subitem--active" : ""}`}
>
<ChildIcon className="light-sidebar-subitem__icon h-4 w-4" />
<span className="light-sidebar-subitem__label">{child.label}</span>
</button>
);
})}
</nav>
</div>
</aside>
</>
);
}
/**
* 旧版浅色整树手风琴侧边栏。仅用于 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";
@@ -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,
});
@@ -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 }) {
<div className="flex gap-2">
<Button
type="button"
variant={settings.display.layoutMode !== "compact" ? "default" : "outline"}
variant={effectiveLayoutMode !== "compact" ? "default" : "outline"}
size="sm"
onClick={() => {
setSettings("display", { layoutMode: "full" });
setLayoutMode("full");
onClose?.();
navigate("/page/workspace/chats/new");
}}
@@ -169,10 +185,10 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
</Button>
<Button
type="button"
variant={settings.display.layoutMode === "compact" ? "default" : "outline"}
variant={effectiveLayoutMode === "compact" ? "default" : "outline"}
size="sm"
onClick={() => {
setSettings("display", { layoutMode: "compact" });
setLayoutMode("compact");
onClose?.();
navigate("/page/workspace/chats/new");
}}
@@ -183,6 +199,43 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
</div>
</SettingsSection>
{isAdmin && (
<>
<Separator />
<SettingsSection
title="系统默认界面模式(管理员)"
description="给所有还没有手动切换过界面模式的用户的默认;已经自己切换过的用户保留其个人选择不受影响。"
>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={globalLayoutMode === "full" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetDefaultLayoutMode("full")}
disabled={updateAppearance.isPending}
>
<PanelLeftIcon className="mr-1.5 size-4" />
全量模式
</Button>
<Button
type="button"
variant={globalLayoutMode === "compact" ? "default" : "outline"}
size="sm"
onClick={() => void handleSetDefaultLayoutMode("compact")}
disabled={updateAppearance.isPending}
>
<LayoutIcon className="mr-1.5 size-4" />
简洁模式
</Button>
<p className="text-muted-foreground ml-2 text-xs">
当前系统默认:
{globalLayoutMode === "full" ? "全量模式" : "简洁模式"}
</p>
</div>
</SettingsSection>
</>
)}
{isAdmin && (
<>
<Separator />
+12 -2
View File
@@ -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,
+36 -7
View File
@@ -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<string, unknown> = {};
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<string, unknown>;
}
}
} catch {
info = {};
}
info.accessToken = trimmed;
localStorage.setItem(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info));
}
@@ -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<string> = 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
@@ -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]);
}
@@ -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;
}
@@ -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<AppearanceSettings> {
@@ -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,
+4 -8
View File
@@ -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 && (
<div className="text-muted-foreground/70 w-full -translate-y-2 text-center text-[11px]">
CM Assistant V1.2 · 深度推理可能会涉及数据同步 · 信息由AI自动生成,请审慎参考
</div>
)}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
+3 -1
View File
@@ -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 (
<div className="h-full overflow-y-auto bg-zinc-50/60 font-sans text-zinc-900 antialiased dark:bg-[#0C1836] dark:text-[#C5CFE0]">
<div className="light-app-list-page h-full overflow-y-auto bg-zinc-50/60 font-sans text-zinc-900 antialiased dark:bg-[#0C1836] dark:text-[#C5CFE0]">
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col gap-6 px-4 py-7 sm:px-6 lg:px-8">
{/* 标题 */}
<div className="space-y-1">
+110 -100
View File
@@ -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 (
<div className="h-full overflow-y-auto bg-neutral-50">
<div className="light-app-management-page h-full overflow-y-auto bg-neutral-50 dark:bg-[#0C1836] dark:text-[#C5CFE0]">
<div className="mx-auto flex min-h-full w-full max-w-7xl flex-col space-y-7 px-8 py-8">
<div className="flex flex-col justify-between gap-6 md:flex-row md:items-center">
<div className="space-y-1.5">
<h1 className="flex items-center text-xl font-black tracking-tight text-neutral-900">
<h1 className="lam-title flex items-center text-xl font-black tracking-tight text-neutral-900">
应用管理
</h1>
<p className="max-w-2xl text-xs leading-relaxed text-neutral-500">
<p className="lam-desc max-w-2xl text-xs leading-relaxed text-neutral-500">
注册轻应用。
<code className="mx-1 rounded-[5px] bg-neutral-100 px-1.5 py-0.5 font-mono text-[11px] text-neutral-600">
window.open
@@ -235,14 +245,14 @@ export default function LightAppManagementPage() {
<button
onClick={openCreate}
className="flex cursor-pointer items-center justify-center space-x-1.5 rounded-[5px] bg-[#A8071A] px-5 py-2.5 text-xs font-semibold text-white shadow-md shadow-rose-950/10 transition-all hover:bg-[#BC0D1E] hover:shadow-lg hover:shadow-rose-900/15 active:bg-[#820014]"
className="lam-primary-btn flex cursor-pointer items-center justify-center space-x-1.5 rounded-[5px] bg-[#A8071A] px-5 py-2.5 text-xs font-semibold text-white shadow-md shadow-rose-950/10 transition-all hover:bg-[#BC0D1E] hover:shadow-lg hover:shadow-rose-900/15 active:bg-[#820014]"
>
<Plus className="h-4 w-4" />
<span>注册应用</span>
</button>
</div>
<div className="flex flex-col items-center gap-3.5 rounded-[5px] bg-white p-3 shadow-[0_1px_3px_rgba(0,0,0,0.01)] md:flex-row">
<div className="lam-toolbar flex flex-col items-center gap-3.5 rounded-[5px] bg-white p-3 shadow-[0_1px_3px_rgba(0,0,0,0.01)] md:flex-row">
<div className="relative w-full flex-1">
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400" />
<input
@@ -250,7 +260,7 @@ export default function LightAppManagementPage() {
value={search}
onChange={(e) => 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 && (
<button
@@ -262,12 +272,12 @@ export default function LightAppManagementPage() {
)}
</div>
<div className="flex shrink-0 items-center space-x-0.5 self-end rounded-[5px] border border-neutral-200/50 bg-neutral-100/60 p-0.5 md:self-auto">
<div className="lam-view-toggle flex shrink-0 items-center space-x-0.5 self-end rounded-[5px] border border-neutral-200/50 bg-neutral-100/60 p-0.5 md:self-auto">
<button
onClick={() => setViewMode("table")}
className={`cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
className={`lam-view-toggle-btn cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
viewMode === "table"
? "bg-white text-[#F5222D] shadow-sm"
? "is-active bg-white text-[#F5222D] shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
@@ -275,9 +285,9 @@ export default function LightAppManagementPage() {
</button>
<button
onClick={() => setViewMode("cards")}
className={`cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
className={`lam-view-toggle-btn cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
viewMode === "cards"
? "bg-white text-[#F5222D] shadow-sm"
? "is-active bg-white text-[#F5222D] shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
@@ -287,14 +297,14 @@ export default function LightAppManagementPage() {
</div>
<div className="space-y-4">
<div className="text-xs font-medium text-neutral-500">
搜寻到 <span className="font-bold text-[#F5222D]">{tableApps.length}</span>{" "}
<div className="lam-count text-xs font-medium text-neutral-500">
搜寻到 <strong className="font-bold text-[#F5222D]">{tableApps.length}</strong>{" "}
个注册完成的子模块轻应用
</div>
{viewMode === "table" ? (
<div className="overflow-visible rounded-[5px] bg-white shadow-[0_1px_3px_0_rgba(0,0,0,0.01)]">
<table className="w-full table-fixed border-collapse text-left">
<div className="lam-table-wrap overflow-visible rounded-[5px] bg-white shadow-[0_1px_3px_0_rgba(0,0,0,0.01)]">
<table className="lam-table w-full table-fixed border-collapse text-left">
<thead>
<tr className="select-none border-b border-neutral-100/80 bg-neutral-50/40 text-[12.5px] font-semibold text-neutral-500">
<th className="w-[15%] px-6 py-4">名称</th>
@@ -310,19 +320,19 @@ export default function LightAppManagementPage() {
{tableApps.map((app) => (
<tr key={app.appId} className="group align-middle transition-colors hover:bg-neutral-50/40">
<td className="px-6 py-[18px]">
<span className="text-[14px] font-bold text-neutral-800 transition-colors group-hover:text-[#F5222D]">
<span className="lam-row-name text-[14px] font-bold text-neutral-800 transition-colors group-hover:text-[#F5222D]">
{app.name}
</span>
</td>
<td className="px-4 py-[18px]">
{app.appType === "window_open" ? (
<span className="inline-flex select-none items-center space-x-1.5 rounded-[5px] border border-rose-100 bg-rose-50 px-3 py-1 text-[12px] font-semibold text-rose-800">
<span className="lam-tag-window inline-flex select-none items-center space-x-1.5 rounded-[5px] border border-rose-100 bg-rose-50 px-3 py-1 text-[12px] font-semibold text-rose-800">
<ExternalLink className="h-3.5 w-3.5" />
<span>新页跳转</span>
</span>
) : (
<span className="inline-flex select-none items-center space-x-1.5 rounded-[5px] border border-sky-100 bg-sky-50 px-3 py-1 text-[12px] font-semibold text-sky-800">
<span className="lam-tag-iframe inline-flex select-none items-center space-x-1.5 rounded-[5px] border border-sky-100 bg-sky-50 px-3 py-1 text-[12px] font-semibold text-sky-800">
<Monitor className="h-3.5 w-3.5" />
<span>iframe 嵌套</span>
</span>
@@ -330,7 +340,7 @@ export default function LightAppManagementPage() {
</td>
<td className="overflow-hidden px-4 py-[18px]">
<div className="flex max-w-full items-center space-x-1.5 font-mono text-[12px] text-neutral-600 transition-colors group-hover:text-neutral-800">
<div className="lam-url flex max-w-full items-center space-x-1.5 font-mono text-[12px] text-neutral-600 transition-colors group-hover:text-neutral-800">
<a
href={app.url}
target="_blank"
@@ -342,7 +352,7 @@ export default function LightAppManagementPage() {
</a>
<button
onClick={() => handleCopyUrl(app.url)}
className="shrink-0 rounded-[5px] p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
className="lam-icon-btn shrink-0 rounded-[5px] p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
title="复制网址"
>
<Copy className="h-3 w-3" />
@@ -352,8 +362,8 @@ export default function LightAppManagementPage() {
<td className="px-4 py-[18px]">
{app.appType === "iframe" && app.mountLocation ? (
<span className="inline-flex max-w-full items-center space-x-1.5 rounded-[5px] bg-sky-50 px-2 py-0.5 text-[11px] font-medium text-sky-700">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
<span className="lam-mount-tag inline-flex max-w-full items-center space-x-1.5 rounded-[5px] bg-sky-50 px-2 py-0.5 text-[11px] font-medium text-sky-700">
<span className="lam-mount-dot h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
<span className="truncate">{mountLabel(app.mountLocation)}</span>
</span>
) : (
@@ -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"
>
<span>{app.routeParams.length}个</span>
{activeParamPopover === app.appId ? (
@@ -389,14 +399,14 @@ export default function LightAppManagementPage() {
className="fixed inset-0 z-40"
onClick={() => setActiveParamPopover(null)}
/>
<div className="absolute left-4 top-12 z-50 w-[280px] animate-in fade-in rounded-[5px] border border-neutral-100 bg-white p-3.5 text-[12px] shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="mb-2.5 flex items-center justify-between border-b border-neutral-100 pb-2">
<div className="lam-param-popover absolute left-4 top-12 z-50 w-[280px] animate-in fade-in rounded-[5px] border border-neutral-100 bg-white p-3.5 text-[12px] shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="lam-param-popover-head mb-2.5 flex items-center justify-between border-b border-neutral-100 pb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-neutral-400">
注入的路由参数
</span>
<button
onClick={() => setActiveParamPopover(null)}
className="text-[10px] font-semibold text-rose-700 hover:underline"
className="lam-param-popover-close text-[10px] font-semibold text-rose-700 hover:underline"
>
关闭
</button>
@@ -405,11 +415,11 @@ export default function LightAppManagementPage() {
{app.routeParams.map((p, idx) => (
<div
key={`${app.appId}-${idx}`}
className="rounded-[5px] border border-neutral-100 bg-neutral-50/60 p-2 text-[11px] leading-relaxed"
className="lam-param-item rounded-[5px] border border-neutral-100 bg-neutral-50/60 p-2 text-[11px] leading-relaxed"
>
<div className="flex items-center justify-between gap-1 font-mono">
<span className="truncate font-bold text-rose-700">{p.key}</span>
<span className="truncate text-[10px] text-neutral-500">
<span className="lam-param-key truncate font-bold text-rose-700">{p.key}</span>
<span className="lam-param-val truncate text-[10px] text-neutral-500">
{getParamSourceLabel(p)}
</span>
</div>
@@ -427,15 +437,15 @@ export default function LightAppManagementPage() {
{app.enabled ? (
<>
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span className="lam-status-on-ping absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="lam-status-on-dot relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
</span>
<span className="text-[12.5px] font-bold text-emerald-700">已启用</span>
<span className="lam-status-on text-[12.5px] font-bold text-emerald-700">已启用</span>
</>
) : (
<>
<span className="h-2.5 w-2.5 shrink-0 rounded-full bg-neutral-300" />
<span className="text-[12.5px] font-medium text-neutral-400">已停用</span>
<span className="lam-status-off-dot h-2.5 w-2.5 shrink-0 rounded-full bg-neutral-300" />
<span className="lam-status-off text-[12.5px] font-medium text-neutral-400">已停用</span>
</>
)}
</div>
@@ -445,14 +455,14 @@ export default function LightAppManagementPage() {
<div className="flex items-center justify-center space-x-1">
<button
onClick={() => openEdit(app)}
className="cursor-pointer rounded-[5px] p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-emerald-600"
className="lam-icon-btn cursor-pointer rounded-[5px] p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-emerald-600"
title="编辑配置"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(app)}
className="cursor-pointer rounded-[5px] p-1.5 text-neutral-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
className="lam-icon-btn lam-icon-btn-danger cursor-pointer rounded-[5px] p-1.5 text-neutral-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
title="删除配置"
>
<Trash2 className="h-4 w-4" />
@@ -466,15 +476,15 @@ export default function LightAppManagementPage() {
<tr>
<td colSpan={7} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-300">
<div className="lam-empty-icon flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-300">
<Info className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="text-[14px] font-semibold text-neutral-400">
<p className="lam-empty-title text-[14px] font-semibold text-neutral-400">
{isLoading ? "加载中..." : "暂无匹配的组件应用"}
</p>
{!isLoading && (
<p className="text-[12px] text-neutral-300">
<p className="lam-empty-sub text-[12px] text-neutral-300">
{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}
</p>
)}
@@ -487,15 +497,15 @@ export default function LightAppManagementPage() {
</table>
</div>
) : tableApps.length === 0 ? (
<div className="rounded-[5px] bg-white py-20 text-center">
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-300">
<div className="lam-table-wrap rounded-[5px] bg-white py-20 text-center">
<div className="lam-empty-icon mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-neutral-100 text-neutral-300">
<Info className="h-6 w-6" />
</div>
<p className="text-[14px] font-semibold text-neutral-400">
<p className="lam-empty-title text-[14px] font-semibold text-neutral-400">
{isLoading ? "加载中..." : "暂无匹配的组件应用"}
</p>
{!isLoading && (
<p className="mt-1 text-[12px] text-neutral-300">
<p className="lam-empty-sub mt-1 text-[12px] text-neutral-300">
{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}
</p>
)}
@@ -505,7 +515,7 @@ export default function LightAppManagementPage() {
{tableApps.map((app) => (
<div
key={app.appId}
className="group flex flex-col justify-between overflow-hidden rounded-[5px] bg-white shadow-[0_1px_3px_0_rgba(0,0,0,0.01)] transition-all hover:shadow-md"
className="lam-card group flex flex-col justify-between overflow-hidden rounded-[5px] bg-white shadow-[0_1px_3px_0_rgba(0,0,0,0.01)] transition-all hover:shadow-md"
>
<div
className={`h-1.5 w-full bg-gradient-to-r ${
@@ -518,38 +528,38 @@ export default function LightAppManagementPage() {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1.5">
{app.appType === "window_open" ? (
<span className="inline-flex items-center space-x-1 rounded-[5px] border border-rose-100 bg-rose-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-rose-700">
<span className="lam-tag-window inline-flex items-center space-x-1 rounded-[5px] border border-rose-100 bg-rose-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-rose-700">
<ExternalLink className="h-3 w-3" />
<span>新页跳转</span>
</span>
) : (
<span className="inline-flex items-center space-x-1 rounded-[5px] border border-sky-100 bg-sky-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-sky-700">
<span className="lam-tag-iframe inline-flex items-center space-x-1 rounded-[5px] border border-sky-100 bg-sky-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-sky-700">
<Monitor className="h-3 w-3" />
<span>iframe 嵌套</span>
</span>
)}
<h4 className="line-clamp-1 text-sm font-bold leading-snug text-neutral-800 transition-colors group-hover:text-[#F5222D]">
<h4 className="lam-card-title line-clamp-1 text-sm font-bold leading-snug text-neutral-800 transition-colors group-hover:text-[#F5222D]">
{app.name}
</h4>
</div>
{app.enabled ? (
<span className="inline-flex shrink-0 items-center space-x-1 text-[11px] font-bold text-emerald-700">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
<span className="lam-status-on inline-flex shrink-0 items-center space-x-1 text-[11px] font-bold text-emerald-700">
<span className="lam-status-on-dot h-2 w-2 rounded-full bg-emerald-500" />
<span>已启用</span>
</span>
) : (
<span className="inline-flex shrink-0 items-center space-x-1 text-[11px] font-medium text-neutral-400">
<span className="h-2 w-2 rounded-full bg-neutral-300" />
<span className="lam-status-off inline-flex shrink-0 items-center space-x-1 text-[11px] font-medium text-neutral-400">
<span className="lam-status-off-dot h-2 w-2 rounded-full bg-neutral-300" />
<span>已停用</span>
</span>
)}
</div>
<p className="line-clamp-2 min-h-[2.25rem] text-xs leading-relaxed text-neutral-500">
<p className="lam-card-desc line-clamp-2 min-h-[2.25rem] text-xs leading-relaxed text-neutral-500">
{app.description || "暂无应用详细描述说明。"}
</p>
<div className="flex items-center space-x-1.5 rounded-[5px] bg-neutral-50/60 p-2 font-mono text-[11px] text-neutral-600">
<div className="lam-card-url-box flex items-center space-x-1.5 rounded-[5px] bg-neutral-50/60 p-2 font-mono text-[11px] text-neutral-600">
<Globe className="h-3.5 w-3.5 shrink-0 text-neutral-400" />
<span className="flex-1 truncate" title={app.url}>
{app.url}
@@ -568,7 +578,7 @@ export default function LightAppManagementPage() {
{app.routeParams.map((p, idx) => (
<span
key={`${app.appId}-card-${idx}`}
className="rounded-[5px] border border-neutral-200/40 bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-500"
className="lam-card-param rounded-[5px] border border-neutral-200/40 bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-500"
>
{p.key}
</span>
@@ -577,24 +587,24 @@ export default function LightAppManagementPage() {
)}
{app.appType === "iframe" && app.mountLocation && (
<div className="inline-flex items-center gap-1.5 rounded-[5px] bg-sky-50/60 p-2 text-[11px] font-medium leading-none text-sky-700">
<div className="lam-card-mount inline-flex items-center gap-1.5 rounded-[5px] bg-sky-50/60 p-2 text-[11px] font-medium leading-none text-sky-700">
<span>挂载分类:</span>
<code className="font-semibold">{mountLabel(app.mountLocation)}</code>
</div>
)}
</div>
<div className="flex items-center justify-end space-x-1 border-t border-neutral-100/60 bg-neutral-50/30 px-5 py-3">
<div className="lam-card-footer flex items-center justify-end space-x-1 border-t border-neutral-100/60 bg-neutral-50/30 px-5 py-3">
<button
onClick={() => openEdit(app)}
className="cursor-pointer rounded-[5px] p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-emerald-600"
className="lam-icon-btn cursor-pointer rounded-[5px] p-1.5 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-emerald-600"
title="编辑配置"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(app)}
className="cursor-pointer rounded-[5px] p-1.5 text-neutral-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
className="lam-icon-btn lam-icon-btn-danger cursor-pointer rounded-[5px] p-1.5 text-neutral-400 transition-colors hover:bg-rose-50 hover:text-rose-600"
title="删除配置"
>
<Trash2 className="h-4 w-4" />
@@ -607,21 +617,21 @@ export default function LightAppManagementPage() {
</div>
{viewMode === "table" && (
<div className="select-none rounded-[5px] bg-amber-50/40 p-5 shadow-[0_1px_2px_rgba(0,0,0,0.005)]">
<div className="flex items-center space-x-2 border-b border-amber-100/50 pb-3 text-amber-800">
<div className="lam-tip-box select-none rounded-[5px] bg-amber-50/40 p-5 shadow-[0_1px_2px_rgba(0,0,0,0.005)]">
<div className="lam-tip-head flex items-center space-x-2 border-b border-amber-100/50 pb-3 text-amber-800">
<AlertCircle className="h-4 w-4 shrink-0 text-amber-600" />
<h4 className="text-[13px] font-bold tracking-tight">智能多维挂载规则:</h4>
</div>
<ul className="mt-3.5 space-y-3 pl-1 text-[12px] leading-relaxed text-neutral-600">
<ul className="lam-tip-list mt-3.5 space-y-3 pl-1 text-[12px] leading-relaxed text-neutral-600">
<li className="flex items-start space-x-2">
<span className="mt-1 shrink-0 text-amber-600">•</span>
<span className="lam-tip-bullet mt-1 shrink-0 text-amber-600">•</span>
<span>
当您注册 <strong className="font-bold text-neutral-800">iframe 嵌套</strong>{" "}
应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。
</span>
</li>
<li className="flex items-start space-x-2">
<span className="mt-1 shrink-0 text-amber-600">•</span>
<span className="lam-tip-bullet mt-1 shrink-0 text-amber-600">•</span>
<span>
当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "}
<strong className="font-bold text-neutral-800">真实 Iframe 预览器</strong>
@@ -636,23 +646,23 @@ export default function LightAppManagementPage() {
{dialogOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center overflow-y-auto select-none">
<div
className="fixed inset-0 bg-[#3D3D3D]/40 backdrop-blur-[1px]"
className="lam-dialog-overlay fixed inset-0 bg-[#3D3D3D]/40 backdrop-blur-[1px]"
onClick={() => setDialogOpen(false)}
/>
<div className="relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[580px] flex-col rounded-[5px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="flex flex-shrink-0 items-center justify-between border-b border-[#E8E8E8] pb-4">
<div className="lam-dialog relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[580px] flex-col rounded-[5px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
<div className="lam-dialog-header flex flex-shrink-0 items-center justify-between border-b border-[#E8E8E8] pb-4">
<div className="flex items-center space-x-2.5">
<span className="rounded-[5px] bg-rose-50 p-1.5 text-[#A8071A]">
<span className="lam-dialog-icon rounded-[5px] bg-rose-50 p-1.5 text-[#A8071A]">
<Layers className="h-5 w-5" />
</span>
<h2 className="text-[17px] font-bold text-[#222222]">
<h2 className="lam-dialog-title text-[17px] font-bold text-[#222222]">
{editing ? "修改轻应用" : "注册应用"}
</h2>
</div>
<button
onClick={() => setDialogOpen(false)}
className="cursor-pointer rounded-[5px] p-1 text-[#A6A6A6] transition-colors hover:bg-[#FAFAFA] hover:text-[#F5222D]"
className="lam-dialog-close cursor-pointer rounded-[5px] p-1 text-[#A6A6A6] transition-colors hover:bg-[#FAFAFA] hover:text-[#F5222D]"
>
<X className="h-5 w-5" />
</button>
@@ -660,8 +670,8 @@ export default function LightAppManagementPage() {
<div className="flex-1 space-y-4 overflow-y-auto py-4 pr-1 text-[#222222]">
<div className="space-y-1.5">
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
<span className="font-bold text-[#F5222D]">*</span>
<label className="lam-form-label flex items-center space-x-1.5 text-[14px] font-medium">
<span className="lam-required font-bold text-[#F5222D]">*</span>
<Layers className="h-4 w-4 text-[#A6A6A6]" />
<span>应用类型</span>
</label>
@@ -672,7 +682,7 @@ export default function LightAppManagementPage() {
popupProps={DIALOG_SELECT_POPUP}
onChange={(v) => patchForm({ appType: v as LightAppType })}
/>
<p className="text-[11.5px] leading-relaxed text-[#A6A6A6]">
<p className="lam-form-hint text-[11.5px] leading-relaxed text-[#A6A6A6]">
{form.appType === "window_open"
? "在新标签页或卡片式路由容器打开。多用于外部高度复杂的系统跳转。"
: "通过标准 iframe 嵌入页面,可直接挂载到左侧导航树相应分类内。"}
@@ -680,8 +690,8 @@ export default function LightAppManagementPage() {
</div>
<div className="space-y-1.5">
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
<span className="font-bold text-[#F5222D]">*</span>
<label className="lam-form-label flex items-center space-x-1.5 text-[14px] font-medium">
<span className="lam-required font-bold text-[#F5222D]">*</span>
<Tag className="h-4 w-4 text-[#A6A6A6]" />
<span>应用名称</span>
</label>
@@ -689,13 +699,13 @@ export default function LightAppManagementPage() {
value={form.name}
onChange={(e) => patchForm({ name: e.target.value })}
placeholder="如: 舆情看板"
className="h-9 rounded-[5px] border-[#DBDBDB] text-[14px]"
className="lam-input h-9 rounded-[5px] border-[#DBDBDB] text-[14px]"
/>
</div>
<div className="space-y-1.5">
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
<span className="font-bold text-[#F5222D]">*</span>
<label className="lam-form-label flex items-center space-x-1.5 text-[14px] font-medium">
<span className="lam-required font-bold text-[#F5222D]">*</span>
<Link2 className="h-4 w-4 text-[#A6A6A6]" />
<span>应用地址</span>
</label>
@@ -703,14 +713,14 @@ export default function LightAppManagementPage() {
value={form.url}
onChange={(e) => patchForm({ url: e.target.value })}
placeholder="https://example.com/page 或 http://47.88.25.99:3000/"
className="h-9 rounded-[5px] border-[#DBDBDB] font-mono text-[14px]"
className="lam-input h-9 rounded-[5px] border-[#DBDBDB] font-mono text-[14px]"
/>
</div>
{form.appType === "iframe" && (
<div className="animate-in fade-in space-y-1.5">
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
<span className="font-bold text-[#F5222D]">*</span>
<label className="lam-form-label flex items-center space-x-1.5 text-[14px] font-medium">
<span className="lam-required font-bold text-[#F5222D]">*</span>
<FolderTree className="h-4 w-4 text-[#A6A6A6]" />
<span>挂载菜单</span>
</label>
@@ -726,7 +736,7 @@ export default function LightAppManagementPage() {
)}
<div className="space-y-1.5">
<label className="flex items-center space-x-1.5 text-[14px] font-medium">
<label className="lam-form-label flex items-center space-x-1.5 text-[14px] font-medium">
<span className="w-2 shrink-0" />
<AlignLeft className="h-4 w-4 text-[#A6A6A6]" />
<span>应用描述 (可选)</span>
@@ -736,13 +746,13 @@ export default function LightAppManagementPage() {
onChange={(e) => patchForm({ description: e.target.value })}
placeholder="简要说明该应用的用途"
rows={2}
className="resize-none rounded-[5px] border-[#DBDBDB] text-[14px]"
className="lam-input resize-none rounded-[5px] border-[#DBDBDB] text-[14px]"
/>
</div>
<div className="space-y-2.5 pt-1.5">
<div className="flex items-center justify-between">
<span className="flex items-center space-x-1.5 text-[14px] font-medium">
<span className="lam-form-label flex items-center space-x-1.5 text-[14px] font-medium">
<Sliders className="h-4 w-4 text-[#A6A6A6]" />
<span>路由参数</span>
<span
@@ -755,7 +765,7 @@ export default function LightAppManagementPage() {
<button
type="button"
onClick={addParam}
className="flex h-7 cursor-pointer items-center space-x-1 rounded-[5px] border border-[#DBDBDB] bg-white px-2.5 text-[12px] font-bold text-[#575757] transition-colors hover:border-[#F5222D] hover:text-[#F5222D]"
className="lam-add-param-btn flex h-7 cursor-pointer items-center space-x-1 rounded-[5px] border border-[#DBDBDB] bg-white px-2.5 text-[12px] font-bold text-[#575757] transition-colors hover:border-[#F5222D] hover:text-[#F5222D]"
>
<Plus className="h-3.5 w-3.5" />
<span>添加路由参数</span>
@@ -763,14 +773,14 @@ export default function LightAppManagementPage() {
</div>
{form.routeParams.length > 0 ? (
<div className="max-h-[220px] space-y-2 overflow-y-auto rounded-[5px] border border-[#E8E8E8] bg-[#FAFAFA] p-1">
<div className="lam-param-panel max-h-[220px] space-y-2 overflow-y-auto rounded-[5px] border border-[#E8E8E8] bg-[#FAFAFA] p-1">
{form.routeParams.map((p, idx) => {
const themeMap =
p.paramType === "theme" ? parseThemeMap(p.themeValue) : {};
return (
<div
key={idx}
className="relative rounded-[5px] border border-[#E8E8E8] bg-white p-2"
className="lam-param-row relative rounded-[5px] border border-[#E8E8E8] bg-white p-2"
>
<div className="flex items-center space-x-2">
<div className="flex-1">
@@ -779,7 +789,7 @@ export default function LightAppManagementPage() {
value={p.key}
onChange={(e) => patchParam(idx, { key: e.target.value })}
placeholder="参数名 key"
className="h-8 w-full rounded-[5px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
className="lam-input h-8 w-full rounded-[5px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
</div>
@@ -817,7 +827,7 @@ export default function LightAppManagementPage() {
patchParam(idx, { customValue: e.target.value })
}
placeholder="自定义值"
className="h-8 w-[120px] rounded-[5px] border border-[#DBDBDB] px-2 text-[12px] focus:border-[#F5222D] focus:outline-none"
className="lam-input h-8 w-[120px] rounded-[5px] border border-[#DBDBDB] px-2 text-[12px] focus:border-[#F5222D] focus:outline-none"
/>
)}
</div>
@@ -826,7 +836,7 @@ export default function LightAppManagementPage() {
<button
type="button"
onClick={() => removeParam(idx)}
className="cursor-pointer rounded-[5px] p-1 text-[#A6A6A6] transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D]"
className="lam-icon-btn lam-icon-btn-danger cursor-pointer rounded-[5px] p-1 text-[#A6A6A6] transition-colors hover:bg-[#FEEDED] hover:text-[#F5222D]"
>
<X className="h-4 w-4" />
</button>
@@ -834,8 +844,8 @@ export default function LightAppManagementPage() {
{/* 主题参数:逐主题色配置传给目标应用的值(左=系统主题色,右=目标应用值,可改) */}
{p.paramType === "theme" && (
<div className="mt-2 space-y-1.5 rounded-[5px] bg-[#FAFAFA] p-2">
<p className="text-[11px] leading-relaxed text-[#A6A6A6]">
<div className="lam-param-theme-box mt-2 space-y-1.5 rounded-[5px] bg-[#FAFAFA] p-2">
<p className="lam-form-hint text-[11px] leading-relaxed text-[#A6A6A6]">
各系统主题色跳转/嵌套时传给该应用的值(默认同名,可按对方命名修改)
</p>
{APP_THEME_COLORS.map((c) => (
@@ -862,7 +872,7 @@ export default function LightAppManagementPage() {
})
}
placeholder={c.key}
className="h-7 flex-1 rounded-[5px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
className="lam-input h-7 flex-1 rounded-[5px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]"
/>
</div>
))}
@@ -873,19 +883,19 @@ export default function LightAppManagementPage() {
})}
</div>
) : (
<div className="rounded-[5px] border border-dashed border-[#E8E8E8] bg-[#FAFAFA] p-4 text-center text-[12px] text-[#A6A6A6]">
<div className="lam-param-empty rounded-[5px] border border-dashed border-[#E8E8E8] bg-[#FAFAFA] p-4 text-center text-[12px] text-[#A6A6A6]">
暂无注入的路由参数,加载地址时不传递多余拼接。
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-[#E8E8E8] py-2.5">
<span className="text-[14px] font-medium text-[#222222]">立即启用该应用</span>
<div className="lam-dialog-footer flex items-center justify-between border-t border-[#E8E8E8] py-2.5">
<span className="lam-form-label text-[14px] font-medium text-[#222222]">立即启用该应用</span>
<button
type="button"
onClick={() => patchForm({ enabled: !form.enabled })}
className={`relative h-[22px] w-10 cursor-pointer rounded-full p-0.5 transition-colors duration-200 ${
form.enabled ? "bg-[#F5222D]" : "bg-[#DBDBDB]"
form.enabled ? "lam-switch-on bg-[#F5222D]" : "lam-switch-off bg-[#DBDBDB]"
}`}
>
<div
@@ -897,12 +907,12 @@ export default function LightAppManagementPage() {
</div>
</div>
<div className="flex flex-shrink-0 items-center justify-end space-x-3 border-t border-[#E8E8E8] pt-4">
<div className="lam-dialog-footer flex flex-shrink-0 items-center justify-end space-x-3 border-t border-[#E8E8E8] pt-4">
<Button
variant="outline"
disabled={saving}
onClick={() => setDialogOpen(false)}
className="h-9 rounded-[5px] border-[#DBDBDB] px-[18px] text-[14px] font-semibold"
className="lam-dialog-cancel h-9 rounded-[5px] border-[#DBDBDB] px-[18px] text-[14px] font-semibold"
>
取消
</Button>
@@ -910,7 +920,7 @@ export default function LightAppManagementPage() {
type="button"
disabled={saving}
onClick={handleSave}
className="h-9 cursor-pointer rounded-[5px] bg-[#F5222D] px-[18px] text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-colors hover:bg-[#CF1322] active:bg-[#A8071A] disabled:cursor-not-allowed disabled:opacity-70"
className="lam-dialog-save h-9 cursor-pointer rounded-[5px] bg-[#F5222D] px-[18px] text-[14px] font-bold text-white shadow-[0_2px_8px_rgba(34,34,34,0.08)] transition-colors hover:bg-[#CF1322] active:bg-[#A8071A] disabled:cursor-not-allowed disabled:opacity-70"
>
{saving ? "保存中..." : editing ? "保存修改" : "立即注册"}
</button>
+7
View File
@@ -16,6 +16,7 @@ import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
import {
GENERAL_QA_ROUTE,
loginByYUserId,
persistAccessToken,
persistToolsToken,
} from '@/core/auth/yuser-login';
import {
@@ -53,6 +54,8 @@ export default function LoginPage() {
// 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage
const yUserId = searchParams.get('yUserId')?.trim() ?? '';
const toolToken = searchParams.get('token')?.trim() ?? '';
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
const accessToken = searchParams.get('access_token')?.trim() ?? '';
// 用户名直登口令门:开启后端开关时,口令从 ?password= 透传
const gatePassword = searchParams.get('password') ?? '';
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
@@ -137,6 +140,9 @@ export default function LoginPage() {
setStoredAuth(auth);
// 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖)
setStoredUserName(loginUserName);
// 内网透传的 access_token 合并进 userInfo(在 loginByYUserId 覆盖之后),
// XK/PG 中任务 iframe 据此取 accessToken
persistAccessToken(accessToken);
persistSidebarEnabled(sidebarEnabled);
setStatus('success');
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
@@ -161,6 +167,7 @@ export default function LoginPage() {
sidebarEnabled,
yUserId,
toolToken,
accessToken,
onlineTicket,
authToken,
gatePassword,
+18 -8
View File
@@ -5,9 +5,11 @@ import { PageSidebar } from "@/components/page-sidebar";
import { PageSidebarDark } from "@/components/page-sidebar-dark";
import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-route";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { isWujieEnv } from "@/core/embed/wujie";
import { useTheme } from "@/contexts/ThemeContext";
import { useLocation } from "react-router-dom";
import { useLocalSettings } from "@/core/settings";
import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu";
import { useLayoutMode } from "@/core/system-settings/hooks";
import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
@@ -22,11 +24,16 @@ function PageLayout() {
const location = useLocation();
const { theme } = useTheme();
const isDark = theme === "dark";
const [settings] = useLocalSettings();
// 界面模式开关(外观设置):用户显式选「全量/简洁」时以其为准;未设置过则回退到
// 会话级 sidebarEnabled(外部门户/yUserId 登录时置位)这一旧默认。这样无论是否走门户
// 登录,「全量↔简洁」切换都能真正控制顶栏+侧边栏的显示。
const layoutMode = settings.display.layoutMode;
// 浅色非嵌入:侧边栏为新版「二级导航图标卡片」窄栏(96px);wujie 嵌入仍走旧版手风琴(64/256px)。
const inWujie = useMemo(() => isWujieEnv(), []);
const lightNarrowSidebar = !isDark && !inWujie;
// 浅色窄栏在右侧展开「三级列」时整体变宽(96→236px),主内容左内边距随之过渡。
const thirdLevel = useActiveThirdLevel();
const lightExpanded = lightNarrowSidebar && thirdLevel.length > 0;
// 界面模式(外观设置):用户显式选「全量/简洁」时以其为准;否则跟随管理员系统默认
// (default_layout_mode);两者都没有才回退到会话级 sidebarEnabled(外部门户/yUserId
// 登录时置位)这一旧默认。这样无论是否走门户登录,「全量↔简洁」都能真正控制顶栏+侧边栏。
const [layoutMode] = useLayoutMode();
const chromeByPreference =
layoutMode === "full" ? true : layoutMode === "compact" ? false : sidebarEnabled;
const pageSidebarAllowed =
@@ -61,8 +68,11 @@ function PageLayout() {
// 只剩左下角浮动按钮(fixed,叠在 main 上不影响布局)。
// 不加 transition:transition + sidebar 立刻 unmount 不同步会抽搐
? (collapsed ? "pl-0" : "pl-[118px]")
// 浅色仍保留过渡动画(pl-16 ↔ pl-64 平滑收展)
: `transition-[padding] duration-300 ${collapsed ? "pl-16" : "pl-64"}`
: lightNarrowSidebar
// 浅色新版窄栏:展开三级列时 96→236px,主内容左内边距随之过渡
? `transition-[padding] duration-300 ${lightExpanded ? "pl-[236px]" : "pl-[96px]"}`
// 浅色旧版手风琴(wujie 嵌入):pl-16 ↔ pl-64 平滑收展
: `transition-[padding] duration-300 ${collapsed ? "pl-16" : "pl-64"}`
}`}
>
<Routes>
@@ -8,9 +8,11 @@ import {
ChevronRight,
ExternalLink,
FileText,
LayoutDashboard,
Loader2,
ScrollText,
} from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import { useParams } from "react-router-dom";
import remarkGfm from "remark-gfm";
@@ -21,6 +23,12 @@ import {
} from "@/core/roundtable-draft-shares/api";
import { usePublicBackground } from "@/core/embed/background";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
import { parseReportJson } from "@/roundtable-planning/lib/report-template";
/** 该分享页支持的三种视图:落地汇总 / 结果绘制(经典 HTML) / 大屏多页(模式二)。 */
export type RoundtableShareView = "landing" | "report" | "dashboard";
/** 各席位最终交付 —— 折叠手风琴(仿 HighFidelityReport 第二区块)。 */
function SeatDeliveries({ seats }: { seats: PublicDraftSeatDelivery[] }) {
@@ -81,20 +89,22 @@ function SeatDeliveries({ seats }: { seats: PublicDraftSeatDelivery[] }) {
* Public, read-only roundtable-result viewer.
*
* Reachable at `#/roundtable-share/<code>` with NO authentication — anyone with
* the link can open it. Two views, driven by the `report` prop / route:
* the link can open it. Three views, driven by the `view` prop / route:
*
* - landing (`#/roundtable-share/<code>`): a read-only result page mirroring the
* in-app Step 3「最终方案展示」— 总控最终结论 + 各席位最终交付 + a 「查看结果绘制」
* button that opens the full report in a NEW TAB (when one was generated).
* - report (`#/roundtable-share/<code>/report`): the Step 3 report (a
* in-app Step 3「最终方案展示」— 结果绘制/大屏多页入口 + 方案总结报告 + 总控最终
* 结论 + 各席位最终交付.
* - report (`#/roundtable-share/<code>/report`): the Step 3 经典 HTML 看板 (a
* self-contained HTML document) rendered full-screen inside a sandboxed
* `iframe` (no `allow-scripts`), so the agent-generated HTML can never execute
* code in the app origin.
* `iframe` (no `allow-scripts`).
* - dashboard (`#/roundtable-share/<code>/dashboard`): the Step 3 大屏多页 (模式二)
* — rendered on the fly from the stored structured data via `renderDashboardHtml`
* into the same sandboxed `iframe`.
*/
export default function PublicRoundtableSharePage({
report = false,
view = "landing",
}: {
report?: boolean;
view?: RoundtableShareView;
}) {
const { shareCode } = useParams<{ shareCode: string }>();
// 嵌套方可用 ?bg=/?text= 或 postMessage 覆盖背景/文字色。
@@ -108,6 +118,17 @@ export default function PublicRoundtableSharePage({
refetchOnWindowFocus: false,
});
// 大屏多页:用与应用内一致的 React 大屏(DashboardApp)渲染,而非自包含 HTML 模板。
// 把存储的结构化数据(report-json)解析并 adapt 成 DashboardApp 的入参;与
// Step3DashboardMode 的「应用渲染」、DashboardStandalonePage 的「新窗口打开」同源。
const dashboardData = useMemo(() => {
const json = data?.dashboard_json;
if (!json) return undefined;
const parsed = parseReportJson(json);
if (!parsed) return undefined;
return adaptReportData(parsed, data?.generated_at ?? "");
}, [data?.dashboard_json, data?.generated_at]);
if (isLoading) {
return (
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm" style={pageBg}>
@@ -128,8 +149,24 @@ export default function PublicRoundtableSharePage({
);
}
// ── 报告页:整页沙箱 iframe 展示第 3 步结果绘制 ──────────────────────────────
if (report) {
// ── 大屏多页:整屏渲染应用内 React 大屏(DashboardApp),与应用渲染/新窗口打开一致 ──
if (view === "dashboard") {
return (
<div className="bg-background h-screen w-screen overflow-auto" style={pageBg}>
{dashboardData ? (
<DashboardApp data={dashboardData} />
) : (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-sm">
<p className="text-base font-medium text-foreground">暂无大屏多页</p>
<p>这次会商还没有生成第三步「大屏多页」的方案报告。</p>
</div>
)}
</div>
);
}
// ── 结果绘制(经典 HTML 看板):整页沙箱 iframe 展示自包含报告 ─────────────────
if (view === "report") {
return (
<div className="bg-background flex h-screen flex-col" style={pageBg}>
<header className="flex shrink-0 items-center gap-2 border-b px-6 py-3">
@@ -161,8 +198,10 @@ export default function PublicRoundtableSharePage({
);
}
// ── 落地页:总控最终结论 + 各席位最终交付 + 查看结果绘制(新标签打开报告页)─────
const reportUrl = `${window.location.origin}${window.location.pathname}#/roundtable-share/${shareCode}/report`;
// ── 落地页:结果绘制/大屏多页入口 + 方案总结报告 + 总控最终结论 + 各席位最终交付 ──
const shareBase = `${window.location.origin}${window.location.pathname}#/roundtable-share/${shareCode}`;
const reportUrl = `${shareBase}/report`;
const dashboardUrl = `${shareBase}/dashboard`;
return (
<div className="bg-[#fef6f6] flex h-screen flex-col text-slate-800" style={pageBg}>
@@ -182,23 +221,39 @@ export default function PublicRoundtableSharePage({
<main className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-6xl space-y-6 p-6">
{/* 1. 结果绘制 —— 新标签打开报告页 */}
{/* 1. 结果绘制 / 大屏多页 —— 新标签打开整页报告 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-primary" />
结果绘制
</h4>
{data.has_report ? (
<a
href={reportUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-primary text-white hover:bg-[var(--brand-900)] inline-flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-semibold transition shadow-sm shadow-primary/10"
>
<BarChart3 className="size-4" />
查看结果绘制
<ExternalLink className="size-3.5 opacity-80" />
</a>
{data.has_report || data.has_dashboard ? (
<div className="flex flex-wrap gap-2.5">
{data.has_report && (
<a
href={reportUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-primary text-white hover:bg-[var(--brand-900)] inline-flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-semibold transition shadow-sm shadow-primary/10"
>
<BarChart3 className="size-4" />
查看结果绘制
<ExternalLink className="size-3.5 opacity-80" />
</a>
)}
{data.has_dashboard && (
<a
href={dashboardUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-white text-primary border border-primary/30 hover:bg-primary/5 inline-flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-semibold transition shadow-sm"
>
<LayoutDashboard className="size-4" />
查看大屏多页
<ExternalLink className="size-3.5 opacity-80" />
</a>
)}
</div>
) : (
<p className="text-[11px] text-slate-400 italic">
这次会商还没有生成第三步「结果绘制」的方案报告。
@@ -206,7 +261,22 @@ export default function PublicRoundtableSharePage({
)}
</section>
{/* 2. 总控最终结论 */}
{/* 2. 方案总结报告(Markdown) */}
{data.has_summary_report && (
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<ScrollText className="w-4 h-4 text-primary" />
方案总结报告
</h4>
<div className="text-xs text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{data.summary_report_md}
</ReactMarkdown>
</div>
</section>
)}
{/* 3. 总控最终结论 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
@@ -223,7 +293,7 @@ export default function PublicRoundtableSharePage({
)}
</section>
{/* 3. 各席位最终交付 */}
{/* 4. 各席位最终交付 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" />
@@ -0,0 +1,570 @@
@import "../../docs/ui规范-深蓝/tokens.css";
@import "./light-app-pages.css";
/**
* 应用管理页 —— 深蓝色主题适配(《深蓝色 UI 规范》)
* 浅色样式仍由组件内 Tailwind 承担;.dark 下本文件覆盖为 ZS-004 / ZS-002 令牌。
*/
.dark .light-app-management-page {
color: var(--color-text);
}
/* ── 页头 / 文案 ─────────────────────────────────────────────────────── */
.dark .light-app-management-page .lam-title {
color: var(--color-text-strong);
}
.dark .light-app-management-page .lam-desc {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-desc code {
background: rgba(22, 119, 255, 0.12);
color: var(--color-link);
}
.dark .light-app-management-page .lam-primary-btn {
background: var(--color-primary);
box-shadow: none;
}
.dark .light-app-management-page .lam-primary-btn:hover {
background: var(--color-primary-hover);
}
.dark .light-app-management-page .lam-primary-btn:active {
background: var(--color-primary-active);
}
/* ── 工具条 / 搜索 / 视图切换 ─────────────────────────────────────────── */
.dark .light-app-management-page .lam-toolbar {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-1);
}
.dark .light-app-management-page .lam-search-input {
background: var(--color-bg-page);
border-color: var(--color-border);
color: var(--color-text);
}
.dark .light-app-management-page .lam-search-input::placeholder {
color: var(--color-text-disabled);
}
.dark .light-app-management-page .lam-search-input:hover {
background: var(--color-bg-card);
border-color: var(--color-link);
}
.dark .light-app-management-page .lam-search-input:focus {
background: var(--color-bg-card);
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.16);
outline: none;
}
.dark .light-app-management-page .lam-view-toggle {
background: var(--color-bg-page);
border-color: var(--color-border);
}
.dark .light-app-management-page .lam-view-toggle-btn.is-active {
background: var(--color-bg-card);
color: var(--color-link);
box-shadow: var(--shadow-1);
}
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active) {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active):hover {
color: var(--color-text);
}
.dark .light-app-management-page .lam-count {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-count strong {
color: var(--color-link);
}
/* ── 表格(§28)──────────────────────────────────────────────────────── */
.dark .light-app-management-page .lam-table-wrap {
background: var(--color-bg-page);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-1);
}
.dark .light-app-management-page .lam-table thead tr {
background: var(--color-bg-card);
border-color: var(--color-border);
color: var(--color-text);
}
.dark .light-app-management-page .lam-table tbody {
border-color: var(--color-border);
}
.dark .light-app-management-page .lam-table tbody tr {
border-color: var(--color-border);
}
.dark .light-app-management-page .lam-table tbody tr:hover {
background: rgba(22, 119, 255, 0.08);
}
.dark .light-app-management-page .lam-row-name {
color: var(--color-text);
}
.dark .light-app-management-page .lam-table tbody tr:hover .lam-row-name {
color: var(--color-link);
}
.dark .light-app-management-page .lam-tag-window {
border-color: rgba(245, 34, 45, 0.35);
background: var(--func-error-bg);
color: #ff7875;
}
.dark .light-app-management-page .lam-tag-iframe {
border-color: rgba(22, 119, 255, 0.35);
background: var(--func-info-bg);
color: var(--color-link);
}
.dark .light-app-management-page .lam-url {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-table tbody tr:hover .lam-url {
color: var(--color-text);
}
.dark .light-app-management-page .lam-url a {
color: inherit;
}
.dark .light-app-management-page .lam-url a:hover {
color: var(--color-link);
}
.dark .light-app-management-page .lam-mount-tag {
background: var(--func-info-bg);
color: var(--color-link);
}
.dark .light-app-management-page .lam-mount-dot {
background: var(--color-primary);
}
.dark .light-app-management-page .lam-param-btn {
border-color: var(--color-border);
background: var(--color-bg-page);
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-param-btn:hover {
background: rgba(22, 119, 255, 0.08);
color: var(--color-text);
}
.dark .light-app-management-page .lam-param-popover {
border-color: var(--color-border);
background: var(--color-bg-card);
box-shadow: var(--shadow-3);
}
.dark .light-app-management-page .lam-param-popover-head {
border-color: var(--color-border);
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-param-popover-close {
color: var(--color-link);
}
.dark .light-app-management-page .lam-param-item {
border-color: var(--color-border);
background: var(--color-bg-page);
}
.dark .light-app-management-page .lam-param-key {
color: #ff7875;
}
.dark .light-app-management-page .lam-param-val {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-status-on {
color: var(--func-success);
}
.dark .light-app-management-page .lam-status-on-dot {
background: var(--func-success);
}
.dark .light-app-management-page .lam-status-on-ping {
background: var(--func-success);
}
.dark .light-app-management-page .lam-status-off {
color: var(--color-text-disabled);
}
.dark .light-app-management-page .lam-status-off-dot {
background: var(--color-text-disabled);
}
.dark .light-app-management-page .lam-icon-btn {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-icon-btn:hover {
background: rgba(22, 119, 255, 0.08);
color: var(--color-link);
}
.dark .light-app-management-page .lam-icon-btn-danger:hover {
background: var(--func-error-bg);
color: var(--func-error);
}
.dark .light-app-management-page .lam-empty-icon {
background: var(--color-bg-page);
border: 1px solid var(--color-border);
color: var(--color-text-disabled);
}
.dark .light-app-management-page .lam-empty-title {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-empty-sub {
color: var(--color-text-disabled);
}
/* ── 卡片视图(§22 / §43)────────────────────────────────────────────── */
.dark .light-app-management-page .lam-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-1);
}
.dark .light-app-management-page .lam-card:hover {
box-shadow: var(--shadow-2);
}
.dark .light-app-management-page .lam-card-title {
color: var(--color-text-strong);
}
.dark .light-app-management-page .lam-card:hover .lam-card-title {
color: var(--color-link);
}
.dark .light-app-management-page .lam-card-desc {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-card-url-box {
background: var(--color-bg-page);
}
.dark .light-app-management-page .lam-card-param {
border-color: var(--color-border);
background: var(--color-bg-page);
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-card-mount {
background: var(--func-info-bg);
color: var(--color-link);
}
.dark .light-app-management-page .lam-card-footer {
border-color: var(--color-border);
background: rgba(12, 24, 54, 0.5);
}
/* ── 底部提示(§33 警告)────────────────────────────────────────────── */
.dark .light-app-management-page .lam-tip-box {
background: var(--func-warning-bg);
border: 1px solid rgba(250, 173, 20, 0.25);
box-shadow: none;
}
.dark .light-app-management-page .lam-tip-head {
border-color: rgba(250, 173, 20, 0.2);
color: var(--func-warning);
}
.dark .light-app-management-page .lam-tip-list {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-tip-list strong {
color: var(--color-text);
}
.dark .light-app-management-page .lam-tip-bullet {
color: var(--func-warning);
}
/* ── 弹窗(§21 Modal)────────────────────────────────────────────────── */
.dark .light-app-management-page .lam-dialog-overlay {
background: rgba(6, 14, 36, 0.6);
}
.dark .light-app-management-page .lam-dialog {
border-color: var(--color-border);
background: linear-gradient(180deg, var(--color-bg-card), var(--color-bg-page));
box-shadow: var(--shadow-3);
color: var(--color-text);
}
.dark .light-app-management-page .lam-dialog-header {
border-color: var(--color-border);
border-bottom: none;
padding-bottom: 0;
}
.dark .light-app-management-page .lam-dialog-header::after {
content: "";
display: block;
height: 1px;
margin-top: 16px;
margin-bottom: 0;
background: var(--gradient-title-line);
}
.dark .light-app-management-page .lam-dialog-icon {
background: var(--func-info-bg);
color: var(--color-link);
}
.dark .light-app-management-page .lam-dialog-title {
color: var(--color-text-strong);
}
.dark .light-app-management-page .lam-dialog-close {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-dialog-close:hover {
background: rgba(22, 119, 255, 0.08);
color: var(--color-link);
}
.dark .light-app-management-page .lam-form-label {
color: var(--color-text);
}
.dark .light-app-management-page .lam-form-label .lam-required {
color: var(--func-error);
}
.dark .light-app-management-page .lam-form-label svg {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-form-hint {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-dialog .lam-input,
.dark .light-app-management-page .lam-dialog input[type="text"] {
background: var(--color-bg-page);
border-color: var(--color-border);
color: var(--color-text);
}
.dark .light-app-management-page .lam-dialog .lam-input::placeholder,
.dark .light-app-management-page .lam-dialog input[type="text"]::placeholder {
color: var(--color-text-disabled);
}
.dark .light-app-management-page .lam-dialog .lam-input:focus,
.dark .light-app-management-page .lam-dialog input[type="text"]:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.16);
outline: none;
}
.dark .light-app-management-page .lam-param-panel {
border-color: var(--color-border);
background: var(--color-bg-page);
}
.dark .light-app-management-page .lam-param-row {
border-color: var(--color-border);
background: var(--color-bg-card);
}
.dark .light-app-management-page .lam-param-theme-box {
background: var(--color-bg-page);
}
.dark .light-app-management-page .lam-param-empty {
border-color: var(--color-border);
background: var(--color-bg-page);
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-add-param-btn {
border-color: var(--color-border-strong);
background: var(--color-bg-card);
color: var(--color-text);
}
.dark .light-app-management-page .lam-add-param-btn:hover {
border-color: var(--color-primary);
color: var(--color-link);
}
.dark .light-app-management-page .lam-dialog-footer {
border-color: var(--color-border);
}
.dark .light-app-management-page .lam-dialog-cancel {
border-color: var(--color-border-strong);
background: transparent;
color: var(--color-text);
}
.dark .light-app-management-page .lam-dialog-cancel:hover {
border-color: var(--color-link);
color: var(--color-link);
}
.dark .light-app-management-page .lam-dialog-save {
background: var(--color-primary);
box-shadow: none;
}
.dark .light-app-management-page .lam-dialog-save:hover {
background: var(--color-primary-hover);
}
.dark .light-app-management-page .lam-dialog-save:active {
background: var(--color-primary-active);
}
.dark .light-app-management-page .lam-switch-on {
background: var(--color-primary);
}
.dark .light-app-management-page .lam-switch-off {
background: var(--color-text-disabled);
}
/* ── TDesign Select(§15)──────────────────────────────────────────── */
.dark .light-app-management-page .t-select .t-input,
.dark .light-app-management-page .t-select .t-input__wrap {
background: var(--color-bg-page);
border-color: var(--color-border);
border-radius: var(--radius-input);
}
.dark .light-app-management-page .t-select .t-input__inner {
color: var(--color-text);
}
.dark .light-app-management-page .t-select .t-input__inner::placeholder {
color: var(--color-text-disabled);
}
.dark .light-app-management-page .t-select:hover .t-input,
.dark .light-app-management-page .t-select:hover .t-input__wrap {
border-color: var(--color-link);
}
.dark .light-app-management-page .t-select.t-is-focused .t-input,
.dark .light-app-management-page .t-select.t-is-focused .t-input__wrap {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.16);
}
.dark .light-app-management-page .t-select .t-fake-arrow {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .t-select__dropdown {
background: var(--color-bg-card);
border-color: var(--color-border);
box-shadow: var(--shadow-2);
}
.dark .light-app-management-page .t-select-option {
color: var(--color-text);
}
.dark .light-app-management-page .t-select-option:hover {
background: rgba(22, 119, 255, 0.08);
}
.dark .light-app-management-page .t-select-option.t-is-selected {
background: rgba(22, 119, 255, 0.16);
color: var(--color-link);
}
/* shadcn Input / Textarea in dialog */
.dark .light-app-management-page .lam-dialog [data-slot="input"],
.dark .light-app-management-page .lam-dialog textarea {
background: var(--color-bg-page);
border-color: var(--color-border);
color: var(--color-text);
}
.dark .light-app-management-page .lam-dialog [data-slot="input"]:focus-visible,
.dark .light-app-management-page .lam-dialog textarea:focus-visible {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.16);
}
.dark .light-app-management-page .lam-param-theme-box span {
color: var(--color-text-secondary);
}
/* TDesign 下拉挂载到 body 时的深蓝样式 */
.dark .lam-select-popup.t-popup {
background: var(--color-bg-card);
border-color: var(--color-border);
box-shadow: var(--shadow-2);
}
.dark .lam-select-popup .t-select-option {
color: var(--color-text);
}
.dark .lam-select-popup .t-select-option:hover {
background: rgba(22, 119, 255, 0.08);
}
.dark .lam-select-popup .t-select-option.t-is-selected {
background: rgba(22, 119, 255, 0.16);
color: var(--color-link);
}
.dark .light-app-management-page .lam-search-input + button,
.dark .light-app-management-page .lam-toolbar .absolute.text-neutral-400 {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-table .text-neutral-300 {
color: var(--color-text-disabled);
}
.dark .light-app-management-page .lam-card-url-box button {
color: var(--color-text-secondary);
}
.dark .light-app-management-page .lam-card-url-box button:hover {
background: rgba(22, 119, 255, 0.08);
color: var(--color-link);
}
@@ -0,0 +1,12 @@
@import "../../docs/ui规范-深蓝/tokens.css";
/** 轻应用中心列表 / 管理页 —— 深色模式共用页面底图(与 workspace 深蓝主题一致) */
.dark .light-app-management-page,
.dark .light-app-list-page {
background-color: var(--color-bg-page);
background-image: url("../assets/img/layout-bg-theme-dark-blue.svg");
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
background-attachment: fixed;
}
@@ -1009,6 +1009,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
filepath={fileSandboxVirtualUrl}
threadId={fileSandboxTarget.threadId}
onClose={handleCloseFileSandbox}
allowVirtualMarkdownEdit
/>
</ThreadContext.Provider>
)}
@@ -115,6 +115,7 @@ export function Step2SandboxLayout({
filepath={selectedArtifact}
threadId={previewThreadId}
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
allowVirtualMarkdownEdit
/>
) : (
<div className="relative flex size-full justify-center">
@@ -139,6 +139,10 @@ const Header = ({ collapsed }: HeaderProps) => {
return email ? email.split("@")[0] : "用户";
}, [auth?.email]);
const showTopNav = isDark && !collapsed && !isWujieEnv();
// 浅色(红/蓝)顶栏:一级导航正常横排进顶栏,不做深色那种围绕基座的特殊处理。
// 与 collapsed 解耦(折叠只影响侧边栏,一级导航始终可见);wujie 嵌入下隐藏,
// 此时一级导航回落到侧边栏(见 page-sidebar.tsx)。
const showLightTopNav = !isDark && !isWujieEnv();
const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) {
@@ -194,6 +198,20 @@ const Header = ({ collapsed }: HeaderProps) => {
);
};
const renderLightTopItem = (item: MenuItem) => {
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
return (
<button
key={item.id}
type="button"
className={`light-topnav-item ${isSelected ? "light-topnav-item--active" : ""}`}
onClick={() => handleNavItemClick(item)}
>
{item.label}
</button>
);
};
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
const actionCluster = (
<>
@@ -309,6 +327,27 @@ const Header = ({ collapsed }: HeaderProps) => {
</div>
</div>
</>
) : showLightTopNav ? (
<>
{/* 浅色顶栏:logo + 一级导航横排(白字落在 banner 上) */}
<div className="flex h-full min-w-0 flex-1 items-center">
<div className="flex flex-shrink-0 items-center gap-2 pr-1">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-white/20">
<FileText className="h-4 w-4 text-white" />
</div>
<span className="whitespace-nowrap text-base font-semibold text-white">
CM助手
</span>
</div>
<nav className="light-topnav" aria-label="一级菜单">
{visibleMenuItems.map(renderLightTopItem)}
</nav>
</div>
<div className="flex shrink-0 items-center gap-2 pl-2">
{actionCluster}
</div>
</>
) : (
<>
<div className="flex h-full min-w-0 flex-1 items-center gap-3">
@@ -335,6 +374,7 @@ const Header = ({ collapsed }: HeaderProps) => {
</>
)}
</header>
{/* 三级菜单横条仅深色用;浅色三级已改到侧边栏右侧列(见 page-sidebar.tsx) */}
{showTopNav && thirdSubNavItems.length ? (
<nav className="third-submenu-bar" aria-label="三级菜单">
<div className="third-submenu-list">
@@ -19,11 +19,17 @@ export function mountLabel(value: string | null | undefined): string {
return MOUNT_OPTIONS.find((o) => o.value === value)?.label ?? value;
}
/** 登录参数可选来源;值均可在 userInfo 中取到,"other" 表示自填。 */
/**
* 登录参数可选来源;值均可在 userInfo 中取到,"other" 表示自填。
* accessToken / yUserId 取登录路由透传值(写入 userInfo 的同名键);
* userName / gateDepartName / typeName 取 consumer 接口 userDetailByYuserId 的返回值。
*/
export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [
{ label: "accessToken", value: "accessToken" },
{ label: "yUserId", value: "yUserId" },
{ label: "userName", value: "userName" },
{ label: "gateDepartName", value: "gateDepartName" },
{ label: "typeName", value: "typeName" },
{ label: "其他(自定义值)", value: "other" },
];
+1 -1
View File
@@ -932,7 +932,7 @@ body {
.strategy-app-header {
background-color: transparent;
background-image: url("../assets/img/red-header.png");
background-size: 100% 100%;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
}
File diff suppressed because it is too large. Load diff
@@ -115,10 +115,17 @@ class PublicDraftShareSnapshotResponse(BaseModel):
seat_deliveries: list[SeatDeliveryItem] = Field(default_factory=list)
# The Step 3「结果绘制」report — a self-contained HTML document. Empty when the
# shared draft never reached / generated a result report.
html: str = Field(default="", description="Self-contained Step 3 result report HTML")
html: str = Field(default="", description="Self-contained Step 3 result report HTML (经典 HTML 看板)")
summary: str | None = Field(default=None, description="The report's closing one-liner")
generated_at: str | None = Field(default=None, description="When the report was generated (ISO-8601)")
has_report: bool = Field(default=False, description="Whether a Step 3 report exists")
has_report: bool = Field(default=False, description="Whether a Step 3 HTML report exists")
# Step 3「方案总结报告」—— the Markdown report from roundtable-summary (step3.summaryReport.md).
summary_report_md: str = Field(default="", description="Step 3 Markdown 方案总结报告")
has_summary_report: bool = Field(default=False, description="Whether a Step 3 Markdown summary report exists")
# Step 3「大屏多页」(模式二) —— structured dashboard data (step3.dashboardJson); the
# frontend renders it to self-contained HTML via renderDashboardHtml at view time.
dashboard_json: str | None = Field(default=None, description="Step 3 大屏多页 structured data (report-json)")
has_dashboard: bool = Field(default=False, description="Whether a Step 3 大屏多页 dashboard exists")
created_at: str | None = Field(default=None, description="When the share was created (ISO-8601)")
@@ -259,29 +266,32 @@ async def import_share(
def _result_sections(draft: dict) -> tuple[str, list[dict[str, str]]]:
"""Derive (总控最终结论, 各席位最终交付) from a draft's Step 2 snapshot.
Mirrors the frontend `Step3Panel`: the consensus is `step2.lastLeaderContent`
and each seat delivery is a Step 2 dialogue bubble tagged
``confidence == "子智能体交付"``. Falls back to the active run's snapshot when
the top-level Step 2 fields are empty (multi-run drafts).
Mirrors the frontend draft hydration (`RoundtablePlanningPage.normalizeStep2Runs`):
when the draft has ``runs[]`` the rendered content is the **active run**
(``activeRunId``), NOT the top-level ``step2`` fields — those can be a stale
foreground seed (e.g. only the 系统消息/开场 2 条气泡) left over after a later
background job wrote its results into ``runs[]``. Reading the top level there
yields an empty consensus and no deliveries (the reported bug). Only drafts
with no ``runs[]`` (legacy/foreground-only) fall back to the top-level fields.
The consensus is ``lastLeaderContent`` and each seat delivery is a dialogue
bubble tagged ``confidence == "子智能体交付"``.
"""
step2 = draft.get("step2") if isinstance(draft.get("step2"), dict) else {}
step2 = step2 or {}
consensus = step2.get("lastLeaderContent") or ""
dialogues = step2.get("step2RoundtableDialogues")
runs = [r for r in step2.get("runs", []) if isinstance(r, dict)] if isinstance(step2.get("runs"), list) else []
if runs:
active_id = step2.get("activeRunId")
source = next((r for r in runs if r.get("id") == active_id), None) or runs[0]
else:
source = step2
consensus = source.get("lastLeaderContent") or ""
dialogues = source.get("step2RoundtableDialogues")
if not isinstance(dialogues, list):
dialogues = []
# Multi-run drafts keep the freshest content on the active run; use it when
# the top-level snapshot carries nothing.
if not consensus and not dialogues and isinstance(step2.get("runs"), list):
runs = [r for r in step2["runs"] if isinstance(r, dict)]
active_id = step2.get("activeRunId")
active = next((r for r in runs if r.get("id") == active_id), None) or (runs[0] if runs else None)
if active:
consensus = active.get("lastLeaderContent") or ""
d = active.get("step2RoundtableDialogues")
dialogues = d if isinstance(d, list) else []
seat_deliveries = [
{"sender": str(d.get("sender") or ""), "content": str(d.get("content") or "")}
for d in dialogues
@@ -313,8 +323,14 @@ async def get_public_draft_share_snapshot(
if draft is None:
raise HTTPException(status_code=404, detail="The shared draft no longer exists")
step3 = draft.get("step3") if isinstance(draft.get("step3"), dict) else None
html = (step3 or {}).get("html") or ""
step3 = draft.get("step3") if isinstance(draft.get("step3"), dict) else {}
step3 = step3 or {}
html = step3.get("html") or ""
# 方案总结报告(Markdown,模式:roundtable-summary)—— 嵌在 step3.summaryReport.md。
summary_report = step3.get("summaryReport") if isinstance(step3.get("summaryReport"), dict) else {}
summary_report_md = (summary_report or {}).get("md") or ""
# 大屏多页(模式二)—— 只存结构化数据 step3.dashboardJson,前端按主题渲染成自包含 HTML。
dashboard_json = step3.get("dashboardJson") or None
consensus_text, seat_deliveries = _result_sections(draft)
return PublicDraftShareSnapshotResponse(
share_code=share_code,
@@ -323,8 +339,12 @@ async def get_public_draft_share_snapshot(
consensus_text=consensus_text,
seat_deliveries=[SeatDeliveryItem(**s) for s in seat_deliveries],
html=html,
summary=(step3 or {}).get("summary"),
generated_at=(step3 or {}).get("generatedAt"),
summary=step3.get("summary"),
generated_at=step3.get("generatedAt"),
has_report=bool(html),
summary_report_md=summary_report_md,
has_summary_report=bool(summary_report_md),
dashboard_json=dashboard_json,
has_dashboard=bool(dashboard_json),
created_at=share.get("created_at"),
)
@@ -311,12 +311,14 @@ class AppearanceResponse(BaseModel):
default_theme: Literal["light", "dark", "lightblue"] = Field(..., description="System default theme — light, dark or lightblue")
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
default_layout_mode: Literal["full", "compact"] = Field(..., description="System default interface layout — full (sidebar + header) or compact (hidden)")
class AppearanceUpdateRequest(BaseModel):
default_theme: Literal["light", "dark", "lightblue"] | None = Field(default=None)
default_skill_view: Literal["card", "list"] | None = Field(default=None)
default_agent_view: Literal["card", "list"] | None = Field(default=None)
default_layout_mode: Literal["full", "compact"] | None = Field(default=None)
def _appearance_response(appearance) -> AppearanceResponse:
@@ -324,6 +326,7 @@ def _appearance_response(appearance) -> AppearanceResponse:
default_theme=appearance.default_theme,
default_skill_view=appearance.default_skill_view,
default_agent_view=appearance.default_agent_view,
default_layout_mode=appearance.default_layout_mode,
)
@@ -343,7 +346,7 @@ async def update_appearance_settings(
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
update: dict[str, object] = {}
for key in ("default_theme", "default_skill_view", "default_agent_view"):
for key in ("default_theme", "default_skill_view", "default_agent_view", "default_layout_mode"):
if key in fields and fields[key] is not None:
update[key] = fields[key]
if update:
@@ -59,6 +59,10 @@ class AppearanceSettings(BaseModel):
default="card",
description="Default agent list display mode for users who haven't picked their own (card / list)",
)
default_layout_mode: Literal["full", "compact"] = Field(
default="full",
description="Default interface layout for users who haven't picked their own — full (sidebar + header) / compact (hidden)",
)
model_config = ConfigDict(extra="ignore")