diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index f8b264dd..f4710c40 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -41,7 +41,7 @@ .item-nav { width: 100%; - height: 70px; + height: 75px; margin-top: 13px; font-family: Noto Sans S Chinese; font-weight: 500; @@ -94,7 +94,7 @@ content: ''; position: absolute; left: 0; - top: 24px; + top: 29px; width: 77px; height: 45px; background: url('../assets/icon/nav-line-logo.svg') no-repeat; @@ -237,4 +237,86 @@ .sidebar-flyout-label { flex: 1; white-space: nowrap; +} + +.sidebar-collapse-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 8px; + cursor: pointer; +} + +.sidebar-collapse-btn__icon { + width: 16px; + height: 16px; + flex: 0 0 16px; + color: #80e9ff; +} + +.sidebar-collapse-btn__label { + font-size: 14px; + line-height: 1; + white-space: nowrap; +} + +.sidebar-collapse-btn--inline { + width: calc(100% - 6px); + min-height: 58px; + margin-top: auto; + margin-bottom: 2px; + flex-direction: column; + gap: 6px; + border: 1px solid #55a6fe33; + background: #000c1c26; + color: rgba(255, 255, 255, 0.9); + box-shadow: inset 0 0 16px #00b9ff1f; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.sidebar-collapse-btn--inline .sidebar-collapse-btn__label { + font-size: 16px; + line-height: 1.1; +} + +.sidebar-collapse-btn--inline:hover { + background: rgba(0, 98, 217, 0.3); + border-color: rgba(255, 255, 255, 0.25); +} + +.sidebar-collapse-btn--floating { + position: fixed; + left: 10px; + bottom: 10px; + z-index: 60; + width: 36px; + height: 36px; + min-height: 36px; + padding: 0 10px; + border: 1px solid rgba(128, 233, 255, 0.35); + background: rgba(3, 38, 80, 0.82); + color: rgba(205, 245, 255, 0.95); + backdrop-filter: blur(2px); + transition: width 0.22s ease, opacity 0.2s ease, background-color 0.2s ease, border-color 0.2s ease; + overflow: hidden; +} + +.sidebar-collapse-btn--floating:hover { + width: 104px; + background: rgba(7, 52, 110, 0.9); + border-color: rgba(128, 233, 255, 0.65); +} + +.sidebar-collapse-btn--floating .sidebar-collapse-btn__label { + max-width: 0; + opacity: 0; + transform: translateX(-4px); + transition: max-width 0.22s ease, opacity 0.16s ease, transform 0.22s ease; +} + +.sidebar-collapse-btn--floating:hover .sidebar-collapse-btn__label { + max-width: 64px; + opacity: 1; + transform: translateX(0); } \ No newline at end of file diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index c9dc31c3..96158764 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; +import { PanelLeftOpen, PanelLeftClose } from "lucide-react"; import { getStoredAuth } from "@/core/auth"; import { @@ -10,7 +11,13 @@ import { } from "@/core/page-layout/sidebar-menu"; import './page-sdebar.css' -export function PageSidebarDark() { +type PageSidebarDarkProps = { + /** 是否收起(true 时只渲染左下角浮动入口,sidebar 主体隐藏) */ + collapsed: boolean; + onToggleCollapsed: () => void; +}; + +export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) { const isAdmin = getStoredAuth()?.system_role === "admin"; const visibleMenuItems = useMemo( () => filterMenuItemsByRole(sidebarMenuItems, !!isAdmin), @@ -91,10 +98,37 @@ export function PageSidebarDark() { }); }; + const findFirstRoutePath = (items?: MenuItem[]): string | null => { + if (!items?.length) { + return null; + } + + for (const child of items) { + if (child.path) { + return child.path; + } + const nestedPath = findFirstRoutePath(child.children); + if (nestedPath) { + return nestedPath; + } + } + + return null; + }; + const handleTopItemClick = (item: MenuItem) => { setActiveTopId(item.id); - if (!item.children?.length && item.path) { + if (item.children?.length) { + const firstPath = findFirstRoutePath(item.children); + if (firstPath) { + closeNavPanel(); + navigate(firstPath); + } + return; + } + + if (item.path) { closeNavPanel(); navigate(item.path); } @@ -138,6 +172,22 @@ export function PageSidebarDark() { })} ); + // collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口 + if (collapsed) { + return ( + + ); + } + return ( ); diff --git a/frontend-web/src/core/auth/external-login.ts b/frontend-web/src/core/auth/external-login.ts new file mode 100644 index 00000000..219db2b2 --- /dev/null +++ b/frontend-web/src/core/auth/external-login.ts @@ -0,0 +1,57 @@ +/** + * 外部系统跳转登录时透传的参数: + * theme —— 主题色,"dark-blue" => 深色,其他 => 浅色(在 LoginPage 调 setTheme 直接生效) + * ticket —— 第三方 ticket,持久化到 localStorage 供后续请求使用 + * userId —— 外部用户 id,同上 + * env —— 环境标识,同上 + * + * 这里只暴露 ``ticket / userId / env`` 三个 storage key 和读写工具; + * theme 不存(直接走 ThemeContext,避免双源)。 + */ + +export const EXTERNAL_LOGIN_STORAGE_KEYS = { + ticket: "login.ticket", + userId: "login.userId", + env: "login.env", +} as const; + +export type ExternalLoginParams = { + ticket?: string; + userId?: string; + env?: string; +}; + +/** 把外部跳转带来的三个参数写到 localStorage;隐私模式 / quota 异常时静默失败 */ +export function persistExternalLoginParams(params: ExternalLoginParams): void { + try { + if (params.ticket) { + localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.ticket, params.ticket); + } + if (params.userId) { + localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, params.userId); + } + if (params.env) { + localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.env, params.env); + } + } catch { + // localStorage 不可用时不阻断登录流程 + } +} + +/** 读取持久化的外部登录参数;不可用时返回空字符串 */ +export function readExternalLoginParams(): Required { + try { + return { + ticket: localStorage.getItem(EXTERNAL_LOGIN_STORAGE_KEYS.ticket) ?? "", + userId: localStorage.getItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId) ?? "", + env: localStorage.getItem(EXTERNAL_LOGIN_STORAGE_KEYS.env) ?? "", + }; + } catch { + return { ticket: "", userId: "", env: "" }; + } +} + +/** 把 ``theme`` query 值映射成 ThemeContext 的 ``'dark' | 'light'`` */ +export function resolveThemeFromQuery(themeParam: string | null | undefined): "dark" | "light" { + return themeParam === "dark-blue" ? "dark" : "light"; +} diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 446d6be8..d2fe690a 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -68,40 +68,40 @@ export const sidebarMenuItems: MenuItem[] = [ { id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" }, ], }, - { - id: "light-app-center", - label: "轻应用", - icon: Grid3X3, - children: [ - { - id: "light-app-note-management", - label: "笔记管理", - icon: NotebookPen, - children: [ - { - id: "light-app-new-note", - label: "新建笔记", - icon: Plus, - path: "/page/strategy/light-app/new-note", - }, - { - id: "light-app-note-list", - label: "笔记列表", - icon: List, - path: "/page/strategy/light-app/notes", - }, - { - id: "light-app-note-qa", - label: "笔记问答", - icon: MessageSquareCode, - path: "/page/strategy/light-app/note-qa", - }, - ], - }, - { id: "light-app-square", label: "应用广场", icon: Layout }, - { id: "light-app-records", label: "运行记录", icon: History }, - ], - }, + // { + // id: "light-app-center", + // label: "轻应用", + // icon: Grid3X3, + // children: [ + // { + // id: "light-app-note-management", + // label: "笔记管理", + // icon: NotebookPen, + // children: [ + // { + // id: "light-app-new-note", + // label: "新建笔记", + // icon: Plus, + // path: "/page/strategy/light-app/new-note", + // }, + // { + // id: "light-app-note-list", + // label: "笔记列表", + // icon: List, + // path: "/page/strategy/light-app/notes", + // }, + // { + // id: "light-app-note-qa", + // label: "笔记问答", + // icon: MessageSquareCode, + // path: "/page/strategy/light-app/note-qa", + // }, + // ], + // }, + // { id: "light-app-square", label: "应用广场", icon: Layout }, + // { id: "light-app-records", label: "运行记录", icon: History }, + // ], + // }, { id: "task-management", label: "任务管理", 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 b319a573..87e75736 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -463,8 +463,9 @@ export function AIWritingPanel({ /> )} - {/* 研究中 + 已有技能步骤:实时流式展示技能检索过程,避免 skeleton 卡片掩盖步骤 */} - {state.status === 'researching' && state.skillSearchSteps.length > 0 && ( + {/* 技能检索 pending:用户点击瞬间到 awaiting 之间,始终显示 SkillStepsCard 占位 + steps.length === 0 时 SkillStepsCard 内部会渲染骨架行,不让用户干等空白 */} + {state.skillSearchPending && (
@@ -474,8 +475,8 @@ export function AIWritingPanel({ state.status !== 'writing_draft' && !(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) && !(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && - /* 技能检索已经在流式展示步骤 / 选择打字机 / 等待 chunk 中,不再叠加 skeleton */ - !(state.status === 'researching' && state.skillSearchSteps.length > 0) && + /* 技能检索占位卡 / 选择打字机 已经在流式展示,不再叠加 skeleton */ + !state.skillSearchPending && !(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/MaterialsToolbar.tsx b/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx index 1b149ed4..66906405 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx @@ -73,7 +73,7 @@ export function MaterialsToolbar({ value={userQuery} onChange={handleInput} onKeyDown={handleKeyDown} - placeholder={placeholder ?? '例如:想查看各大平台销量 / 2025 年财报 / 行业头部公司近期动态…'} + placeholder={placeholder ?? '输入文字后点击“重新搜索”或“技能检索”按钮…'} disabled={disabled} className="w-full resize-none rounded-md border border-input bg-background px-2.5 py-1.5 text-xs leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 min-h-[28px] max-h-24" /> diff --git a/frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx index 2f95f1fe..a243e295 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx @@ -118,9 +118,28 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) { ); })} - {inProgress && ( + {inProgress && steps.length === 0 && ( + // 用户刚点击"技能检索",还没收到任何步骤事件:用骨架行 + spinner 占位, + // 避免用户在等待后端 emit 时干等空白 + <> +
  • + + 正在准备技能检索… +
  • + {[0, 1, 2].map((i) => ( +
  • + +
    +
    +
    +
    +
  • + ))} + + )} + {inProgress && steps.length > 0 && (
  • - + 更多步骤生成中…
  • )} diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts index 0cd2943e..94b5ada4 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts @@ -75,6 +75,7 @@ const INITIAL_STATE: AIWritingState = { skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '', + skillSearchPending: false, streamingReviewItems: [], revisionSummary: null, pendingRevisionContext: null, @@ -105,10 +106,13 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState { case 'intervention_submitted': { const ns = action.nextStatus; + // 点击瞬间标记技能检索 pending(动机 = 用户的 action 类型),让 SkillStepsCard 占位卡立即挂载 + const enablingSkillSearch = action.record.action === 'enable_skill_search'; return { ...prev, currentPause: null, status: ns, + skillSearchPending: enablingSkillSearch ? true : prev.skillSearchPending, ...(ns === 'researching' ? { researchProgress: '', skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '' } : {}), ...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}), ...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}), @@ -341,7 +345,9 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState const extra: Partial = {}; if (pp === 'material_confirm' && event.materials?.length) extra.materials = event.materials; if (pp === 'outline_confirm' && event.outline) extra.outline = event.outline; - return { ...base, ...extra, status: pauseStatus, currentPause: event }; + // 进入素材确认(material_confirm)= 技能检索(如果在进行)已经完成,清掉 pending 占位 + const clearPending = pp === 'material_confirm' ? { skillSearchPending: false } : {}; + return { ...base, ...extra, ...clearPending, status: pauseStatus, currentPause: event }; } case 'done': diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index faa6a3c6..98d6a380 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -321,6 +321,9 @@ export interface AIWritingState { // 技能选择 LLM 流式打字内容(正文 / 思考分开),select_skills 完成后清空 skillSelectStreamingText: string; skillSelectThinkingText: string; + /** 技能检索是否正在进行:用户点击"技能检索"瞬间 true,进入 awaiting 时清掉。 + * 用于在 LangGraph 还没推任何 skill_search_step 事件时给前端展示占位卡。 */ + skillSearchPending: boolean; // 流式审核(逐条到达) streamingReviewItems: StreamingReviewItem[]; // 修改摘要 diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index 16ee95b9..5edbf2db 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -3,8 +3,13 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; +import { useTheme } from "@/contexts/ThemeContext"; import { loginByUsername } from "@/core/auth/api"; import { setStoredAuth } from "@/core/auth"; +import { + persistExternalLoginParams, + resolveThemeFromQuery, +} from "@/core/auth/external-login"; import { isSidebarEnabledFromSearch, persistSidebarEnabled, @@ -18,6 +23,7 @@ export default function LoginPage() { const { username } = useParams<{ username?: string }>(); const router = useRouter(); const searchParams = useSearchParams(); + const { setTheme } = useTheme(); const sidebarEnabled = useMemo( () => isSidebarEnabledFromSearch(searchParams.toString()), [searchParams], @@ -26,9 +32,31 @@ export default function LoginPage() { const raw = username?.trim(); return raw && raw.length > 0 ? raw : DEFAULT_USERNAME; }, [username]); + // 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage + const externalParams = useMemo(() => ({ + theme: searchParams.get("theme") ?? "", + ticket: searchParams.get("ticket") ?? "", + userId: searchParams.get("userId") ?? "", + env: searchParams.get("env") ?? "", + }), [searchParams]); const [status, setStatus] = useState("loading"); const [errorMessage, setErrorMessage] = useState(""); + // 主题切换:theme=dark-blue → 深色,其他值或缺省 → 浅色 + useEffect(() => { + if (!externalParams.theme) return; + setTheme(resolveThemeFromQuery(externalParams.theme)); + }, [externalParams.theme, setTheme]); + + // ticket / userId / env 持久化(与 login 请求并行做,互不阻塞) + useEffect(() => { + persistExternalLoginParams({ + ticket: externalParams.ticket, + userId: externalParams.userId, + env: externalParams.env, + }); + }, [externalParams.ticket, externalParams.userId, externalParams.env]); + useEffect(() => { let cancelled = false; diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx index e88e6562..db4ed060 100644 --- a/frontend-web/src/pages/PageRoutes.tsx +++ b/frontend-web/src/pages/PageRoutes.tsx @@ -24,7 +24,10 @@ function PageLayout() {
    {sidebarEnabled && ( isDark ? ( - + setCollapsed((prev) => !prev)} + /> ) : ( diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 08b7181d..f70d42a1 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -59,10 +59,12 @@ export function WorkspaceLayout({ const pageSidebarWidth = pageSidebarHidden ? "0rem" : pageSidebarDark - // dark page sidebar 视觉右边界是 108px(外框 98 + 内层 translateX 10), - // 再 +10px gap 让 workspace sidebar 跟它之间留一条间隔; - // 与 PageRoutes.tsx 的 main pl-[118px] 保持一致 - ? "118px" + // dark page sidebar: + // 展开: 视觉右边界 108px(外框 98 + 内层 translateX 10)+ 10px gap = 118px + // 收起: sidebar 主体完全消失(连同其占据的宽度),workspace 内容铺到屏幕左边; + // 左下角浮动按钮是 fixed,叠在主内容上不影响 layout + // 与 PageRoutes.tsx 的 main pl-[118px] / pl-0 保持一致 + ? (pageSidebarCollapsed ? "0rem" : "118px") : pageSidebarCollapsed ? "4rem" : "16rem";