feat(embed): iframe 嵌入模式(去 chrome + 主题隔离 / hideJump / 登录用户隔离)
外部系统用 iframe 套用深链单智能体问答/多智能体会商时,三个按标签页(sessionStorage) 开关,由深链 URL 参数开启、跨登录跳转/刷新/页内导航存活,绝不跨标签污染正常系统页: - embed=1(可带 theme=):去全局导航栏 + 侧边栏;按外部主题色强制明/暗,走 next-themes forcedTheme(不写 localStorage),嵌入侧不调 setTheme/persistSidebarEnabled - hideJump=1:隐藏深链底部「快捷跳转」栏 TaskJumpBar(底部「下一步」按钮 + 右侧悬浮菜单) - 登录隔离:嵌入态鉴权/身份键(deerflow.auth/userInfo/login.*/tools_token 等)改走 sessionStorage(core/auth/scoped-storage.ts 的 authStorage),iframe 登录用户与正常 登录用户完全隔离、互不覆盖;token 读取统一过 getStoredAuth,改中心函数即全体生效; 跨标签账号变更监听 AuthSyncWatcher 嵌入态禁用 嵌入会话在 main.tsx 渲染前同步启用(早于任何鉴权写入)。正常标签页 isXxx() 恒 false, 行为零变化。新增 core/embed/embed-session.ts、core/auth/scoped-storage.ts。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2b58fe6c8b
commit
31f9838095
16 files changed
+337
-58
No files matched your search
@@ -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/<business-path>/{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`<business-path>` = `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 Line truncated
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<ExternalLoginParams> {
|
||||
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) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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 {
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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<string | null>(readUserId());
|
||||
|
||||
useEffect(() => {
|
||||
// 嵌入(iframe)态:鉴权走按标签页的 sessionStorage,本就不与其它标签页共享,且不应被正常
|
||||
// 标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
|
||||
if (isEmbedActive()) return;
|
||||
|
||||
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function evaluateSettledIdentity() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, unknown> = {};
|
||||
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));
|
||||
}
|
||||
@@ -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<EmbedSession>;
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>) : {};
|
||||
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 等价;无界等嵌套
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>) : {};
|
||||
|
||||
Reference in new issue
Block a user