内部平台有个通病:功能越堆越多,新人进来面对满屏的入口不知道该先点哪里。传统解法是写用户手册——但说实话,没人读手册,手册的宿命是写完即过时。

我们在自己的内部 Agent 平台上选了另一条路:对标游戏新手教程,做一个交互式分步引导。页面元素高亮 + 浮动提示卡片 + 步骤导航,随时可跳过,随时可重进。最终形态是纯前端实现、三个核心文件、零后端改动。这篇文章记录它的设计和关键实现细节,包括一个很多人没想到的权衡:为什么不用 driver.js 这类现成库。

一、目标与形态

设计目标一句话:为首次登录的用户提供交互式分步指引,降低上手门槛,替代传统用户手册。

核心交互全部对齐游戏新手教程的体验标准:

  • 当前要讲的功能区域高亮,其余界面压暗;
  • 高亮区旁浮动一张提示卡片,带标题、描述、步骤导航;
  • 支持随时跳过键盘操作手动重新进入
  • 引导跨多个页面——步骤之间要能自动跳转路由

最后一条是内部平台和营销落地页的关键区别:引导不是单页面上高亮几个按钮,而是带着用户在真实页面间走一遍核心功能闭环。

二、为什么不用 driver.js / intro.js

这是立项时第一个被问的问题。driver.js、intro.js、shepherd.js 这类库做"元素高亮 + 提示气泡"已经很成熟,直接用似乎能省掉造轮子。我们评估后决定手写,理由按权重排序:

  1. 视觉风格必须深度定制。平台是深色赛博朋克主题(近黑底、青色/绿色辉光强调),遮罩颜色、高亮边缘的发光边框、提示卡片的边框透明度和等宽字体,全都要贴设计系统。第三方库的主题定制能力普遍是"改几个 CSS 变量"级别,改到我们需要的程度,覆写样式的工作量已经接近重写渲染层。
  2. 跨路由导航是一等需求。我们的引导步骤分布在 4 个不同路由下,步骤切换要先 navigate()、等目标页面 DOM 渲染完再计算高亮位置。这与 React Router 的渲染时序深度耦合,库提供的通用 hook 接不干净,最后还是要自己包一层路由同步逻辑——那索性整条链路自己写。
  3. 依赖面与体积。整个功能的渲染本质就是"一个定位 div + 一张卡片 + 几个事件监听",手写约三百行。为一个三百行能覆盖的需求引入一个第三方依赖(含其后续升级、安全公告、 breaking change),不划算。
  4. 没有高级需求。我们不需要库提供的复杂能力:多形状挖洞、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 上,组件卸载时记得移除。

六、验证清单

设计文档里附的验证用例,覆盖了全部状态迁移,上线前手工过一遍:

  1. 清除 localStorage → 登录选空间 → 教程自动弹出;
  2. 依次走完 5 步 → 高亮位置准确、文案正确;
  3. 第 5 步完成 → 标记写入 localStorage → 刷新后不重复弹出;
  4. 点击 TopNav ? → 可重新进入;
  5. Esc → 教程关闭;
  6. 步骤间自动跳转路由正常(重点验证 1→2、3→4 这两个跨页跳转)。

结语

整套系统三个核心文件、约三百行代码、零后端改动、零新增依赖。它再次印证了一个判断:很多"看起来该用库"的需求,真正的难点不在通用能力(高亮、气泡),而在与你自身技术栈的接缝处(路由时序、主题系统、认证状态机)。接缝处的代码反正要自己写,通用部分又简单到一行 box-shadow 就能解决——这时自研不是重复造轮子,是避免为轮子付过路费。