用户引导

This commit is contained in:
董宏凯 committed 2026-08-26 11:09:43 +08:00
1 parent c6fcf51e0c
commit 33b719d460
5 files changed
+456 -42

No files matched your search

@@ -3,6 +3,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Guide, type GuideStep } from "tdesign-react";
import { PROMPT_DIALOG_GUIDE_EVENT } from "@/components/workspace/custom-prompt-menu";
/**
* 简洁模式(隐藏全局导航栏 + 页面侧边栏的布局)新手引导,基于 TDesign React 的
* Guide 组件(https://tdesign.tencent.com/react/components/guide)。
@@ -16,7 +18,9 @@ import { Guide, type GuideStep } from "tdesign-react";
*
* 高亮目标通过 `data-compact-guide` 标记在各组件上(见 TARGET 选择器)。启动时逐个
* 检查目标是否存在(新用户无聊天记录、侧栏被收起等场景对应区块不渲染),缺失的
* 步骤直接剔除 —— Guide 对找不到的目标元素会抛错,不能盲传。
* 步骤直接剔除 —— Guide 对找不到的目标元素会抛错,不能盲传。唯一例外是提示词
* 管理弹窗步骤:弹窗由引导在切到该步时才打开(PROMPT_DIALOG_GUIDE_EVENT 事件),
* 存在性检查落在它的入口(提示词栏)上,且必须等弹窗锚点真正挂载后才切步。
*/
const COMPACT_GUIDE_DONE_KEY = "deerflow.compact-guide-finished";
@@ -32,6 +36,8 @@ const TARGET = {
settingsMenu: '[data-compact-guide="settings-menu"]',
themeSwitcher: '[data-compact-guide="theme-switcher"]',
layoutMode: '[data-compact-guide="layout-mode"]',
promptBar: '[data-compact-guide="prompt-bar"]',
promptDialog: '[data-compact-guide="prompt-dialog"]',
promptInput: '[data-compact-guide="prompt-input"]',
} as const;
@@ -39,6 +45,10 @@ const TARGET = {
// 起步太早会把高亮框定位到过渡中的坐标且不会自行纠正。
const START_DELAY_MS = 800;
// 引导打开提示词管理弹窗后等待其锚点挂载的最长时间,超时视为弹窗打不开,
// 跳过该步避免引导卡死。
const PROMPT_DIALOG_TIMEOUT_MS = 4000;
function isBrowser(): boolean {
return typeof window !== "undefined";
}
@@ -70,6 +80,12 @@ function consumeCompactGuideRequest() {
interface StepSpec {
/** 高亮目标选择器;缺省(欢迎步骤 dialog 模式)以 body 兜底。 */
selector?: string;
/**
* 目标元素在引导过程中才渲染时(提示词管理弹窗由引导动态打开),存在性检查
* 改查这个已存在的「入口」元素 —— Guide 只在切到某步时才解析其元素,所以
* 该步可以留在步骤列表里,等锚点真正挂载后再切过去。
*/
dependsOn?: string;
mode: "popup" | "dialog";
placement: NonNullable<GuideStep["placement"]>;
title: string;
@@ -118,6 +134,21 @@ const STEP_SPECS: StepSpec[] = [
title: "切回全量模式",
body: "需要顶部导航栏和全局侧边栏时,从这里随时切换回「全量模式」。",
},
{
selector: TARGET.promptBar,
mode: "popup",
placement: "top",
title: "常用提示词",
body: "输入框上方是常用提示词栏:点击任意提示词即可一键填入输入框,省去重复输入。最右侧的齿轮按钮用来管理这些提示词,点击「下一步」带你看一看。",
},
{
selector: TARGET.promptDialog,
dependsOn: TARGET.promptBar,
mode: "popup",
placement: "top",
title: "管理常用提示词",
body: "这就是齿轮按钮打开的管理弹窗:填写名称与提示词内容、选择关联的智能体后保存,即可添加自己的常用提示词(与账号绑定、跨设备可用);下方列表中已保存的提示词可随时编辑、删除;顶部开关可让本账号只用自己保存的提示词。",
},
{
selector: TARGET.promptInput,
mode: "popup",
@@ -127,19 +158,6 @@ const STEP_SPECS: StepSpec[] = [
},
];
function buildSteps(): GuideStep[] {
if (!isBrowser()) return [];
return STEP_SPECS.filter(
(spec) => !spec.selector || document.querySelector(spec.selector),
).map((spec) => ({
element: spec.selector ?? (() => document.body),
mode: spec.mode,
placement: spec.placement,
title: spec.title,
body: spec.body,
}));
}
/**
* 简洁模式引导。挂在 ChatPage:`eligible` 表示当前处于简洁模式(无全局
* chrome)且非嵌入/强制收起等特殊环境;`autoStart` 表示处于新对话落地态,
@@ -153,14 +171,54 @@ export function CompactModeGuide({
autoStart?: boolean;
}) {
const [current, setCurrent] = useState(-1);
const specsRef = useRef<StepSpec[]>([]);
const stepsRef = useRef<GuideStep[]>([]);
const pollTimerRef = useRef<number | null>(null);
// 引导是否处于「已要求打开并接管提示词管理弹窗」状态(事件已发出 owner:true)。
const promptDialogTakenRef = useRef(false);
const clearPollTimer = useCallback(() => {
if (pollTimerRef.current !== null) {
window.clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
const setPromptDialog = useCallback(
(open: boolean, takeOver: boolean) => {
promptDialogTakenRef.current = takeOver;
window.dispatchEvent(
new CustomEvent(PROMPT_DIALOG_GUIDE_EVENT, {
detail: { open, owner: takeOver },
}),
);
},
[],
);
const releasePromptDialog = useCallback(() => {
if (!promptDialogTakenRef.current) return;
setPromptDialog(false, false);
}, [setPromptDialog]);
const start = useCallback(() => {
const steps = buildSteps();
if (steps.length === 0) return;
stepsRef.current = steps;
clearPollTimer();
releasePromptDialog();
const specs = STEP_SPECS.filter(
(spec) =>
!spec.selector || document.querySelector(spec.dependsOn ?? spec.selector),
);
if (specs.length === 0) return;
specsRef.current = specs;
stepsRef.current = specs.map((spec) => ({
element: spec.selector ?? (() => document.body),
mode: spec.mode,
placement: spec.placement,
title: spec.title,
body: spec.body,
}));
setCurrent(0);
}, []);
}, [clearPollTimer, releasePromptDialog]);
// 自动引导:没显式结束过(没点过完成/跳过)时,每次落地页都演示。是否「已看过」
// 只由下方 finish 回调(完成/跳过)写入 —— 这里启动时绝不能提前写,否则中途刷新
@@ -199,12 +257,52 @@ export function CompactModeGuide({
return () => window.removeEventListener(COMPACT_GUIDE_START_EVENT, handler);
}, [eligible, start]);
// 中途卸载(用户离开页面)也要归还弹窗控制权并停止等待。
useEffect(() => {
return () => {
clearPollTimer();
releasePromptDialog();
};
}, [clearPollTimer, releasePromptDialog]);
// 受控切步:进入提示词弹窗步骤前先打开弹窗并等锚点挂载(Guide 切步时才解析
// 元素,元素必须已存在);离开该步 / 跳过 / 完成时收回弹窗。跳过和完成时
// onChange 也会收到 -1,与 finish 回调各自幂等。
const goTo = (next: number) => {
const specs = specsRef.current;
clearPollTimer();
if (next < 0 || next >= specs.length) {
releasePromptDialog();
setCurrent(-1);
return;
}
if (specs[next].selector === TARGET.promptDialog) {
setPromptDialog(true, true);
const startedAt = Date.now();
pollTimerRef.current = window.setInterval(() => {
if (document.querySelector(TARGET.promptDialog)) {
clearPollTimer();
setCurrent(next);
} else if (Date.now() - startedAt > PROMPT_DIALOG_TIMEOUT_MS) {
clearPollTimer();
releasePromptDialog();
goTo(next + 1 >= specs.length ? -1 : next + 1);
}
}, 50);
return;
}
releasePromptDialog();
setCurrent(next);
};
// 完成 / 跳过是用户显式结束引导 —— 只有这两种情况才记忆「已看过」;中途刷新或
// 离开不记忆,下次进入简洁模式问答页还会再演示。
const finish = useCallback(() => {
clearPollTimer();
releasePromptDialog();
markCompactGuideDone();
setCurrent(-1);
}, []);
}, [clearPollTimer, releasePromptDialog]);
if (!eligible) return null;
@@ -212,7 +310,7 @@ export function CompactModeGuide({
<Guide
current={current}
steps={stepsRef.current}
onChange={setCurrent}
onChange={goTo}
onFinish={finish}
onSkip={finish}
/>
@@ -42,6 +42,13 @@ import {
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/**
* 简洁模式引导控制提示词管理弹窗的窗口事件。detail 为
* `{ open: boolean; owner?: boolean }`:`owner` 表示弹窗由引导接管,期间 ESC /
* 点击遮罩等关闭请求一律忽略,直到引导以 `owner: false` 收回控制权。
*/
export const PROMPT_DIALOG_GUIDE_EVENT = "compact-guide:prompt-dialog";
export function CustomPromptBar({
onInsert,
className,
@@ -56,10 +63,23 @@ export function CustomPromptBar({
(prompt) => !prompt.agent_id || prompt.agent_id === currentAgentId,
);
const [manageOpen, setManageOpen] = useState(false);
const guideOwnsDialogRef = useRef(false);
const scrollRef = useRef<HTMLDivElement>(null);
const [canLeft, setCanLeft] = useState(false);
const [canRight, setCanRight] = useState(false);
useEffect(() => {
const handler = (event: Event) => {
const detail = (
event as CustomEvent<{ open?: boolean; owner?: boolean }>
).detail;
guideOwnsDialogRef.current = Boolean(detail?.owner);
setManageOpen(Boolean(detail?.open));
};
window.addEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler);
return () => window.removeEventListener(PROMPT_DIALOG_GUIDE_EVENT, handler);
}, []);
const updateArrows = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
@@ -81,7 +101,10 @@ export function CustomPromptBar({
return (
<>
<div className={cn("flex min-w-0 items-center gap-1", className)}>
<div
className={cn("flex min-w-0 items-center gap-1", className)}
data-compact-guide="prompt-bar"
>
{prompts.length > 0 && (
<>
{overflowing && (
@@ -148,7 +171,14 @@ export function CustomPromptBar({
</Button>
</div>
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
<ManagePromptsDialog
open={manageOpen}
onOpenChange={(next) => {
// 引导演示期间接管弹窗:ESC / 点击遮罩不关闭,避免高亮目标消失导致引导报错。
if (!next && guideOwnsDialogRef.current) return;
setManageOpen(next);
}}
/>
</>
);
}
@@ -257,7 +287,10 @@ function ManagePromptsDialog({
onOpenChange(next);
}}
>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogContent
className="max-h-[85vh] overflow-y-auto sm:max-w-xl"
data-compact-guide="prompt-dialog"
>
<DialogHeader>
<DialogTitle>管理常用提示词</DialogTitle>
<DialogDescription>
@@ -0,0 +1,164 @@
/**
* embed-session.ts 浏览上下文隔离单测。
* 运行方式(项目无 vitest):
* npx esbuild src/core/embed/embed-session.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/embed-session.test.mjs
* node --test .tmp-tests/embed-session.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
isChromeHidden,
isEmbedActive,
isIsolatedSession,
readEmbedSession,
syncEmbedSessionFromSearch,
} from "./embed-session";
class MemoryStorage {
#map = new Map<string, string>();
getItem(key: string): string | null {
return this.#map.has(key) ? this.#map.get(key)! : null;
}
setItem(key: string, value: string): void {
this.#map.set(key, String(value));
}
removeItem(key: string): void {
this.#map.delete(key);
}
}
type FakeWindow = {
self: FakeWindow;
top: FakeWindow;
sessionStorage: MemoryStorage;
location: { hash: string; search: string };
__deerflowTopChromeEmbed?: boolean;
};
const originalWindow = (globalThis as { window?: unknown }).window;
function withSharedTab<T>(run: (ctx: {
storage: MemoryStorage;
asTop: () => void;
asIframe: () => void;
}) => T): T {
const storage = new MemoryStorage();
const top = {
sessionStorage: storage,
location: { hash: "", search: "" },
} as FakeWindow;
top.self = top;
top.top = top;
const iframe = {
sessionStorage: storage,
location: { hash: "#/login/guest?embed=1&theme=blue&hideJump=0", search: "" },
} as FakeWindow;
iframe.self = iframe;
iframe.top = top;
const asTop = () => {
(globalThis as { window: FakeWindow }).window = top;
};
const asIframe = () => {
(globalThis as { window: FakeWindow }).window = iframe;
};
asTop();
try {
return run({ storage, asTop, asIframe });
} finally {
if (originalWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window: unknown }).window = originalWindow;
}
}
}
test("nested embed=1 does not hide chrome on the same-origin top window", () => {
withSharedTab(({ storage, asTop, asIframe }) => {
asTop();
syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
asIframe();
syncEmbedSessionFromSearch("?embed=1&theme=blue&hideJump=0");
assert.equal(isEmbedActive(), true);
assert.equal(isChromeHidden(), true);
assert.ok(storage.getItem("embed.session.nested"));
assert.equal(storage.getItem("embed.session"), null);
asTop();
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
});
});
test("nested iframe reclaims a polluted shared embed.session key", () => {
withSharedTab(({ storage, asTop, asIframe }) => {
asTop();
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: false,
}),
);
assert.equal(isEmbedActive(), true);
asIframe();
syncEmbedSessionFromSearch("?embed=1");
asTop();
assert.equal(isEmbedActive(), false);
assert.equal(isChromeHidden(), false);
assert.equal(storage.getItem("embed.session"), null);
});
});
test("nested iframe keeps the top window's own embed session", () => {
withSharedTab(({ asTop, asIframe }) => {
asTop();
syncEmbedSessionFromSearch("?embed=1&theme=dark");
syncEmbedSessionFromSearch("");
assert.equal(isEmbedActive(), true);
asIframe();
syncEmbedSessionFromSearch("?embed=1");
asTop();
assert.equal(isEmbedActive(), true);
assert.equal(readEmbedSession()?.theme, "dark");
});
});
test("nested reclaim strips embed but keeps top isolate", () => {
withSharedTab(({ storage, asTop, asIframe }) => {
asTop();
storage.setItem(
"embed.session",
JSON.stringify({
embed: true,
theme: "light",
hideJump: false,
hideHeader: false,
hideChrome: false,
isolate: true,
}),
);
asIframe();
syncEmbedSessionFromSearch("?embed=1");
asTop();
assert.equal(isEmbedActive(), false);
assert.equal(isIsolatedSession(), true);
});
});
+137 -18
View File
@@ -27,11 +27,133 @@ export { resolveEmbedThemeMode } from "./theme-mode.mjs";
* - 去 chrome / 去跳转栏均由本模块的 sessionStorage flag 驱动,不碰 `persistSidebarEnabled` 等
* localStorage 偏好。
*
* iframe 的浏览器上下文就是它自己的标签页,sessionStorage 天然「一个 iframe 一份、随关闭消失」;
* 正常页标签页里既无这些参数也无这份会话 → 所有 `isXxx()` 恒为 false,完全不受影响。
* 同源陷阱:sessionStorage **不是**「一个 iframe 一份」。规范按「顶层标签页 × origin」
* 分区,同一标签里所有同源文档(含嵌套 iframe、换路径/换端口反向代理后仍同源的另一套部署)
* **共用一份**。全量模式任务管理用 iframe 套外部页、外部页再套咱们 `embed=1` 深链时,内层
* 若写入同一个 `embed.session` 键,外层点左侧菜单重算 `PageLayout` 就会把导航栏/侧边栏藏掉,
* 路由看起来却没变。因此顶层窗口写 `embed.session`,嵌套 iframe 写 `embed.session.nested`。
*/
const EMBED_SESSION_KEY = "embed.session";
/** 嵌套 iframe(`window !== top`)专用,避免与顶层全量页共用 `embed.session`。 */
const EMBED_SESSION_NESTED_KEY = "embed.session.nested";
/**
* 顶层窗口认领「本页自己的去 chrome 会话」的标记。嵌套同源 iframe 看到此标记就不会
* 清掉顶层合法的 embed/hideChrome 持久化。
*/
const TOP_CHROME_OWNER_FLAG = "__deerflowTopChromeEmbed";
type TopChromeOwnerWindow = Window & { [TOP_CHROME_OWNER_FLAG]?: boolean };
function getWindow(): TopChromeOwnerWindow | undefined {
if (typeof globalThis === "undefined") return undefined;
return (globalThis as { window?: TopChromeOwnerWindow }).window;
}
function getSessionStorage(): Storage | null {
try {
return getWindow()?.sessionStorage ?? null;
} catch {
return null;
}
}
/** 当前文档是否为顶层窗口(跨源 `window.top` 比较是安全的;访问被拒则视为嵌套)。 */
function isTopLevelWindow(): boolean {
const w = getWindow();
if (!w) return true;
try {
return w.self === w.top;
} catch {
return false;
}
}
function embedSessionKey(): string {
return isTopLevelWindow() ? EMBED_SESSION_KEY : EMBED_SESSION_NESTED_KEY;
}
function parseEmbedSession(raw: string | null): EmbedSession | null {
if (!raw) return null;
try {
const p = JSON.parse(raw) as Partial<EmbedSession>;
return {
embed: p.embed === true,
theme: p.theme === "dark" ? "dark" : "light",
hideJump: p.hideJump === true,
hideHeader: p.hideHeader === true,
hideChrome: p.hideChrome === true,
isolate: p.isolate === true,
};
} catch {
return null;
}
}
function sessionHidesChrome(session: EmbedSession | null | undefined): boolean {
return (
session?.embed === true ||
session?.hideChrome === true ||
session?.hideHeader === true ||
session?.hideJump === true
);
}
/** 顶层窗口同步过自己的去 chrome 参数后打标,供同源嵌套 iframe 识别「这不是我写脏的」。 */
function markTopChromeOwner(session: EmbedSession): void {
const w = getWindow();
if (!w || !isTopLevelWindow()) return;
if (!sessionHidesChrome(session)) return;
w[TOP_CHROME_OWNER_FLAG] = true;
}
/**
* 旧代码把嵌套 iframe 的 embed 写进了共用的 `embed.session`。嵌套侧启动时把这份
* 遗产迁到 nested 键;若顶层并未认领去 chrome,再把顶层键里的 embed/hideChrome 清掉。
*/
function migrateLegacySharedSessionIfNested(): void {
if (isTopLevelWindow()) return;
try {
const store = getSessionStorage();
if (!store || store.getItem(EMBED_SESSION_NESTED_KEY)) return;
const legacy = store.getItem(EMBED_SESSION_KEY);
if (legacy) store.setItem(EMBED_SESSION_NESTED_KEY, legacy);
} catch {
/* noop */
}
}
function reclaimPollutedTopChrome(): void {
if (isTopLevelWindow()) return;
try {
const top = getWindow()?.top as TopChromeOwnerWindow | null | undefined;
if (!top || top[TOP_CHROME_OWNER_FLAG] === true) return;
const store = getSessionStorage();
if (!store) return;
const cur = parseEmbedSession(store.getItem(EMBED_SESSION_KEY));
if (!cur) return;
const next: EmbedSession = {
...cur,
embed: false,
hideJump: false,
hideHeader: false,
hideChrome: false,
};
if (
!next.embed &&
!next.hideJump &&
!next.hideHeader &&
!next.hideChrome &&
!next.isolate
) {
store.removeItem(EMBED_SESSION_KEY);
return;
}
store.setItem(EMBED_SESSION_KEY, JSON.stringify(next));
} catch {
/* 跨源 top:sessionStorage 本就不与外层其它 origin 共享,无需回收 */
}
}
export type EmbedTheme = "light" | "dark";
@@ -101,20 +223,10 @@ export function readEmbedThemeParam(search: string): string {
return (toSearchParams(search).get("theme") ?? "").trim();
}
/** 读本标签页的嵌入会话;无 → null。 */
/** 读本浏览上下文的嵌入会话;无 → null。顶层与嵌套 iframe 使用不同的 sessionStorage 键。 */
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,
hideHeader: p.hideHeader === true,
hideChrome: p.hideChrome === true,
isolate: p.isolate === true,
};
return parseEmbedSession(getSessionStorage()?.getItem(embedSessionKey()) ?? null);
} catch {
return null;
}
@@ -123,11 +235,14 @@ export function readEmbedSession(): EmbedSession | null {
/** 写会话;无任何开关置位时直接清除(保持 sessionStorage 干净)。隐私模式/quota 异常静默失败。 */
function writeEmbedSession(next: EmbedSession): void {
try {
const store = getSessionStorage();
if (!store) return;
const key = embedSessionKey();
if (!next.embed && !next.hideJump && !next.hideHeader && !next.hideChrome && !next.isolate) {
sessionStorage.removeItem(EMBED_SESSION_KEY);
store.removeItem(key);
return;
}
sessionStorage.setItem(EMBED_SESSION_KEY, JSON.stringify(next));
store.setItem(key, JSON.stringify(next));
} catch {
/* noop */
}
@@ -182,6 +297,7 @@ export function isIsolatedSession(): boolean {
* 之前置位的开关不会被抹掉。
*/
export function syncEmbedSessionFromSearch(search: string): void {
migrateLegacySharedSessionIfNested();
const cur =
readEmbedSession() ??
{
@@ -219,6 +335,8 @@ export function syncEmbedSessionFromSearch(search: string): void {
else if (isolateParam === "off") next.isolate = false;
writeEmbedSession(next);
if (isTopLevelWindow()) markTopChromeOwner(next);
else reclaimPollutedTopChrome();
}
/**
@@ -227,8 +345,9 @@ export function syncEmbedSessionFromSearch(search: string): void {
* 否则首帧 LoginPage 的 effect 可能抢在 AppProviders 同步 effect 之前把登录参数写进 localStorage。
*/
function currentHashSearch(): string {
if (typeof window === "undefined") return "";
const hash = window.location.hash || "";
const w = getWindow();
if (!w) return "";
const hash = w.location?.hash || "";
const i = hash.indexOf("?");
return i >= 0 ? hash.slice(i) : "";
}