feat(auth): 独立登录会话开关(设置-外观)—— 同浏览器多账号互不覆盖

复用 iframe 嵌入的按标签页隔离机制:新增 ?isolate=1 标志,authStorage()
从「仅嵌入」放宽为 isEmbedActive() || isIsolatedSession(),独立态走
sessionStorage 故本标签页登录态不被其它标签页覆盖。设置→外观→「登录会话」
加 Switch,开启时先 migrateAuthToSession() 把 localStorage 受保护键快照搬进
sessionStorage 再翻标志,无感不登出;AuthSyncWatcher 跨标签监听对隔离态实时
忽略,运行时开关即刻生效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-19 17:51:08 +08:00
1 parent a9f01bec79
commit a85d2480b4
5 files changed
+146 -15

No files matched your search

+42 -4
View File
@@ -17,15 +17,17 @@
* sessionStorage、正常态恒为 localStorage;嵌入标志一进程内不翻转,故一致)。
*/
import { isEmbedActive } from "@/core/embed/embed-session";
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
/**
* 返回当前应使用的鉴权存储:嵌入态 → sessionStorage(按标签页隔离);否则 → localStorage。
* 非浏览器环境返回 null(调用方各自有 SSR 守卫)。
* 返回当前应使用的鉴权存储:嵌入态(iframe)**或**独立会话态(`?isolate=1`)→ sessionStorage
* (按标签页隔离);否则 → localStorage(跨标签共享、持久)。非浏览器环境返回 null(调用方各自
* 有 SSR 守卫)。两种隔离态等价于「本标签页鉴权独立、绝不污染其它标签页登录态」,区别仅在于嵌入态
* 还会去 chrome / 强制主题,独立会话态只切换存储。
*/
export function authStorage(): Storage | null {
if (typeof window === "undefined") return null;
return isEmbedActive() ? window.sessionStorage : window.localStorage;
return isEmbedActive() || isIsolatedSession() ? window.sessionStorage : window.localStorage;
}
/** 读受保护键(嵌入态 sessionStorage / 正常态 localStorage)。 */
@@ -54,3 +56,39 @@ export function authStorageRemove(key: string): void {
/* noop */
}
}
/**
* 受 `authStorage()` 收口管理的鉴权 / 身份键:四个精确键 + `login.` 前缀(外部登录参数族
* ticket/userId/env/maxkeyOnlineToken/userDetailToken)。用于「开启独立会话」时把当前登录态从
* localStorage 快照搬进 sessionStorage,避免切换存储后被登出。
*/
const PROTECTED_AUTH_EXACT_KEYS = ["deerflow.auth", "userInfo", "userId", "tools_token"];
const PROTECTED_AUTH_PREFIX = "login.";
function isProtectedAuthKey(key: string): boolean {
return PROTECTED_AUTH_EXACT_KEYS.includes(key) || key.startsWith(PROTECTED_AUTH_PREFIX);
}
/**
* 把 localStorage 里的鉴权 / 身份键**快照复制**进 sessionStorage(覆盖式)。
*
* 「外观」设置开启独立会话时调用:此后 `authStorage()` 改读 sessionStorage,若不先搬一份当前账号
* 过去就会瞬间「未登录」。每次开启都重新快照(覆盖旧值),确保拿到的是当前 localStorage 的最新账号。
* 仅搬受保护键,不动 sessionStorage 里既有的本标签页专属态(embed.session / 任务上下文等)。
*/
export function migrateAuthToSession(): void {
if (typeof window === "undefined") return;
try {
const ls = window.localStorage;
const ss = window.sessionStorage;
for (let i = 0; i < ls.length; i++) {
const key = ls.key(i);
if (key && isProtectedAuthKey(key)) {
const value = ls.getItem(key);
if (value !== null) ss.setItem(key, value);
}
}
} catch {
/* noop */
}
}
+9 -4
View File
@@ -4,7 +4,7 @@ import { useEffect, useRef } from "react";
import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { isEmbedActive } from "@/core/embed/embed-session";
import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session";
const AUTH_STORAGE_KEY = "deerflow.auth";
const SETTLE_DELAY_MS = 1200;
@@ -22,9 +22,9 @@ export function AuthSyncWatcher() {
const lastUserIdRef = useRef<string | null>(readUserId());
useEffect(() => {
// 嵌入(iframe)态:鉴权走按标签页的 sessionStorage,本就不与其它标签页共享,且不应被正常
// 标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
if (isEmbedActive()) return;
// 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页
// 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。
if (isEmbedActive() || isIsolatedSession()) return;
let settleTimer: ReturnType<typeof setTimeout> | null = null;
@@ -52,6 +52,11 @@ export function AuthSyncWatcher() {
}
function handleStorage(event: StorageEvent) {
// 运行时若已切到独立会话(「外观」开关在挂载后才开启):鉴权改读本标签页 sessionStorage,
// 不应再被其它标签页的 localStorage 账号变更打扰 —— 即时忽略,无需刷新页面。
if (isEmbedActive() || isIsolatedSession()) {
return;
}
// Ignore unrelated keys and localStorage.clear(). Backend session loss is
// handled centrally through 401 responses.
if (event.key !== AUTH_STORAGE_KEY) {
+46 -5
View File
@@ -1,13 +1,19 @@
/**
* 「外部系统 iframe 套用深链」相关的**按标签页**会话开关。承载两个相互独立的能力,均由 URL
* 路由参数开启、存进同一份 sessionStorage 会话、跨登录跳转/刷新/页内导航存活:
* 「外部系统 iframe 套用深链」相关的**按标签页**会话开关。承载三个相互独立的能力,多由 URL
* 路由参数开启(独立会话 `isolate` 另可由「外观」设置里的开关在运行时切换,见 `setIsolatedSession`),
* 存进同一份 sessionStorage 会话、跨登录跳转/刷新/页内导航存活:
*
* 1. `embed=1`(可带 `&theme=`)—— **嵌入模式**:去掉全局导航栏(Header)+ 侧边栏(PageSidebar),
* 并按外部主题色强制系统明/暗(离散切换)。
* 2. `hideJump=1` —— **隐藏深链底部「快捷跳转」栏**:去掉 `TaskJumpBar` 的两部分——底部的
* 「下一步」等按钮 + 右侧垂直居中的悬浮菜单(单智能体问答 / 多智能体会商深链共用该栏)。
* 3. `isolate=1` —— **独立会话登录**:本标签页的鉴权身份按标签页隔离(见 [[scoped-storage]]
* `authStorage()`,嵌入态与独立态同走 sessionStorage)。用于同一浏览器里**另开一个标签页登
* 录不同账号**而**不覆盖**当前标签页(localStorage)的登录态——`embed` 已隐含此隔离,`isolate`
* 则是把同一套隔离单独提供给**普通(带 chrome)标签页**,不去 chrome、不强制主题、不动跳转栏。
*
* 两者可单独使用,也可叠加:`hideJump` **只**隐藏跳转栏,不动 chrome / 主题;`embed` 不影响跳转栏。
* 三者可单独使用,也可叠加:`hideJump` **只**隐藏跳转栏,不动 chrome / 主题;`embed` 不影响跳转栏;
* `isolate` 只切换鉴权存储,不影响 chrome / 主题 / 跳转栏。
*
* 隔离要点(核心诉求:同一浏览器另开咱们正常系统页,**绝不能**被这些开关影响):
* - 用 **sessionStorage**(按标签页隔离、绝不跨标签)持有这些 flag,而**不用** localStorage
@@ -33,6 +39,8 @@ export interface EmbedSession {
theme: EmbedTheme;
/** 隐藏深链底部「快捷跳转」栏(TaskJumpBar:底部按钮 + 右侧悬浮菜单)。 */
hideJump: boolean;
/** 独立会话:鉴权身份按本标签页隔离(走 sessionStorage),用于同浏览器登录不同账号互不覆盖。 */
isolate: boolean;
}
/** URL 布尔参数的三态:显式开 / 显式关 / 未带。 */
@@ -61,6 +69,11 @@ export function readHideJumpParam(search: string): FlagParam {
return readFlagParam(search, "hideJump");
}
/** 读 URL 里的 `isolate` 三态(独立会话登录)。 */
export function readIsolateParam(search: string): FlagParam {
return readFlagParam(search, "isolate");
}
/**
* 把外部传入的主题色映射成离散明/暗。与 `resolveThemeFromQuery` 同源约定(dark-blue → 深色),
* 另外宽松接受 `dark` / 中文「深色/深蓝」直传;其余(含空)一律浅色。
@@ -86,6 +99,7 @@ export function readEmbedSession(): EmbedSession | null {
embed: p.embed === true,
theme: p.theme === "dark" ? "dark" : "light",
hideJump: p.hideJump === true,
isolate: p.isolate === true,
};
} catch {
return null;
@@ -95,7 +109,7 @@ export function readEmbedSession(): EmbedSession | null {
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
function writeEmbedSession(next: EmbedSession): void {
try {
if (!next.embed && !next.hideJump) {
if (!next.embed && !next.hideJump && !next.isolate) {
sessionStorage.removeItem(EMBED_SESSION_KEY);
return;
}
@@ -115,13 +129,24 @@ export function isJumpBarHidden(): boolean {
return readEmbedSession()?.hideJump === true;
}
/**
* 本标签页是否处于独立会话态:鉴权身份按标签页隔离(走 sessionStorage)。
* `embed` 已隐含同样的隔离,故 `authStorage()` 取「embed || isolate」之并;这里只判 `isolate`,
* 供需要区分「纯独立会话(带 chrome)」与「嵌入态(去 chrome)」的场合使用。
*/
export function isIsolatedSession(): boolean {
return readEmbedSession()?.isolate === 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 cur =
readEmbedSession() ??
{ embed: false, theme: "light" as EmbedTheme, hideJump: false, isolate: false };
const next: EmbedSession = { ...cur };
const embedParam = readEmbedParam(search);
@@ -136,6 +161,10 @@ export function syncEmbedSessionFromSearch(search: string): void {
if (hideJumpParam === "on") next.hideJump = true;
else if (hideJumpParam === "off") next.hideJump = false;
const isolateParam = readIsolateParam(search);
if (isolateParam === "on") next.isolate = true;
else if (isolateParam === "off") next.isolate = false;
writeEmbedSession(next);
}
@@ -170,3 +199,15 @@ export function resolveForcedEmbedTheme(search: string): EmbedTheme | undefined
const session = readEmbedSession();
return session?.embed ? session.theme : undefined;
}
/**
* 运行时直接置位/清除本标签页的独立会话标志(供「外观」设置里的开关用,区别于 URL 参数驱动)。
* 仅切换标志;登录态的「localStorage 快照搬进 sessionStorage」由 [[scoped-storage]]
* `migrateAuthToSession()` 负责,调用顺序见 appearance 设置页。
*/
export function setIsolatedSession(on: boolean): void {
const cur =
readEmbedSession() ??
{ embed: false, theme: "light" as EmbedTheme, hideJump: false, isolate: false };
writeEmbedSession({ ...cur, isolate: on });
}