diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index de2edd84..f8b264dd 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -1,6 +1,16 @@ +.sidebar-contnet { + position: fixed; + left: 0; + top: 4rem; + z-index: 40; + width: 98px; + height: calc(100vh - 4rem); + flex-shrink: 0; +} + .sidebar-contents { width: 98px; - min-height: 986px; + min-height: calc(100% - 20px); background: rgba(3, 38, 80, 0.8); border: 1px solid; border-color: rgba(43, 143, 254, 0.6); @@ -11,16 +21,30 @@ align-items: center; padding: 12px 10px; position: relative; + height: calc(100% - 20px); + box-sizing: border-box; + transform: translate(10px, 10px); +} + +.item-nav-group { + width: 100%; +} + +.item-nav-wrap { + position: relative; + width: 100%; +} + +.item-nav-group:first-child .item-nav { + margin-top: 0; } .item-nav { width: 100%; height: 70px; - background: #ffffff1a; - border-color: #fefafa4d; margin-top: 13px; font-family: Noto Sans S Chinese; - font-weight: 550; + font-weight: 500; color: #fff; font-size: 20px; display: flex; @@ -35,9 +59,46 @@ position: relative; } -.item-nav:hover { - background: rgba(85, 166, 254, 0.12); +.item-nav:hover, +.item-nav-active, +.item-nav-hovered { color: #ffffff; + background: linear-gradient(134.25deg, + rgba(0, 98, 217, 0.59) 2.28%, + rgba(0, 98, 217, 0.3) 99.59%) !important; + background-size: cover; + border: 1px solid; + border-color: rgba(255, 255, 255, 0.2); + position: relative; +} + +.item-nav:hover::before, +.item-nav-active::before, +.item-nav-hovered::before { + content: ''; + position: absolute; + right: -10px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + border-left: 10px solid rgba(0, 98, 217, 0.45); + pointer-events: none; +} + +.item-nav:hover::after, +.item-nav-active::after, +.item-nav-hovered::after { + content: ''; + position: absolute; + left: 0; + top: 24px; + width: 77px; + height: 45px; + background: url('../assets/icon/nav-line-logo.svg') no-repeat; + background-size: cover; } .item-nav__label { @@ -52,53 +113,47 @@ .item-nav-content { width: 155px; - min-height: 970px; + height: calc(100vh - 4rem - 20px); + min-height: calc(100vh - 4rem - 20px); background: rgba(3, 38, 80, 0.8); border: 1px solid; border-color: rgba(43, 143, 254, 0.6); border-radius: 4px; backdrop-filter: blur(2px); position: absolute; - left: 97px; + left: 108px; top: 0; z-index: 1000; padding: 14px 13px 18px 16px; overflow-y: auto; -} - -.item-nav-active { - color: #ffffff; - background: linear-gradient(134.25deg, - rgba(0, 98, 217, 0.59) 2.28%, - rgba(0, 98, 217, 0.3) 99.59%) !important; - background-size: cover; - border: 1px solid; - border-color: rgba(255, 255, 255, 0.2); - position: relative; -} - -.item-nav-active::before { - content: ''; - position: absolute; - right: -10px; - top: 50%; - transform: translateY(-50%); - width: 0; - height: 0; - border-top: 8px solid transparent; - border-bottom: 8px solid transparent; - border-left: 10px solid rgba(0, 98, 217, 0.45); + opacity: 0; + visibility: hidden; pointer-events: none; + transition: opacity 0.2s ease, visibility 0.2s ease; } -.item-nav-active::after { + +.item-nav-content::before { content: ''; position: absolute; - left: 0; - top: 24px; - width: 77px; - height: 45px; - background: url('../assets/icon/nav-line-logo.svg') no-repeat; - background-size: cover; + left: -30px; + top: 0; + width: 30px; + height: 100%; +} + +.item-nav-hover-bridge { + position: absolute; + right: -30px; + top: 0; + width: 30px; + height: 100%; + z-index: 999; +} + +.item-nav-content--visible { + opacity: 1; + visibility: visible; + pointer-events: auto; } .sidebar-flyout-list { @@ -140,7 +195,7 @@ padding: 3px 0; font-family: Noto Sans S Chinese; font-size: 18px; - line-height: 28px; + line-height: 16px; font-weight: 500; letter-spacing: 0; color: rgba(255, 255, 255, 0.8); diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index aa18608f..17238017 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { ElementType } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { @@ -37,18 +37,7 @@ import { 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 './page-sdebar.css' type MenuItem = { @@ -60,13 +49,6 @@ type MenuItem = { children?: MenuItem[]; }; -type PageSidebarDarkProps = { - collapsed: boolean; - onToggleCollapsed: () => void; -}; - -type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about"; - const menuItems: MenuItem[] = [ { id: "qa-center", @@ -158,9 +140,9 @@ const menuItems: MenuItem[] = [ label: "PG中任务", icon: Folder, children: [ - { id: "pg-task-pg", label: "任务PG", icon: ListTodo }, - { id: "pg-task-xdpg", label: "XDPG", icon: ListTodo }, - { id: "pg-task-info-pg", label: "信息PG", icon: ListTodo }, + { id: "pg-task-pg", label: "任务PG", icon: ListTodo, path: "/page/strategy/task-management/effect-task-pg" }, + { id: "pg-task-xdpg", label: "XDPG", icon: ListTodo, path: "/page/strategy/task-management/effect-action-pg" }, + { id: "pg-task-info-pg", label: "信息PG", icon: ListTodo, path: "/page/strategy/task-management/effect-info-pg" }, ], }, { @@ -168,9 +150,9 @@ const menuItems: MenuItem[] = [ label: "TY中任务", icon: Folder, children: [ - { id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo }, - { id: "ty-task-plan", label: "方案TY任务", icon: ListTodo }, - { id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo }, + { id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo, path: "/page/strategy/task-management/mnty-task-mechanism" }, + { id: "ty-task-plan", label: "方案TY任务", icon: ListTodo, path: "/page/strategy/task-management/mnty-task-plan" }, + { id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo, path: "/page/strategy/task-management/mnty-task-zsty" }, ], }, ], @@ -278,35 +260,49 @@ function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] ); } -export function PageSidebarDark({ - collapsed, - onToggleCollapsed, -}: PageSidebarDarkProps) { - const { theme } = useTheme(); - const isDark = theme === "dark"; +export function PageSidebarDark() { const isAdmin = getStoredAuth()?.system_role === "admin"; const visibleMenuItems = useMemo( () => filterMenuItemsByRole(menuItems, !!isAdmin), [isAdmin], ); - const { t } = useI18n(); - const { data: unreadCount = 0 } = useUnreadCount(); const [expandedIds, setExpandedIds] = useState>(defaultExpandedIds); - const [settingsOpen, setSettingsOpen] = useState(false); - const [settingsDefaultSection, setSettingsDefaultSection] = - useState("appearance"); - const [notificationOpen, setNotificationOpen] = useState(false); - const [mounted, setMounted] = useState(false); const navigate = useNavigate(); const { pathname } = useLocation(); + // pathname 变化时取消任何未触发的 hover 关闭计时(cleanup 在卸载时也跑) useEffect(() => { - setMounted(true); + return () => { + if (hoverCloseTimerRef.current !== null) { + window.clearTimeout(hoverCloseTimerRef.current); + } + }; }, []); - const openSettings = (section: SettingsSection) => { - setSettingsDefaultSection(section); - setSettingsOpen(true); + const handleNavHoverEnter = (id: string) => { + if (hoverCloseTimerRef.current !== null) { + window.clearTimeout(hoverCloseTimerRef.current); + hoverCloseTimerRef.current = null; + } + setHoveredTopId(id); + }; + + const scheduleNavPanelClose = () => { + if (hoverCloseTimerRef.current !== null) { + window.clearTimeout(hoverCloseTimerRef.current); + } + hoverCloseTimerRef.current = window.setTimeout(() => { + setHoveredTopId(null); + hoverCloseTimerRef.current = null; + }, 180); + }; + + const closeNavPanel = () => { + if (hoverCloseTimerRef.current !== null) { + window.clearTimeout(hoverCloseTimerRef.current); + hoverCloseTimerRef.current = null; + } + setHoveredTopId(null); }; const hasActiveChild = (item: MenuItem): boolean => { @@ -317,18 +313,21 @@ export function PageSidebarDark({ return item.children?.some(hasActiveChild) ?? false; }; - const initialActiveTopItem = visibleMenuItems.find(hasActiveChild) ?? visibleMenuItems[0]; - const [activeTopId, setActiveTopId] = useState(initialActiveTopItem?.id ?? "qa-center"); + // lazy init:仅在首次 render 计算一次 active id;之后由下面的 useEffect 跟随 pathname 同步 + const [activeTopId, setActiveTopId] = useState( + () => visibleMenuItems.find(hasActiveChild)?.id ?? visibleMenuItems[0]?.id ?? "qa-center", + ); + const [hoveredTopId, setHoveredTopId] = useState(null); + const hoverCloseTimerRef = useRef(null); useEffect(() => { const matchedTopItem = visibleMenuItems.find(hasActiveChild); if (matchedTopItem) { setActiveTopId(matchedTopItem.id); } + closeNavPanel(); }, [pathname, visibleMenuItems]); - const activeTopItem = visibleMenuItems.find((item) => item.id === activeTopId) ?? initialActiveTopItem; - const toggleExpanded = (id: string) => { setExpandedIds((prev) => { const next = new Set(prev); @@ -345,6 +344,7 @@ export function PageSidebarDark({ setActiveTopId(item.id); if (!item.children?.length && item.path) { + closeNavPanel(); navigate(item.path); } }; @@ -356,6 +356,7 @@ export function PageSidebarDark({ } if (item.path) { + closeNavPanel(); navigate(item.path); } }; @@ -387,34 +388,37 @@ export function PageSidebarDark({ ); return ( - <> - - - {/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */} -
-
-
- {activeTopItem?.children?.length ? renderFlyoutMenu(activeTopItem.children) : null} +
- + ); } diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index 9efa88b2..b319a573 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -448,8 +448,15 @@ export function AIWritingPanel({ /> )} - {/* 技能选择 LLM 流式打字(含 思考分离),select_skills 完成后自动清空 */} - {state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText) && ( + {/* 技能选择 LLM 流式打字(含 思考分离),select_skills 完成后自动清空。 + 挂载条件包含"过渡态":已经走到 match_skills 但还没出 select_skills 时也保持挂载, + 让首个空 chunk 一到就能立刻显示等待提示 + 闪烁光标,避免模型 buffer 期间静默。 */} + {state.status === 'researching' && ( + state.skillSelectStreamingText + || state.skillSelectThinkingText + || (state.skillSearchSteps.some((s) => s.type === 'match_skills') + && !state.skillSearchSteps.some((s) => s.type === 'select_skills')) + ) && ( 0) && - /* 技能检索已经在流式展示步骤 / 选择打字机,不需要再叠加 skeleton */ - !(state.status === 'researching' && (state.skillSearchSteps.length > 0 || state.skillSelectStreamingText || state.skillSelectThinkingText)) && + /* 技能检索已经在流式展示步骤 / 选择打字机 / 等待 chunk 中,不再叠加 skeleton */ + !(state.status === 'researching' && state.skillSearchSteps.length > 0) && + !(state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText)) && /* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */ !state.intentStreamingText && !state.intentThinkingText && (
diff --git a/frontend-web/src/open-canvas/components/ai-writing/SkillSelectStreamingView.tsx b/frontend-web/src/open-canvas/components/ai-writing/SkillSelectStreamingView.tsx index ca8bd908..f1c958c0 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/SkillSelectStreamingView.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/SkillSelectStreamingView.tsx @@ -7,25 +7,22 @@ interface SkillSelectStreamingViewProps { } /** - * 从流式 JSON 文本里增量提取已经识别出的技能名。 + * 从流式 JSON 文本里增量提取已识别的技能名(仅取**已闭合**的字符串项)。 * - * LLM 输出形如 `{"selected_skills": ["skill-a", "skill-b", "skill-c"], "reason": "..."}`, - * 我们不等整段 JSON 解析完,逐字符扫描"已闭合"的双引号字符串: - * - 找到 `"selected_skills"`(或末尾不完整时回退兜底)后取后续内容 - * - 在该位置之后的 `[ ... ]` 内逐个匹配 `"..."` - * - 只取**已闭合**的字符串(结尾必须是 `"`),确保不把正在打字的半截名字露出来 + * LLM 输出形如 `{"selected_skills": ["skill-a", "skill-b", "skill-c"], "reason": "..."}`。 + * 不等整段 JSON 解析完,逐字符扫描: + * - 定位 `"selected_skills":\s*[` + * - 在该位置后到 `]`(或文本末尾)之间逐个匹配 `"..."` + * - 只取已闭合的字符串(结尾必须是 `"`),保证不显示正在打字的半截名字 */ function extractStreamingSkillNames(text: string): string[] { if (!text) return []; - // 定位 selected_skills 的值起点(容错:允许中英文引号 / 不同空白) const keyMatch = text.match(/["']?selected_skills["']?\s*:\s*\[/); if (!keyMatch) return []; const start = keyMatch.index! + keyMatch[0].length; - // 截取数组段(找到对应的 ],或者文本结尾) const rest = text.slice(start); const endIdx = rest.indexOf(']'); const arrBody = endIdx >= 0 ? rest.slice(0, endIdx) : rest; - // 只提取已经"闭合"的字符串:[^"\\]*(?:\\.[^"\\]*)* 处理转义引号 const names: string[] = []; const re = /"((?:[^"\\]|\\.)*)"/g; let m: RegExpExecArray | null; @@ -37,32 +34,46 @@ function extractStreamingSkillNames(text: string): string[] { } /** - * 技能选择 LLM 流式打字机视图。 + * 从流式 JSON 文本里提取 `reason` 字段当前已输出的内容。 * - * - 思考过程( 内)用 amber 折叠面板(与 OutlineStreamingView 风格一致) - * - 正文 JSON 用 violet 主题(与 SkillStepsCard 同色系,强调"技能"语义) - * - 两边都自动滚动到底;正文带闪烁光标,思考过程在正文未开始时也有光标 + * 和 skill name 不同,reason 是流式叙述文本,**未闭合也要展示**(带闪烁光标), + * 让用户看到 LLM 正在解释选择理由。 */ +function extractStreamingReason(text: string): { value: string; closed: boolean } { + if (!text) return { value: '', closed: false }; + // 匹配 "reason": "..." 直到下一个未转义的 " 或文本末尾 + const m = text.match(/["']?reason["']?\s*:\s*"((?:[^"\\]|\\.)*?)("|$)/); + if (!m) return { value: '', closed: false }; + const raw = m[1]; + const closed = m[2] === '"'; + // 反转义常见序列 + const value = raw + .replace(/\\n/g, '\n') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); + return { value, closed }; +} + export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStreamingViewProps) { - const preRef = useRef(null); const thinkRef = useRef(null); const [thinkOpen, setThinkOpen] = useState(true); - // 实时从流式 JSON 中增量解析出已识别的技能名 + // 增量解析:候选技能 + 选择原因 const streamingSkills = useMemo(() => extractStreamingSkillNames(text), [text]); - - useEffect(() => { - if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight; - }, [text]); + const reason = useMemo(() => extractStreamingReason(text), [text]); useEffect(() => { if (thinkOpen && thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight; }, [thinkingText, thinkOpen]); - if (!text && !thinkingText) return null; + // 步骤预览始终渲染(挂载即显示),让父组件控制挂载时机; + // 这样首个空 chunk 到达时用户就能看到"等待模型输出"占位 + 跳动光标 + const showStepPreview = true; return (
+ {/* 思考过程(amber,可折叠) */} {thinkingText && (