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:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-19 12:02:36 +08:00
1 parent 2b58fe6c8b
commit 31f9838095
16 files changed
+337 -58

No files matched your search

+16 -21
View File
@@ -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) ?? "",
};
}
/**
+12 -5
View File
@@ -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 */
}
}
+5
View File
@@ -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));
}
}
+6 -5
View File
@@ -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>) : {};