319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
"use client";
|
||
|
||
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)。
|
||
*
|
||
* 触发方式:
|
||
* - 自动:简洁模式问答落地页自动演示,直到用户显式点「完成」或「跳过」才写
|
||
* localStorage 记忆;中途刷新/离开不记忆,下次进入还会再演示。
|
||
* - 手动:左侧「设置和更多」下拉的「界面使用引导」。问答页内通过
|
||
* COMPACT_GUIDE_START_EVENT 直接启动;其它页面先落 sessionStorage 再导航到
|
||
* /chats/new,由本组件在挂载后消费。
|
||
*
|
||
* 高亮目标通过 `data-compact-guide` 标记在各组件上(见 TARGET 选择器)。启动时逐个
|
||
* 检查目标是否存在(新用户无聊天记录、侧栏被收起等场景对应区块不渲染),缺失的
|
||
* 步骤直接剔除 —— Guide 对找不到的目标元素会抛错,不能盲传。唯一例外是提示词
|
||
* 管理弹窗步骤:弹窗由引导在切到该步时才打开(PROMPT_DIALOG_GUIDE_EVENT 事件),
|
||
* 存在性检查落在它的入口(提示词栏)上,且必须等弹窗锚点真正挂载后才切步。
|
||
*/
|
||
|
||
const COMPACT_GUIDE_DONE_KEY = "deerflow.compact-guide-finished";
|
||
const COMPACT_GUIDE_PENDING_KEY = "deerflow.compact-guide-pending";
|
||
|
||
/** 同页手动触发引导的窗口事件名(「设置和更多」→「界面使用引导」)。 */
|
||
export const COMPACT_GUIDE_START_EVENT = "compact-guide:start";
|
||
|
||
/** 引导步骤高亮目标选择器;各目标元素由所在组件打上同名 data 标记。 */
|
||
const TARGET = {
|
||
nav: '[data-compact-guide="sidebar-nav"]',
|
||
recentChats: '[data-compact-guide="recent-chats"]',
|
||
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;
|
||
|
||
// 等侧栏宽度过渡 / 落地态布局稳定后再启动:Guide 只在步骤切换时测量目标位置,
|
||
// 起步太早会把高亮框定位到过渡中的坐标且不会自行纠正。
|
||
const START_DELAY_MS = 800;
|
||
|
||
// 引导打开提示词管理弹窗后等待其锚点挂载的最长时间,超时视为弹窗打不开,
|
||
// 跳过该步避免引导卡死。
|
||
const PROMPT_DIALOG_TIMEOUT_MS = 4000;
|
||
|
||
function isBrowser(): boolean {
|
||
return typeof window !== "undefined";
|
||
}
|
||
|
||
export function isCompactGuideDone(): boolean {
|
||
return isBrowser() && localStorage.getItem(COMPACT_GUIDE_DONE_KEY) === "1";
|
||
}
|
||
|
||
function markCompactGuideDone() {
|
||
if (isBrowser()) localStorage.setItem(COMPACT_GUIDE_DONE_KEY, "1");
|
||
}
|
||
|
||
/** 跨页触发:非问答页点击「界面使用引导」时先记录请求,导航到 /chats/new 后消费。 */
|
||
export function requestCompactGuideStart() {
|
||
if (isBrowser()) sessionStorage.setItem(COMPACT_GUIDE_PENDING_KEY, "1");
|
||
}
|
||
|
||
function hasCompactGuideRequest(): boolean {
|
||
return (
|
||
isBrowser() &&
|
||
sessionStorage.getItem(COMPACT_GUIDE_PENDING_KEY) === "1"
|
||
);
|
||
}
|
||
|
||
function consumeCompactGuideRequest() {
|
||
if (isBrowser()) sessionStorage.removeItem(COMPACT_GUIDE_PENDING_KEY);
|
||
}
|
||
|
||
interface StepSpec {
|
||
/** 高亮目标选择器;缺省(欢迎步骤 dialog 模式)以 body 兜底。 */
|
||
selector?: string;
|
||
/**
|
||
* 目标元素在引导过程中才渲染时(提示词管理弹窗由引导动态打开),存在性检查
|
||
* 改查这个已存在的「入口」元素 —— Guide 只在切到某步时才解析其元素,所以
|
||
* 该步可以留在步骤列表里,等锚点真正挂载后再切过去。
|
||
*/
|
||
dependsOn?: string;
|
||
mode: "popup" | "dialog";
|
||
placement: NonNullable<GuideStep["placement"]>;
|
||
title: string;
|
||
body: string;
|
||
}
|
||
|
||
const STEP_SPECS: StepSpec[] = [
|
||
{
|
||
mode: "dialog",
|
||
placement: "center",
|
||
title: "欢迎使用简洁模式",
|
||
body: "当前已隐藏顶部导航栏与全局侧边栏,为您留出更大的工作区。接下来花一分钟认识一下界面布局。",
|
||
},
|
||
{
|
||
selector: TARGET.nav,
|
||
mode: "popup",
|
||
placement: "right",
|
||
title: "功能导航",
|
||
body: "新对话、智能体、技能、AI 写作、深度研究等主要入口都在左侧这里,点击即可切换(菜单项由管理员统一配置)。",
|
||
},
|
||
{
|
||
selector: TARGET.recentChats,
|
||
mode: "popup",
|
||
placement: "right",
|
||
title: "聊天记录",
|
||
body: "最近的对话会保留在这里,点击任意一条即可继续上次的问答。",
|
||
},
|
||
{
|
||
selector: TARGET.settingsMenu,
|
||
mode: "popup",
|
||
placement: "right-top",
|
||
title: "设置和更多",
|
||
body: "通知、设置、用户排行榜,以及菜单管理、技能管理等管理入口都收纳在这个菜单里。",
|
||
},
|
||
{
|
||
selector: TARGET.themeSwitcher,
|
||
mode: "popup",
|
||
placement: "bottom-left",
|
||
title: "主题换肤",
|
||
body: "在这里一键切换红色、蓝色、深蓝等多套界面主题。",
|
||
},
|
||
{
|
||
selector: TARGET.layoutMode,
|
||
mode: "popup",
|
||
placement: "bottom-left",
|
||
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",
|
||
placement: "top",
|
||
title: "开始提问",
|
||
body: "在输入框中输入问题,可先选择智能体与知识空间,发送后即可开始问答。",
|
||
},
|
||
];
|
||
|
||
/**
|
||
* 简洁模式引导。挂在 ChatPage:`eligible` 表示当前处于简洁模式(无全局
|
||
* chrome)且非嵌入/强制收起等特殊环境;`autoStart` 表示处于新对话落地态,
|
||
* 满足「首次进入」条件时自动演示一遍。
|
||
*/
|
||
export function CompactModeGuide({
|
||
eligible,
|
||
autoStart = false,
|
||
}: {
|
||
eligible: boolean;
|
||
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(() => {
|
||
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 回调(完成/跳过)写入 —— 这里启动时绝不能提前写,否则中途刷新
|
||
// 又会被当成已看过。
|
||
const autoTriedRef = useRef(false);
|
||
useEffect(() => {
|
||
if (!eligible || !autoStart || autoTriedRef.current) return;
|
||
autoTriedRef.current = true;
|
||
if (isCompactGuideDone()) return;
|
||
const timer = setTimeout(start, START_DELAY_MS);
|
||
return () => {
|
||
clearTimeout(timer);
|
||
// 组件在计时窗口内卸载(用户迅速离开页面)则下次落地再演示。
|
||
autoTriedRef.current = false;
|
||
};
|
||
}, [eligible, autoStart, start]);
|
||
|
||
// 跨页手动触发:非问答页点「界面使用引导」→ 记录请求 → 导航到 /chats/new → 此处启动。
|
||
// 请求只在定时器真正启动引导时才消费 —— StrictMode 下 effect 会 mount→cleanup→mount
|
||
// 双跑,若在检查时就删除,第二次跑就拿不到请求了。
|
||
useEffect(() => {
|
||
if (!eligible) return;
|
||
if (!hasCompactGuideRequest()) return;
|
||
const timer = setTimeout(() => {
|
||
consumeCompactGuideRequest();
|
||
start();
|
||
}, START_DELAY_MS);
|
||
return () => clearTimeout(timer);
|
||
}, [eligible, start]);
|
||
|
||
// 同页手动触发:已在问答页时「界面使用引导」直接广播事件启动。
|
||
useEffect(() => {
|
||
if (!eligible) return;
|
||
const handler = () => start();
|
||
window.addEventListener(COMPACT_GUIDE_START_EVENT, handler);
|
||
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;
|
||
|
||
return (
|
||
<Guide
|
||
current={current}
|
||
steps={stepsRef.current}
|
||
onChange={goTo}
|
||
onFinish={finish}
|
||
onSkip={finish}
|
||
/>
|
||
);
|
||
}
|