内部平台有个通病:功能越堆越多,新人进来面对满屏的入口不知道该先点哪里。传统解法是写用户手册——但说实话,没人读手册,手册的宿命是写完即过时。
我们在自己的内部 Agent 平台上选了另一条路:对标游戏新手教程,做一个交互式分步引导。页面元素高亮 + 浮动提示卡片 + 步骤导航,随时可跳过,随时可重进。最终形态是纯前端实现、三个核心文件、零后端改动。这篇文章记录它的设计和关键实现细节,包括一个很多人没想到的权衡:为什么不用 driver.js 这类现成库。
一、目标与形态
设计目标一句话:为首次登录的用户提供交互式分步指引,降低上手门槛,替代传统用户手册。
核心交互全部对齐游戏新手教程的体验标准:
- 当前要讲的功能区域高亮,其余界面压暗;
- 高亮区旁浮动一张提示卡片,带标题、描述、步骤导航;
- 支持随时跳过、键盘操作、手动重新进入;
- 引导跨多个页面——步骤之间要能自动跳转路由。
最后一条是内部平台和营销落地页的关键区别:引导不是单页面上高亮几个按钮,而是带着用户在真实页面间走一遍核心功能闭环。
二、为什么不用 driver.js / intro.js
这是立项时第一个被问的问题。driver.js、intro.js、shepherd.js 这类库做"元素高亮 + 提示气泡"已经很成熟,直接用似乎能省掉造轮子。我们评估后决定手写,理由按权重排序:
- 视觉风格必须深度定制。平台是深色赛博朋克主题(近黑底、青色/绿色辉光强调),遮罩颜色、高亮边缘的发光边框、提示卡片的边框透明度和等宽字体,全都要贴设计系统。第三方库的主题定制能力普遍是"改几个 CSS 变量"级别,改到我们需要的程度,覆写样式的工作量已经接近重写渲染层。
- 跨路由导航是一等需求。我们的引导步骤分布在 4 个不同路由下,步骤切换要先
navigate()、等目标页面 DOM 渲染完再计算高亮位置。这与 React Router 的渲染时序深度耦合,库提供的通用 hook 接不干净,最后还是要自己包一层路由同步逻辑——那索性整条链路自己写。 - 依赖面与体积。整个功能的渲染本质就是"一个定位 div + 一张卡片 + 几个事件监听",手写约三百行。为一个三百行能覆盖的需求引入一个第三方依赖(含其后续升级、安全公告、 breaking change),不划算。
- 没有高级需求。我们不需要库提供的复杂能力:多形状挖洞、SVG 路径高亮、步骤条件分支、异步步骤。需求清单越短,自研的相对成本越低。
反过来,如果哪天需求长成"运营可配置引导步骤、按用户角色分流、A/B 测试引导文案",我们会重新评估——那时引导已经是一个产品功能而非一次性组件,引入库或低代码化都合理。选型结论跟着需求复杂度走,不是一次性的信仰声明。
三、架构:一个 Context + 一个 Portal 浮层
TutorialContext (React Context)
│
├── state: { isActive, currentStep, completed }
├── actions: startTutorial / skipTutorial / nextStep / prevStep
└── persist: localStorage ─ app_tutorial_completed
TutorialOverlay (React Portal → document.body)
│
├── 遮罩层 (semi-transparent overlay)
├── 镂空高亮 → 突出当前步骤目标元素
├── 浮动提示卡片 (工具提示 + 步骤导航)
└── 键盘事件 (← → Esc)
职责切分很直白:
- Context 管状态机:当前是否激活、走到第几步、是否已完成,以及四个动作。完成状态持久化到
localStorage(键app_tutorial_completed),纯前端实现,零后端改动——这是刻意的设计:引导完成状态是"用户偏好"而非"业务数据",不值得为它建表、加接口、过权限。 - Overlay 管渲染:通过 React Portal 挂到
document.body,脱离业务组件树的 z-index 和 overflow 上下文,负责遮罩、镂空高亮、提示卡片和键盘事件。
文件清单:
| 文件 | 职责 |
|---|---|
src/lib/tutorial-steps.ts |
步骤数组定义(数据,无逻辑) |
src/contexts/TutorialContext.tsx |
状态管理 + localStorage + 路由同步 |
src/components/TutorialOverlay.tsx |
遮罩渲染 + 镂空高亮 + 提示卡片 |
src/components/TopNav.tsx |
右侧加 ? 触发按钮(手动重进入口) |
src/App.tsx |
<TutorialProvider> 包裹路由 |
四、步骤定义:数据驱动,共 5 步
步骤不是写死在组件里的 JSX,而是一个纯数据数组:
interface TutorialStep {
path: string // 需要导航到的路由
selector: string // CSS 选择器,定位要 highlight 的 DOM 元素
title: string // 步骤标题
description: string // 步骤描述
placement?: 'bottom' | 'top' | 'left' | 'right' // 提示卡片相对目标的位置
}
5 个步骤覆盖平台的核心功能闭环:
| # | 目标页面 | 高亮元素 | 标题 | 讲什么 |
|---|---|---|---|---|
| 0 | / |
KPI 统计卡片 | 算力中枢 | 首页俯瞰全局:Agent、知识库、文档、反馈数量及满意率,点击卡片可跳转对应模块 |
| 1 | / |
系统日志流面板 | 实时监控 | 所有业务事件实时展示,每条日志标注操作用户和关联 Agent |
| 2 | /agents |
Agent 列表区域 | 智能体 | 创建和配置 AI Agent:选择模型、设定系统提示词、挂载知识库、激活工具 |
| 3 | /knowledge |
知识库列表区域 | 知识库 | 上传文档构建企业知识库,Agent 推理时自动检索相关内容 |
| 4 | /reasoning |
推理对话区 | 对话推理 | 选择 Agent 发起对话,SSE 流式输出响应,支持 Markdown 渲染 |
注意步骤 0→1 在同页、1→2 开始跨页——数据驱动的设计让"步骤"只是配置,调整顺序、增删步骤、改文案都不碰逻辑代码。
五、关键实现细节
5.1 高亮镂空:clip-path 挖洞 vs box-shadow 描边
这是最费思量的一处。第一直觉是用 clip-path 在全屏遮罩上挖一个矩形洞:
const rect = document.querySelector(step.selector)?.getBoundingClientRect()
// clip-path: 用多边形绕着目标矩形画一圈
const pad = 4 // 四周留白
const clip = `polygon(
0% 0%, 0% 100%, ${rect.left - pad}px 100%, ${rect.left - pad}px ${rect.top - pad}px,
${rect.right + pad}px ${rect.top - pad}px, ${rect.right + pad}px ${rect.bottom + pad}px,
${rect.left - pad}px ${rect.bottom + pad}px, ${rect.left - pad}px 0%, 0% 0%
)`;
可行,但坐标计算啰嗦,且对 clip-path 的调试极不友好。落地时换了一个简单得多的方案:
一个定位在目标元素上方的透明 div,加
box-shadow: 0 0 0 9999px rgba(0,0,0,0.7)。
box-shadow 向四周无限扩散形成遮罩,div 本身的区域天然就是"洞"。不需要多边形数学,不需要处理 clip-path 的边界 case,洞的边缘再加一层发光边框(主题青色,box-shadow 模拟辉光)就齐活。同样的视觉效果,从十几行坐标计算缩到一行 CSS——这类"用渲染引擎的特性代替几何计算"的思路在前端屡试不爽。
另一个细节:目标元素可能不在视口内(比如列表太长),高亮前先 scrollIntoView({ block: 'center' }),再取 getBoundingClientRect(),顺序不能反。
5.2 跨路由步骤切换
步骤的目标元素分布在不同页面,nextStep / prevStep 时发现 step.path !== location.pathname 就要先跳转:
if (step.path !== location.pathname) {
navigate(step.path)
// useEffect 中检测路由就绪后重新渲染高亮
// 时机由 location.pathname === step.path 这个条件保证
}
最初的设计是"跳转后固定延迟 200ms 等 DOM 渲染",落地时改成了条件驱动而非定时器驱动:Overlay 的渲染 effect 依赖 location.pathname,只有当前路由与步骤 path 一致时才去 querySelector 定位目标。固定延迟在慢机器上会闪、在快机器上浪费等待;以路由状态作为就绪信号,时序永远正确。
5.3 触发时机
| 触发条件 | 行为 |
|---|---|
| 首次登录且完成空间选择 | 自动弹出(localStorage 无完成标记) |
点击 TopNav ? 按钮 |
手动重新进入 |
| 点击"跳过" | 写完成标记,关闭 |
按 Esc |
同跳过 |
| 走完最后一步点"完成" | 写完成标记,关闭 |
自动弹出的判断放在 Context 的 useEffect 里:
if (isAuthenticated && !localStorage.getItem('app_tutorial_completed')) {
startTutorial()
}
有个容易踩的坑:必须等认证流程完全结束(含空间选择)再弹。登录刚成功时平台还处于"未选工作空间"的中间态,此时页面是空间选择页,步骤里定义的选择器一个都匹配不到,高亮会全部落空。
5.4 提示卡片
- 定位:优先放目标元素下方,空间不足自动翻转到上方(
placement字段可手动覆盖); - 内容:步骤编号(
0/4)、标题、描述; - 导航:
← 上一步|下一步 →|跳过; - 步骤指示器:5 个小圆点,已完成绿色、当前青色;
- 键盘:
→下一步、←上一步、Esc跳过——监听挂在 Overlay 上,组件卸载时记得移除。
六、验证清单
设计文档里附的验证用例,覆盖了全部状态迁移,上线前手工过一遍:
- 清除
localStorage→ 登录选空间 → 教程自动弹出; - 依次走完 5 步 → 高亮位置准确、文案正确;
- 第 5 步完成 → 标记写入 localStorage → 刷新后不重复弹出;
- 点击 TopNav
?→ 可重新进入; - 按
Esc→ 教程关闭; - 步骤间自动跳转路由正常(重点验证 1→2、3→4 这两个跨页跳转)。
结语
整套系统三个核心文件、约三百行代码、零后端改动、零新增依赖。它再次印证了一个判断:很多"看起来该用库"的需求,真正的难点不在通用能力(高亮、气泡),而在与你自身技术栈的接缝处(路由时序、主题系统、认证状态机)。接缝处的代码反正要自己写,通用部分又简单到一行 box-shadow 就能解决——这时自研不是重复造轮子,是避免为轮子付过路费。