Merge branch 'dhk-1' into 'master'

feat(agent-sidebar): 独立问答智能体列表栏增加「最近的对话」区(参考首页)

See merge request knowledge-base-bj/deerflow-server!138
This commit is contained in:
董宏凯 committed 2026-06-19 09:53:27 +00:00
commit 3b165d9ea5
12 files changed
+187 -16

No files matched your search

+1 -1
View File
@@ -88,7 +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,行为**零变化**。
- **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,行为**零变化**。第三个开关 `?isolate=1`(**独立登录会话**)把同一套**登录用户隔离**单独提供给**普通(带 chrome)标签页**——只切换鉴权存储、不去 chrome / 不强制主题 / 不动跳转栏:`authStorage()` 的条件从「仅嵌入」放宽为 `isEmbedActive() || isIsolatedSession()`,`AuthSyncWatcher` 的跨标签监听对嵌入态**或**独立态都即时忽略(handler 内实时判,运行时开关也即刻生效)。除 URL 参数外,**设置 → 外观 →「登录会话」** 有一个 Switch「独立登录会话(本标签页)」运行时切换(`setIsolatedSession`,`appearance-settings-page.tsx`):**开启时先 `migrateAuthToSession()`**(`scoped-storage.ts`,把 localStorage 的受保护键 `deerflow.auth`/`userInfo`/`userId`/`tools_token` + `login.*` 前缀快照搬进 sessionStorage)再翻标志,故无感不登出;此后本标签页登录态独立——别处登录/换号影响不到它,它换号也影响不到别人,但关掉该标签即结束(需重登)。开启给 toast 提示。
### Configuration
@@ -1,7 +1,7 @@
"use client";
import { DropletIcon, LayoutGridIcon, LayoutIcon, ListIcon, MoonIcon, PanelLeftIcon, SunIcon } from "lucide-react";
import { useMemo, type ComponentType, type SVGProps } from "react";
import { useMemo, useState, type ComponentType, type SVGProps } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
@@ -20,6 +20,8 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
import { migrateAuthToSession } from "@/core/auth/scoped-storage";
import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-session";
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings";
@@ -64,6 +66,29 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
// 独立登录会话(本标签页):开关切换鉴权存储 localStorage ↔ sessionStorage(见 scoped-storage)。
const [isolatedSession, setIsolatedSessionState] = useState(() => isIsolatedSession());
const handleToggleIsolatedSession = (checked: boolean) => {
if (checked) {
// 先把当前账号从 localStorage 快照搬进 sessionStorage,再翻标志,避免切换存储后被登出。
migrateAuthToSession();
setIsolatedSession(true);
setIsolatedSessionState(true);
toast.success("已开启独立登录会话", {
description:
"本标签页的登录已与其它标签页隔离:在别的窗口登录或切换其它账号都不会影响这里,你也可以在此单独登录另一个账号。该独立会话只存在于当前标签页,关闭此标签后即结束(需重新登录)。",
duration: 9000,
});
} else {
setIsolatedSession(false);
setIsolatedSessionState(false);
toast.info("已关闭独立登录会话", {
description: "本标签页恢复与其它标签页共享同一登录。如页面显示未及时同步,请刷新本页。",
duration: 6000,
});
}
};
const handleSetGlobalDefault = async (
value: "light" | "dark" | "lightblue",
) => {
@@ -395,6 +420,28 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<Separator />
<SettingsSection
title="登录会话"
description="控制本标签页的登录身份是否与浏览器其它标签页隔离。"
>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">独立登录会话(本标签页)</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
开启后,本标签页的登录状态仅保存在当前标签页:在别的窗口登录或切换其它账号都不会影响这里,
你也可以在此单独登录另一个账号。关闭浏览器标签后该独立会话即结束(需重新登录)。
</p>
</div>
<Switch
checked={isolatedSession}
onCheckedChange={handleToggleIsolatedSession}
aria-label="独立登录会话"
/>
</div>
</SettingsSection>
<Separator />
<SettingsSection
title="对话体验"
description="问答过程中的辅助显示。"
+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) {
+16 -1
View File
@@ -59,6 +59,9 @@ export type TaskButtonLinkType = "url" | "business" | "purpose";
/** 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同)。 */
export type TaskButtonIdKind = "task" | "action";
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口(嵌入态即当前 iframe)。 */
export type TaskButtonOpenMode = "blank" | "self";
/** 一个可配置跳转按钮。 */
export interface TaskButton {
id: string;
@@ -76,6 +79,8 @@ export interface TaskButton {
* 实为行动id,任务id 由 action 接口解析。其它业务两者相同。
*/
idKind: TaskButtonIdKind;
/** ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。仅 ``url`` 类型有意义。 */
openMode: TaskButtonOpenMode;
enabled: boolean;
sortOrder: number;
}
@@ -109,6 +114,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
appendTaskId: false,
idParam: "id",
idKind: "task",
openMode: "blank",
enabled: true,
sortOrder,
});
@@ -122,6 +128,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
appendTaskId: false,
idParam: "id",
idKind: "task",
openMode: "blank",
enabled: true,
sortOrder,
});
@@ -136,6 +143,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
appendTaskId: true,
idParam: "id",
idKind: "task",
openMode: "blank",
enabled: true,
sortOrder: 0,
},
@@ -148,6 +156,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
appendTaskId: true,
idParam: "id",
idKind: "task",
openMode: "blank",
enabled: true,
sortOrder: 1,
},
@@ -160,6 +169,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
appendTaskId: true,
idParam: "id",
idKind: "task",
openMode: "blank",
enabled: true,
sortOrder: 2,
},
@@ -227,7 +237,12 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
const id = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId;
const param = (btn.idParam || "id").trim() || "id";
const url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base;
window.open(url, "_blank", "noopener,noreferrer");
// 打开方式:self=当前窗口(嵌入态即当前 iframe);否则 blank=新窗口(默认)。
if (btn.openMode === "self") {
window.location.assign(url);
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
return;
}
// business:内部切换到另一业务
+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 });
}
@@ -104,6 +104,7 @@ export default function TaskButtonsPage() {
appendTaskId: true,
idParam: "id",
idKind: "task",
openMode: "blank",
enabled: true,
sortOrder: maxSort + 1,
},
@@ -305,6 +306,19 @@ export default function TaskButtonsPage() {
)}
</div>
)}
<label className="text-muted-foreground ml-2 shrink-0 text-xs">打开方式</label>
<Select
value={r.openMode}
onValueChange={(v) => update(r.id, { openMode: v as TaskButton["openMode"] })}
>
<SelectTrigger className="h-8 w-32" title="外链打开方式">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="blank">新窗口</SelectItem>
<SelectItem value="self">当前窗口</SelectItem>
</SelectContent>
</Select>
</>
) : (
<Select value={r.target} onValueChange={(v) => update(r.id, { target: v })}>
@@ -15,6 +15,7 @@ import type {
TaskButton,
TaskButtonIdKind,
TaskButtonLinkType,
TaskButtonOpenMode,
} from "@/core/auth/task-buttons";
const BASE = () => `${getBackendBaseURL()}/api/task-buttons`;
@@ -28,6 +29,7 @@ interface ServerButton {
append_task_id: boolean;
id_param: string;
id_kind: string;
open_mode: string;
enabled: boolean;
sort_order: number;
}
@@ -42,6 +44,7 @@ function toButton(s: ServerButton): TaskButton {
appendTaskId: s.append_task_id ?? true,
idParam: s.id_param || "id",
idKind: (s.id_kind as TaskButtonIdKind) ?? "task",
openMode: (s.open_mode as TaskButtonOpenMode) ?? "blank",
enabled: s.enabled ?? true,
sortOrder: s.sort_order ?? 0,
};
@@ -57,6 +60,7 @@ function toBody(b: TaskButton): ServerButton {
append_task_id: b.appendTaskId,
id_param: b.idParam,
id_kind: b.idKind,
open_mode: b.openMode,
enabled: b.enabled,
sort_order: b.sortOrder,
};
@@ -46,6 +46,8 @@ class TaskButton(BaseModel):
id_param: str = Field(default="id", max_length=64)
# task=任务id(默认)/ action=行动id(仅 xdfx 二者不同)。
id_kind: str = Field(default="task", max_length=16)
# url 外链打开方式:blank=新窗口(默认)/ self=当前窗口。
open_mode: str = Field(default="blank", max_length=16)
enabled: bool = True
sort_order: int = 0
@@ -18,6 +18,7 @@ def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]:
"append_task_id": bool(data.get("append_task_id", True)),
"id_param": str(data.get("id_param") or "id"),
"id_kind": str(data.get("id_kind") or "task"),
"open_mode": str(data.get("open_mode") or "blank"),
"enabled": bool(data.get("enabled", True)),
"sort_order": int(data.get("sort_order", 0)),
"updated_by": updated_by,
@@ -46,6 +46,8 @@ class TaskButtonRow(Base):
# 追加的 id 取哪种:``task``=任务id(默认)/ ``action``=行动id(仅 xdfx 二者不同:深链原始
# ?taskId= 实为行动id,任务id 由 action 接口解析)。可空,读时归一化为 ``task``。
id_kind: Mapped[str | None] = mapped_column(String(16), default="task")
# ``url`` 外链打开方式:``blank``=新窗口(默认)/ ``self``=当前窗口。可空,读时归一化为 ``blank``。
open_mode: Mapped[str | None] = mapped_column(String(16), default="blank")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
updated_by: Mapped[str | None] = mapped_column(String(64))
@@ -20,6 +20,7 @@ def _to_dict(row: TaskButtonRow) -> dict[str, Any]:
# 旧行 / NULL 归一化为默认值。
data["id_param"] = data.get("id_param") or "id"
data["id_kind"] = data.get("id_kind") or "task"
data["open_mode"] = data.get("open_mode") or "blank"
return data
@@ -33,6 +34,7 @@ def _row_from(data: dict[str, Any], updated_by: str | None) -> TaskButtonRow:
append_task_id=bool(data.get("append_task_id", True)),
id_param=str(data.get("id_param") or "id"),
id_kind=str(data.get("id_kind") or "task"),
open_mode=str(data.get("open_mode") or "blank"),
enabled=bool(data.get("enabled", True)),
sort_order=int(data.get("sort_order", 0)),
updated_by=updated_by,