diff --git a/CLAUDE.md b/CLAUDE.md index f3c34921..f2dd1701 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) - **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. +- **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。 ### Configuration diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 6c1db30f..2b9e6c9a 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; import { ThemeProvider as NextThemesProvider } from "next-themes"; @@ -17,6 +17,10 @@ import { } from "@/core/embed/params"; import { usePublicEmbedTheme } from "@/core/embed/background"; import { readWujieProps } from "@/core/embed/wujie"; +import { + resolveForcedEmbedTheme, + syncEmbedSessionFromSearch, +} from "@/core/embed/embed-session"; import { HashRouter, useLocation } from "react-router-dom"; @@ -41,7 +45,16 @@ function AppProviders({ children }: { children: React.ReactNode }) { const locale = useMemo(() => detectLocale(), []); const location = useLocation(); const publicEmbedTheme = usePublicEmbedTheme(); + // iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页 + // 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。 + useEffect(() => { + syncEmbedSessionFromSearch(location.search); + }, [location.search]); const forcedTheme = useMemo(() => { + // iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。 + // 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。 + const embedTheme = resolveForcedEmbedTheme(location.search); + if (embedTheme) return embedTheme; const routeTheme = resolveForcedThemeForPath(location.pathname); if (routeTheme) return routeTheme; if (location.pathname === "/embed/roundtable") { diff --git a/frontend-web/src/core/auth/external-login.ts b/frontend-web/src/core/auth/external-login.ts index 37c865a6..eb95657f 100644 --- a/frontend-web/src/core/auth/external-login.ts +++ b/frontend-web/src/core/auth/external-login.ts @@ -9,6 +9,8 @@ * theme 不存(直接走 ThemeContext,避免双源)。 */ +import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage"; + export const EXTERNAL_LOGIN_STORAGE_KEYS = { ticket: "login.ticket", userId: "login.userId", @@ -27,32 +29,25 @@ export type ExternalLoginParams = { /** 把外部跳转带来的三个参数写到 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 不可用时不阻断登录流程 + // 嵌入(iframe)态走 sessionStorage(按标签页隔离),正常态走 localStorage —— 见 scoped-storage.ts。 + if (params.ticket) { + authStorageSet(EXTERNAL_LOGIN_STORAGE_KEYS.ticket, params.ticket); + } + if (params.userId) { + authStorageSet(EXTERNAL_LOGIN_STORAGE_KEYS.userId, params.userId); + } + if (params.env) { + authStorageSet(EXTERNAL_LOGIN_STORAGE_KEYS.env, params.env); } } /** 读取持久化的外部登录参数;不可用时返回空字符串 */ 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: "" }; - } + return { + ticket: authStorageGet(EXTERNAL_LOGIN_STORAGE_KEYS.ticket) ?? "", + userId: authStorageGet(EXTERNAL_LOGIN_STORAGE_KEYS.userId) ?? "", + env: authStorageGet(EXTERNAL_LOGIN_STORAGE_KEYS.env) ?? "", + }; } /** diff --git a/frontend-web/src/core/auth/index.ts b/frontend-web/src/core/auth/index.ts index e2f5b724..23659489 100644 --- a/frontend-web/src/core/auth/index.ts +++ b/frontend-web/src/core/auth/index.ts @@ -9,6 +9,12 @@ export interface LoginByUsernameResponse { created: boolean; } +import { + authStorageGet, + authStorageRemove, + authStorageSet, +} from "@/core/auth/scoped-storage"; + const AUTH_STORAGE_KEY = "deerflow.auth"; function isBrowser(): boolean { @@ -19,7 +25,8 @@ export function getStoredAuth(): LoginByUsernameResponse | null { if (!isBrowser()) { return null; } - const raw = window.localStorage.getItem(AUTH_STORAGE_KEY); + // 嵌入(iframe)态走 sessionStorage、正常态走 localStorage —— 隔离 iframe 登录用户与正常登录用户。 + const raw = authStorageGet(AUTH_STORAGE_KEY); if (!raw) { return null; } @@ -34,14 +41,14 @@ export function setStoredAuth(auth: LoginByUsernameResponse) { if (!isBrowser()) { return; } - window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(auth)); + authStorageSet(AUTH_STORAGE_KEY, JSON.stringify(auth)); } export function clearStoredAuth() { if (!isBrowser()) { return; } - window.localStorage.removeItem(AUTH_STORAGE_KEY); + authStorageRemove(AUTH_STORAGE_KEY); } /** 与设置 → 个人信息页「用户名」一致:邮箱 @ 前缀,否则 user_id。 */ @@ -67,7 +74,7 @@ export function setStoredUserName(name: string | null | undefined) { } let info: Record = {}; try { - const raw = window.localStorage.getItem("userInfo"); + const raw = authStorageGet("userInfo"); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") { @@ -81,7 +88,7 @@ export function setStoredUserName(name: string | null | undefined) { return; } info.userName = trimmed; - window.localStorage.setItem("userInfo", JSON.stringify(info)); + authStorageSet("userInfo", JSON.stringify(info)); } export function getAccessToken(): string | undefined { diff --git a/frontend-web/src/core/auth/scoped-storage.ts b/frontend-web/src/core/auth/scoped-storage.ts new file mode 100644 index 00000000..bea05a3e --- /dev/null +++ b/frontend-web/src/core/auth/scoped-storage.ts @@ -0,0 +1,56 @@ +/** + * 鉴权 / 身份存储的「按登录态隔离」选择器。 + * + * 诉求:外部系统用 iframe 套用咱们的深链问答时,**iframe 里登录的用户**要和**同浏览器正常登录 + * 的用户**完全隔离——不然 iframe 里随便登一个用户,会覆盖正常标签页的登录态(甚至触发跨标签 + * 账号变更提示)。 + * + * 根因:同源下 `localStorage` 跨所有标签页共享,鉴权 token(`deerflow.auth`)/ 身份(`userInfo`)/ + * 外部登录参数(`login.*` / `tools_token`)都存在那里,iframe 一写就污染正常页。 + * + * 方案:嵌入态(`isEmbedActive()`,见 [[embed-session]])下,这些键改走 **sessionStorage**——它按 + * **标签页/浏览上下文**隔离,iframe(即便同源)有自己独立的一份,写它绝不动正常标签页的 + * localStorage,也不会触发正常页的 `storage` 事件(见 sync.tsx 的跨标签账号监听)。正常态仍走 + * localStorage,行为零变化。 + * + * 把所有受保护键的读写都收口到 `authStorage()`,保证同一标签页内读写落到同一处(嵌入态恒为 + * sessionStorage、正常态恒为 localStorage;嵌入标志一进程内不翻转,故一致)。 + */ + +import { isEmbedActive } from "@/core/embed/embed-session"; + +/** + * 返回当前应使用的鉴权存储:嵌入态 → sessionStorage(按标签页隔离);否则 → localStorage。 + * 非浏览器环境返回 null(调用方各自有 SSR 守卫)。 + */ +export function authStorage(): Storage | null { + if (typeof window === "undefined") return null; + return isEmbedActive() ? window.sessionStorage : window.localStorage; +} + +/** 读受保护键(嵌入态 sessionStorage / 正常态 localStorage)。 */ +export function authStorageGet(key: string): string | null { + try { + return authStorage()?.getItem(key) ?? null; + } catch { + return null; + } +} + +/** 写受保护键。隐私模式 / quota 异常静默失败。 */ +export function authStorageSet(key: string, value: string): void { + try { + authStorage()?.setItem(key, value); + } catch { + /* noop */ + } +} + +/** 删受保护键。 */ +export function authStorageRemove(key: string): void { + try { + authStorage()?.removeItem(key); + } catch { + /* noop */ + } +} diff --git a/frontend-web/src/core/auth/sync.tsx b/frontend-web/src/core/auth/sync.tsx index 4e74eb6d..cb390fd1 100644 --- a/frontend-web/src/core/auth/sync.tsx +++ b/frontend-web/src/core/auth/sync.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from "react"; import { toast } from "sonner"; import { getStoredAuth } from "@/core/auth"; +import { isEmbedActive } from "@/core/embed/embed-session"; const AUTH_STORAGE_KEY = "deerflow.auth"; const SETTLE_DELAY_MS = 1200; @@ -21,6 +22,10 @@ export function AuthSyncWatcher() { const lastUserIdRef = useRef(readUserId()); useEffect(() => { + // 嵌入(iframe)态:鉴权走按标签页的 sessionStorage,本就不与其它标签页共享,且不应被正常 + // 标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。 + if (isEmbedActive()) return; + let settleTimer: ReturnType | null = null; function evaluateSettledIdentity() { diff --git a/frontend-web/src/core/auth/wujie-ticket-login.ts b/frontend-web/src/core/auth/wujie-ticket-login.ts index 9bccd610..bbaf9a19 100644 --- a/frontend-web/src/core/auth/wujie-ticket-login.ts +++ b/frontend-web/src/core/auth/wujie-ticket-login.ts @@ -4,6 +4,7 @@ import { EXTERNAL_LOGIN_STORAGE_KEYS, persistExternalLoginParams, } from "./external-login"; +import { authStorageSet } from "@/core/auth/scoped-storage"; const AUTH_BASE_URL = (import.meta.env.VITE_AUTH_BASE_URL || "").replace( /\/$/, @@ -49,21 +50,14 @@ type UserDetailTokenResponse = { }; function persistMaxkeyOnlineToken(token: string): void { - try { - localStorage.setItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY, token); - } catch { - // 静默失败,不阻断登录 - } + // 嵌入(iframe)态走 sessionStorage(按标签页隔离),正常态走 localStorage。 + authStorageSet(MAXKEY_ONLINE_TOKEN_STORAGE_KEY, token); } function persistUserDetailToken(data: UserDetailTokenPayload): void { - try { - localStorage.setItem(USER_DETAIL_TOKEN_STORAGE_KEY, JSON.stringify(data)); - if (data.user_id) { - localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, String(data.user_id)); - } - } catch { - // 静默失败 + authStorageSet(USER_DETAIL_TOKEN_STORAGE_KEY, JSON.stringify(data)); + if (data.user_id) { + authStorageSet(EXTERNAL_LOGIN_STORAGE_KEYS.userId, String(data.user_id)); } } diff --git a/frontend-web/src/core/auth/yuser-login.ts b/frontend-web/src/core/auth/yuser-login.ts index f118d245..21936506 100644 --- a/frontend-web/src/core/auth/yuser-login.ts +++ b/frontend-web/src/core/auth/yuser-login.ts @@ -1,4 +1,5 @@ import { requestCrudService } from "@/service/request-axios-service"; +import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage"; const CONSUMER_URL = import.meta.env.VITE_AIPI_CONSUMER_URL || ""; @@ -23,7 +24,7 @@ export async function loginByYUserId( token?: string | null, ): Promise { if (token) { - localStorage.setItem(TOOLS_TOKEN_STORAGE_KEY, token); + authStorageSet(TOOLS_TOKEN_STORAGE_KEY, token); } const res = (await requestCrudService.get( @@ -37,14 +38,14 @@ export async function loginByYUserId( // 注意:接口也会返回 accessToken,但 XK/PG 及 source=accessToken 要用路由透传的 // access_token,故登录流程随后会用 persistAccessToken 覆盖 userInfo.accessToken。 const result = res.data ?? {}; - localStorage.setItem( + authStorageSet( EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify({ ...result, yUserId }), ); } export function persistToolsToken(token: string): void { - localStorage.setItem(TOOLS_TOKEN_STORAGE_KEY, token); + authStorageSet(TOOLS_TOKEN_STORAGE_KEY, token); } /** @@ -59,7 +60,7 @@ export function persistAccessToken(token: string): void { if (!trimmed) return; let info: Record = {}; try { - const raw = localStorage.getItem(EXTERNAL_USER_INFO_STORAGE_KEY); + const raw = authStorageGet(EXTERNAL_USER_INFO_STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") { @@ -70,5 +71,5 @@ export function persistAccessToken(token: string): void { info = {}; } info.accessToken = trimmed; - localStorage.setItem(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info)); + authStorageSet(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info)); } diff --git a/frontend-web/src/core/embed/embed-session.ts b/frontend-web/src/core/embed/embed-session.ts new file mode 100644 index 00000000..81720f23 --- /dev/null +++ b/frontend-web/src/core/embed/embed-session.ts @@ -0,0 +1,172 @@ +/** + * 「外部系统 iframe 套用深链」相关的**按标签页**会话开关。承载两个相互独立的能力,均由 URL + * 路由参数开启、存进同一份 sessionStorage 会话、跨登录跳转/刷新/页内导航存活: + * + * 1. `embed=1`(可带 `&theme=`)—— **嵌入模式**:去掉全局导航栏(Header)+ 侧边栏(PageSidebar), + * 并按外部主题色强制系统明/暗(离散切换)。 + * 2. `hideJump=1` —— **隐藏深链底部「快捷跳转」栏**:去掉 `TaskJumpBar` 的两部分——底部的 + * 「下一步」等按钮 + 右侧垂直居中的悬浮菜单(单智能体问答 / 多智能体会商深链共用该栏)。 + * + * 两者可单独使用,也可叠加:`hideJump` **只**隐藏跳转栏,不动 chrome / 主题;`embed` 不影响跳转栏。 + * + * 隔离要点(核心诉求:同一浏览器另开咱们正常系统页,**绝不能**被这些开关影响): + * - 用 **sessionStorage**(按标签页隔离、绝不跨标签)持有这些 flag,而**不用** localStorage + * (跨标签共享 → 会污染正常页)。 + * - 主题走 next-themes 的 `forcedTheme`(见 App.tsx `AppProviders`)——只在本标签页生效、 + * 不写 localStorage `strategy-theme`;嵌入侧绝不调 `setTheme`。 + * - 去 chrome / 去跳转栏均由本模块的 sessionStorage flag 驱动,不碰 `persistSidebarEnabled` 等 + * localStorage 偏好。 + * + * iframe 的浏览器上下文就是它自己的标签页,sessionStorage 天然「一个 iframe 一份、随关闭消失」; + * 正常页标签页里既无这些参数也无这份会话 → 所有 `isXxx()` 恒为 false,完全不受影响。 + */ + +const EMBED_SESSION_KEY = "embed.session"; + +export type EmbedTheme = "light" | "dark"; + +/** 本标签页的嵌入会话(按需置位的若干独立开关)。 */ +export interface EmbedSession { + /** 嵌入模式:去全局 chrome + 强制主题。 */ + embed: boolean; + /** 嵌入模式下强制的主题(仅 embed 为真时有意义)。 */ + theme: EmbedTheme; + /** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */ + hideJump: boolean; +} + +/** URL 布尔参数的三态:显式开 / 显式关 / 未带。 */ +type FlagParam = "on" | "off" | null; + +function toSearchParams(search: string): URLSearchParams { + return new URLSearchParams(search.startsWith("?") ? search : `?${search}`); +} + +/** 读某个布尔型 query 参数的三态。`1/true/yes` → on;`0/false/no` → off;缺省/其它 → null。 */ +function readFlagParam(search: string, key: string): FlagParam { + const v = (toSearchParams(search).get(key) ?? "").trim().toLowerCase(); + if (!v) return null; + if (v === "1" || v === "true" || v === "yes") return "on"; + if (v === "0" || v === "false" || v === "no") return "off"; + return null; +} + +/** 读 URL 里的 `embed` 三态。 */ +export function readEmbedParam(search: string): FlagParam { + return readFlagParam(search, "embed"); +} + +/** 读 URL 里的 `hideJump` 三态。 */ +export function readHideJumpParam(search: string): FlagParam { + return readFlagParam(search, "hideJump"); +} + +/** + * 把外部传入的主题色映射成离散明/暗。与 `resolveThemeFromQuery` 同源约定(dark-blue → 深色), + * 另外宽松接受 `dark` / 中文「深色/深蓝」直传;其余(含空)一律浅色。 + */ +export function resolveEmbedThemeMode(raw: string | null | undefined): EmbedTheme { + const v = (raw ?? "").trim().toLowerCase(); + if (v === "dark" || v === "dark-blue" || v === "深色" || v === "深蓝") return "dark"; + return "light"; +} + +/** 取 URL query 里的 `theme` 值(embed 主题来源)。 */ +export function readEmbedThemeParam(search: string): string { + return (toSearchParams(search).get("theme") ?? "").trim(); +} + +/** 读本标签页的嵌入会话;无 → null。 */ +export function readEmbedSession(): EmbedSession | null { + try { + const raw = sessionStorage.getItem(EMBED_SESSION_KEY); + if (!raw) return null; + const p = JSON.parse(raw) as Partial; + return { + embed: p.embed === true, + theme: p.theme === "dark" ? "dark" : "light", + hideJump: p.hideJump === true, + }; + } catch { + return null; + } +} + +/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */ +function writeEmbedSession(next: EmbedSession): void { + try { + if (!next.embed && !next.hideJump) { + sessionStorage.removeItem(EMBED_SESSION_KEY); + return; + } + sessionStorage.setItem(EMBED_SESSION_KEY, JSON.stringify(next)); + } catch { + /* noop */ + } +} + +/** 本标签页是否处于嵌入模式(驱动去全局 chrome + 强制主题)。 */ +export function isEmbedActive(): boolean { + return readEmbedSession()?.embed === true; +} + +/** 本标签页是否要隐藏深链底部「快捷跳转」栏(TaskJumpBar)。 */ +export function isJumpBarHidden(): boolean { + return readEmbedSession()?.hideJump === true; +} + +/** + * 按 URL 参数同步 sessionStorage 会话(AppProviders 在每次 search 变化时调用一次)。 + * **合并语义**:on 置位、off 清位、未带则沿用既有值——这样登录后跳到不带参数的干净路由时, + * 之前置位的开关不会被抹掉。 + */ +export function syncEmbedSessionFromSearch(search: string): void { + const cur = readEmbedSession() ?? { embed: false, theme: "light" as EmbedTheme, hideJump: false }; + const next: EmbedSession = { ...cur }; + + const embedParam = readEmbedParam(search); + if (embedParam === "on") { + next.embed = true; + next.theme = resolveEmbedThemeMode(readEmbedThemeParam(search)); + } else if (embedParam === "off") { + next.embed = false; + } + + const hideJumpParam = readHideJumpParam(search); + if (hideJumpParam === "on") next.hideJump = true; + else if (hideJumpParam === "off") next.hideJump = false; + + writeEmbedSession(next); +} + +/** + * 从当前 HashRouter URL(`#/path?a=b`)取出 query 串。 + * 供「在 React 渲染之前」同步启用嵌入会话(main.tsx)——必须早于任何鉴权写入, + * 否则首帧 LoginPage 的 effect 可能抢在 AppProviders 同步 effect 之前把登录参数写进 localStorage。 + */ +function currentHashSearch(): string { + if (typeof window === "undefined") return ""; + const hash = window.location.hash || ""; + const i = hash.indexOf("?"); + return i >= 0 ? hash.slice(i) : ""; +} + +/** main.tsx 在渲染前同步调用一次:据当前 URL 启用/关闭本标签页嵌入会话。 */ +export function syncEmbedSessionFromCurrentUrl(): void { + syncEmbedSessionFromSearch(currentHashSearch()); +} + +/** + * 解析「应当强制的嵌入主题」供 `AppProviders` 的 `forcedTheme` 用: + * - URL 显式 `embed=1` → 用 URL `theme`(首帧即生效,登录页转场不闪烁); + * - URL 显式 `embed=0` → 不强制(undefined); + * - URL 未带 → 回退本标签页嵌入会话主题(仅 embed 置位时)。 + * 非嵌入标签页恒返回 undefined → 完全不影响正常系统页的主题。 + */ +export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined { + const param = readEmbedParam(search); + if (param === "on") return resolveEmbedThemeMode(readEmbedThemeParam(search)); + if (param === "off") return undefined; + const session = readEmbedSession(); + return session?.embed ? session.theme : undefined; +} diff --git a/frontend-web/src/core/strategy/external-links.ts b/frontend-web/src/core/strategy/external-links.ts index a4b62536..7d890d0d 100644 --- a/frontend-web/src/core/strategy/external-links.ts +++ b/frontend-web/src/core/strategy/external-links.ts @@ -1,5 +1,7 @@ /** Strategy 模块外部 iframe / 嵌入页链接(来自 Vite 环境变量) */ +import { authStorageGet } from "@/core/auth/scoped-storage"; + function trimTrailingSlash(url: string): string { return url.replace(/\/+$/, ""); } @@ -30,7 +32,7 @@ export function getNetworkActorIframeUrl(): string { /** 读取并解析 localStorage 中的 userInfo(登录信息)。 */ function readUserInfo(): Record { try { - const raw = localStorage.getItem("userInfo"); + const raw = authStorageGet("userInfo"); if (!raw) return {}; const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? (parsed as Record) : {}; diff --git a/frontend-web/src/main.tsx b/frontend-web/src/main.tsx index b7b08da6..2c9beaf4 100644 --- a/frontend-web/src/main.tsx +++ b/frontend-web/src/main.tsx @@ -1,15 +1,20 @@ -import "@fontsource/noto-sans-sc/chinese-simplified-400.css"; -import "@fontsource/noto-sans-sc/chinese-simplified-500.css"; -import "@fontsource/noto-sans-sc/chinese-simplified-600.css"; -import "@fontsource/noto-sans-sc/chinese-simplified-700.css"; -import "./styles/globals.css"; -import "tdesign-react/es/style/index.css"; -import "katex/dist/katex.min.css"; +import "@fontsource/noto-sans-sc/chinese-simplified-400.css"; +import "@fontsource/noto-sans-sc/chinese-simplified-500.css"; +import "@fontsource/noto-sans-sc/chinese-simplified-600.css"; +import "@fontsource/noto-sans-sc/chinese-simplified-700.css"; +import "./styles/globals.css"; +import "tdesign-react/es/style/index.css"; +import "katex/dist/katex.min.css"; import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App"; +import { syncEmbedSessionFromCurrentUrl } from "@/core/embed/embed-session"; + +// 渲染前同步启用「iframe 嵌入会话」(按标签页 sessionStorage):必须早于任何鉴权写入, +// 使 iframe 嵌入态的登录走 sessionStorage、与同浏览器正常登录(localStorage)完全隔离。 +syncEmbedSessionFromCurrentUrl(); ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index 45009d45..6fc329d3 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -20,6 +20,7 @@ import { } from '@/core/auth/task-deeplink'; import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login'; import { isWujieEnv, useWujieProps } from '@/core/embed/wujie'; +import { readEmbedParam } from '@/core/embed/embed-session'; import { GENERAL_QA_ROUTE, loginByYUserId, @@ -48,6 +49,13 @@ export default function LoginPage() { () => isSidebarEnabledFromSearch(searchParams.toString()), [searchParams], ); + // iframe 嵌入模式(外部系统 `&embed=1`):去 chrome + 按标签页强制主题。嵌入侧**不**走 + // setTheme / persistSidebarEnabled(那俩落 localStorage、会跨标签污染正常系统页),改用 + // sessionStorage 嵌入会话(见 embed-session.ts)。`embed=0` 显式关闭。 + const isEmbed = useMemo( + () => readEmbedParam(searchParams.toString()) === 'on', + [searchParams], + ); const redirectPath = useMemo( () => searchParams.get('from') === IFRAME_LOGIN_SOURCE @@ -105,11 +113,14 @@ export default function LoginPage() { // 主题切换:无界 props.theme 优先,其次 URL query theme。两者取值约定一致 // (dark-blue → 深色;red → 浅色;blue 暂无该主题,先用浅色代替),统一走 resolveThemeFromQuery。 + // **嵌入模式跳过**:setTheme 会写 localStorage `strategy-theme`,跨标签污染正常系统页; + // 嵌入主题改由 forcedTheme(按标签页、不持久化)接管。 useEffect(() => { + if (isEmbed) return; const themeValue = wujieProps.theme || externalParams.theme; if (!themeValue) return; setTheme(resolveThemeFromQuery(themeValue)); - }, [wujieProps.theme, externalParams.theme, setTheme]); + }, [isEmbed, wujieProps.theme, externalParams.theme, setTheme]); // ticket / userId / env 持久化(与 login 请求并行做,互不阻塞) useEffect(() => { @@ -154,7 +165,9 @@ export default function LoginPage() { // 内网透传的 access_token 合并进 userInfo(在 loginByYUserId 覆盖之后), // XK/PG 中任务 iframe 据此取 accessToken persistAccessToken(accessToken); - persistSidebarEnabled(sidebarEnabled); + // 嵌入模式不写 sidebarEnabled(localStorage 的「简洁/全量」偏好会跨标签污染正常页); + // 去 chrome 由按标签页的嵌入会话(isEmbedActive)驱动,见 PageLayout。 + if (!isEmbed) persistSidebarEnabled(sidebarEnabled); setStatus('success'); // 任务深链:带 taskId + goPath 时拉任务详情并直送对应问答/会商页; @@ -209,6 +222,7 @@ export default function LoginPage() { gatePassword, taskId, goPath, + isEmbed, ]); return ( diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx index 15a95a64..aceffd87 100644 --- a/frontend-web/src/pages/PageRoutes.tsx +++ b/frontend-web/src/pages/PageRoutes.tsx @@ -6,6 +6,7 @@ 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 { isEmbedActive, readEmbedParam } from "@/core/embed/embed-session"; import { useTheme } from "@/contexts/ThemeContext"; import { useLocation } from "react-router-dom"; import { useActiveThirdLevel } from "@/core/page-layout/use-sidebar-menu"; @@ -37,8 +38,14 @@ function PageLayout() { const [layoutMode] = useLayoutMode(); const chromeByPreference = layoutMode === "full" ? true : layoutMode === "compact" ? false : sidebarEnabled; + // iframe 嵌入模式(外部系统 `&embed=1`,按标签页 sessionStorage):去掉全局导航栏 + 侧边栏, + // 只留问答/会商工作区本体。按 location 重算以跟随页内导航;非嵌入标签页恒为 false,不受影响。 + const embedActive = useMemo( + () => isEmbedActive() || readEmbedParam(location.search) === "on", + [location.pathname, location.search], + ); const pageSidebarAllowed = - chromeByPreference && !isChatsIframeWorkspacePath(location.pathname); + chromeByPreference && !isChatsIframeWorkspacePath(location.pathname) && !embedActive; // h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100% // 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套 diff --git a/frontend-web/src/strategy-components/components/resource-management/KnowledgeBaseResourceManagement.tsx b/frontend-web/src/strategy-components/components/resource-management/KnowledgeBaseResourceManagement.tsx index dd1e5509..305edc6e 100644 --- a/frontend-web/src/strategy-components/components/resource-management/KnowledgeBaseResourceManagement.tsx +++ b/frontend-web/src/strategy-components/components/resource-management/KnowledgeBaseResourceManagement.tsx @@ -4,6 +4,7 @@ import { getKnowledgeIframeApiUrl, getKnowledgeIframeSkin, } from "@/core/strategy/external-links"; +import { authStorageGet } from "@/core/auth/scoped-storage"; import { requestCrudService } from "@/service/request-axios-service"; import ExternalIframePage from "../common/ExternalIframePage"; @@ -18,7 +19,7 @@ const KnowledgeBaseResourceManagement: React.FC = () => { const apiUrl = getKnowledgeIframeApiUrl(); if (!apiUrl) return; - const jsonStr = localStorage.getItem("userInfo"); + const jsonStr = authStorageGet("userInfo"); if (!jsonStr) return; try { diff --git a/frontend-web/src/strategy-components/components/task-components/TaskJumpBar.tsx b/frontend-web/src/strategy-components/components/task-components/TaskJumpBar.tsx index 13b5050f..9283ea29 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskJumpBar.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskJumpBar.tsx @@ -36,6 +36,7 @@ import { type TaskBusiness, } from "@/core/auth/task-buttons"; import { useTaskButtons } from "@/strategy-components/api/task-buttons"; +import { isJumpBarHidden } from "@/core/embed/embed-session"; import { desensitize } from "@/lib/desensitize"; import { TaskPurposeDialog } from "@/strategy-components/components/task-components/TaskPurposePanel"; @@ -77,6 +78,9 @@ export function TaskJumpBar({ business === "xdfx" && ctxActionId && ctxActionId !== taskId ? ctxActionId : undefined; if (jumpButtons.length === 0) return null; + // 深链 `hideJump=1`(外部 iframe 套用时):整条跳转栏隐藏——底部「下一步」等按钮 + + // 右侧悬浮菜单都不渲染。按标签页生效,不影响正常系统页(见 embed-session.ts)。 + if (isJumpBarHidden()) return null; const handleRun = (btn: (typeof jumpButtons)[number]) => { if (btn.linkType === "purpose") { diff --git a/frontend-web/src/strategy-components/lib/light-app.ts b/frontend-web/src/strategy-components/lib/light-app.ts index afda0d7c..52694549 100644 --- a/frontend-web/src/strategy-components/lib/light-app.ts +++ b/frontend-web/src/strategy-components/lib/light-app.ts @@ -1,10 +1,12 @@ /** * 轻应用共享常量 + 运行时 URL 构造(管理页预览 / 列表页跳转 / iframe 宿主共用)。 * - * 登录参数运行时从 `localStorage.getItem("userInfo")`(JSON) 解析;主题参数用配置值。 + * 登录参数运行时从 `userInfo`(JSON) 解析(经 authStorage:嵌入态 sessionStorage / 正常态 + * localStorage,见 scoped-storage.ts);主题参数用配置值。 */ import type { LightApp, LightAppRouteParam } from "@/strategy-components/api/light-apps"; +import { authStorageGet } from "@/core/auth/scoped-storage"; /** iframe 可挂载的父菜单(取自 core/page-layout/sidebar-menu.ts 的现有菜单 id)。 */ export const MOUNT_OPTIONS: { label: string; value: string }[] = [ @@ -114,7 +116,7 @@ export function resolveThemeParamValue( /** 读取并解析 localStorage 中的 userInfo。 */ export function readUserInfo(): Record { try { - const raw = localStorage.getItem("userInfo"); + const raw = authStorageGet("userInfo"); if (!raw) return {}; const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? (parsed as Record) : {};