用户引导
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user