mcpField
T
tour-guide

tour-guide

github.com/rheinmir设计与 UI10258 次安装2 周前更新
代码仓库

关于 tour-guide

tour-guide 是面向 React/Next.js 应用的无依赖应用内引导技能,用聚光灯覆盖层同时标出一个路由的多个界面元素。它适合需要自定义入门提示的前端项目,所有位置通过稳定的 `data-tour` 属性配置。

主要功能

  1. 01无需导览库,仅依赖 React
  2. 02通过 `data-tour` 定位元素
  3. 03单个覆盖层展示多个标记点
  4. 04支持移动端与桌面布局
  5. 05点击覆盖层关闭并恢复滚动

适用场景

  1. 01为新用户标出搜索框和关键操作
  2. 02为不同屏幕布局配置导览位置
  3. 03为异步加载的列表延后启动导览

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/rheinmir/setup --skill tour-guide

安装后在 Claude Code 中输入/tour-guide确认已加载。

详细介绍

tour-guide — 聚焦式应用内导览(无需库)

此模式已在 bonbon DMS 中实际运行。一个组件 + data-tour 约定 + 每个路由一个 spots 数组。

是什么

用途和背景
  • 用途: 添加自编写的应用内产品导览(聚焦式引导浮层),无需任何库:添加暗色遮罩,围绕各个元素按 CSS 选择器挖出聚焦区域,显示标签工具提示,在最大空白处显示“点击即可关闭”胶囊提示,并适配移动端和桌面端。
  • 触发条件(何时使用): 用户想要“添加导览”“引导导览”“应用内指南”“聚焦导览”“产品导览”“操作演示”“bonbon 风格导览”,或调用 /tour-guide。适用于 React/Next.js + Tailwind。
  • 非目标: 不使用导览库(joyride/intro.js/driver.js);不是带有“下一步/上一步”的多步导览,而是显示当前路由的所有停靠点的单个浮层,点击任意位置即可关闭。
心智模型
  1. 用 data-tour="..." 标记需要高亮的元素(比 class 更稳定)。
  2. 使用 tourActive 状态 + 触发按钮(例如点击 logo)来开启/关闭。
  3. 每个路由声明一个 spots: TourSpot[](选择器 + 标签)并传入 <TourGuide>。
  4. 组件测量 DOM,绘制 SVG 暗色遮罩 + 聚焦镂空区 + 工具提示,点击任意位置即可关闭。
输入和输出约定
字段必填?含义
输入React/Next.js 应用(+ Tailwind)是放置组件的位置
输入每个路由的停靠点列表(selector + label,可选 labelSide/spotlightOnly/tooltipOnly)是spots: TourSpot[] 数组
输入6 个品牌颜色令牌 + dismissText否默认值位于 TOUR_THEME / prop 中
输出components/ui/tour-guide.tsx(或同等位置)是复制完整文件,除 React 外无其他依赖
输出元素上的 data-tour 属性 + tourActive 状态 + 触发按钮 + 每个路由中的 <TourGuide>是可开启/关闭的导览
规则和能力

原文没有单独的 Rules 部分——以下规则整合自“工作原理”、步骤 1–2 和注意事项(保留原意):

  • RULE-01(必须):不使用导览库——复制完整组件文件,除 React 外无其他依赖;只调整 TOUR_THEME 中的 6 个颜色令牌,并通过 prop 设置 dismissText。
  • RULE-02(必须):使用 data-tour,而不是 class——class 可能因重构/Tailwind 而变化;data-tour 更稳定。
  • RULE-03(必须):**锁定滚动:**激活时设置 document.body.style.overflow='hidden',关闭时记得恢复(已在清理逻辑中处理)。
  • RULE-04(必须):**点击关闭:**整个浮层设置 onClick={onClose};工具提示/提示内容为 pointer-events-none,因此不会阻止点击关闭。
  • RULE-05(建议):**延迟 300ms 测量:**等待布局/动画稳定后再测量。如果列表是异步加载的,请在数据就绪后打开导览(或调用重新测量)。
  • 能力:修改应用前端代码(添加组件、属性和状态);运行时测量 DOM。
失败边界
  • 选择器不匹配 / 元素被隐藏或位于屏幕外 → 自动跳过对应聚焦点(不会报错)——如果所有聚焦点都被跳过,导览将为空:失败,请检查 data-tour 或打开导览的时机。
  • 应用中存在 z-index 高于 z-[500] 的层 → 浮层会被遮挡:提高 z-index。
  • 应用不是 React → 需澄清:组件使用 TSX/React,需要移植。

如何使用

主要工作流
步骤类型输入操作输出/结束条件失败/下一步
W01执行应用步骤 1:创建 components/ui/tour-guide.tsx(复制完整文件),调整 TOUR_THEME组件—
W02执行需要高亮的元素步骤 2:添加 data-tour="..."稳定的选择器—
W03执行路由步骤 3:添加 tourActive 状态 + 触发按钮 + SPOTS 数组 + <TourGuide>导览在路由上运行两种布局形式 → B01
W04判断运行中的页面打开导览,检查移动端和桌面端的聚焦效果/工具提示/胶囊提示是否位于正确位置,点击任意位置均可关闭,滚动已恢复导览正常聚焦点缺失 → 检查注意事项

各步骤详情(W01–W04 的事实依据):

步骤 1 — 组件(复制完整文件)

创建 components/ui/tour-guide.tsx(或同等位置)。除 React 外无其他依赖。 调整 TOUR_THEME 中的 6 个颜色令牌以匹配品牌;通过 prop 修改 dismissText。

"use client";
import { useEffect, useState, useCallback } from "react";

/** Sửa 6 token này cho khớp brand của bạn. */
const TOUR_THEME = {
  scrim: "rgba(0,0,0,0.72)",                 // lớp tối
  ring: "rgba(255,255,255,0.2)",             // viền spotlight
  tipDesktop: "border border-sky-400 bg-white text-slate-900 shadow-[0_2px_8px_rgba(56,189,248,0.2)]",
  tipMobile: "bg-sky-100 text-slate-900",
  hintDesktop: "bg-slate-900 text-white",
  hintMobile: "bg-white/25 text-white backdrop-blur-sm",
};

export interface TourSpot {
  selector: string;                          // CSS selector phần tử (ưu tiên [data-tour='...'])
  label: string;                             // chữ tooltip ("" nếu chỉ muốn spotlight)
  labelSide?: "top" | "bottom" | "right";    // hướng đặt tooltip
  spotlightOnly?: boolean;                   // chỉ khoét spotlight, không tooltip
  tooltipOnly?: boolean;                     // chỉ tooltip, không khoét spotlight
}

interface MeasuredSpot {
  top: number; left: number; width: number; height: number;
  label: string; labelSide: "top" | "bottom" | "right";
  spotlightOnly?: boolean; tooltipOnly?: boolean;
}

function measure(spots: TourSpot[]): MeasuredSpot[] {
  return spots.flatMap(spot => {
    const el = document.querySelector(spot.selector);
    if (!el) return [];
    const r = el.getBoundingClientRect();
    if (r.width === 0 && r.height === 0) return [];
    if (r.top < -20 || r.top > window.innerHeight) return []; // off-screen → bỏ
    const vh = window.innerHeight;
    return [{
      top: r.top, left: r.left, width: r.width, height: r.height,
      label: spot.label,
      labelSide: spot.labelSide ?? (r.top > vh * 0.6 ? "top" : "bottom"),
      spotlightOnly: spot.spotlightOnly,
      tooltipOnly: spot.tooltipOnly,
    }];
  });
}

export function TourGuide({
  active, onClose, spots, dismissText = "Tap anywhere to close",
}: {
  active: boolean; onClose: () => void; spots: TourSpot[]; dismissText?: string;
}) {
  const [maskId] = useState(() => `tm-${Math.random().toString(36).slice(2, 8)}`);
  const [rects, setRects] = useState<MeasuredSpot[]>([]);

  const remeasure = useCallback(() => { if (active) setRects(measure(spots)); }, [active, spots]);

  useEffect(() => {
    if (!active) { setRects([]); document.body.style.overflow = ""; return; }
    window.scrollTo({ top: 0, behavior: "instant" });
    document.body.style.overflow = "hidden";
    const id = setTimeout(() => setRects(measure(spots)), 300) as unknown as number;
    window.addEventListener("resize", remeasure);
    return () => {
      clearTimeout(id);
      window.removeEventListener("resize", remeasure);
      document.body.style.overflow = "";
    };
  }, [active, remeasure]);

  if (!active) return null;

  const PAD = 8;
  const vw = window.innerWidth;
  const vh = window.innerHeight;
  const isDesktop = vw >= 768;

  return (
    <div className="fixed inset-0 z-[500]" onClick={onClose}>
      <svg className="pointer-events-none absolute inset-0" width={vw} height={vh}>
        <defs>
          <mask id={maskId}>
            <rect width="100%" height="100%" fill="white" />
            {rects.filter(r => !r.tooltipOnly).map((r, i) => (
              <rect key={i} x={r.left - PAD} y={r.top - PAD}
                width={r.width + PAD * 2} height={r.height + PAD * 2} rx={12} fill="black" />
            ))}
          </mask>
        </defs>
        <rect width="100%" height="100%" fill={TOUR_THEME.scrim} mask={`url(#${maskId})`} />
        {rects.filter(r => !r.tooltipOnly).map((r, i) => (
          <rect key={i} x={r.left - PAD} y={r.top - PAD}
            width={r.width + PAD * 2} height={r.height + PAD * 2} rx={12}
            fill="none" stroke={TOUR_THEME.ring} strokeWidth={1.5} />
        ))}
      </svg>

      {rects.filter(r => !r.spotlightOnly).map((r, i) => {
        const isTop = r.labelSide === "top";
        const isRight = r.labelSide === "right";
        const isTall = r.height > 50 && r.height <= 200;
        const tip = `whitespace-nowrap rounded-lg px-2.5 py-1 text-[10px] font-semibold shadow-md ${isDesktop ? TOUR_THEME.tipDesktop : TOUR_THEME.tipMobile}`;
        let tooltipTop: number;
        if (isRight) {
          const estWR = Math.min(r.label.length * 7 + 24, 220);
          tooltipTop = r.top + r.height / 2 - 14;
          const rightLeft = r.left + r.width + PAD + 6;
          return (
            <div key={i} className="pointer-events-none fixed z-[501]"
              style={{ top: tooltipTop, left: Math.min(rightLeft, vw - estWR - 8) }}>
              <div className={tip}>{r.label}</div>
            </div>
          );
        } else if (isTop) {
          tooltipTop = Math.max(8, r.top - 30);
        } else if (isTall) {
          tooltipTop = Math.min(r.top + r.height * 0.82, vh - 40);
        } else if (r.height > 200 && (!isDesktop || r.tooltipOnly)) {
          return null; // card mở rộng (mobile luôn ẩn; desktop chỉ ẩn khi tooltipOnly)
        } else {
          tooltipTop = Math.min(r.top + r.height + 6, vh - 40);
        }
        const estW = Math.min(r.label.length * 7 + 24, 220);
        const centerX = r.left + r.width / 2 - estW / 2;
        const tooltipLeft = Math.max(8, Math.min(centerX, vw - estW - 8));
        return (
          <div key={i} className="pointer-events-none fixed z-[501]"
            style={{ top: tooltipTop, left: tooltipLeft }}>
            <div className={tip}>{r.label}</div>
          </div>
        );
      })}

      {(() => {
        const PILL_H = 40, MARGIN = 16;
        const zones: [number, number][] = rects.map(r => [r.top - PAD - 36, r.top + r.height + PAD + 36]);
        zones.sort((a, b) => a[0] - b[0]);
        const merged: [number, number][] = [];
        for (const z of zones) {
          if (merged.length && z[0] <= merged[merged.length - 1][1]) {
            merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], z[1]);
          } else { merged.push([...z] as [number, number]); }
        }
        const gaps: { top: number; bot: number; size: number }[] = [];
        let prev = 80;
        for (const [zTop, zBot] of merged) {
          if (zTop - prev >= PILL_H + MARGIN * 2) gaps.push({ top: prev, bot: zTop, size: zTop - prev });
          prev = zBot;
        }
        if (vh - prev >= PILL_H + MARGIN * 2) gaps.push({ top: prev, bot: vh, size: vh - prev });
        const best = gaps.sort((a, b) => b.size - a.size)[0];
        if (!best || best.size < PILL_H + MARGIN * 2) return null;
        const pillTop = best.top + (best.size - PILL_H) / 2;
        return (
          <div className={`pointer-events-none fixed left-1/2 z-[501] -translate-x-1/2 rounded-full px-5 py-2.5 text-xs font-semibold shadow-lg ${isDesktop ? TOUR_THEME.hintDesktop : TOUR_THEME.hintMobile}`}
            style={{ top: pillTop }}>
            {dismissText}
          </div>
        );
      })()}
    </div>
  );
}
步骤 2 — 使用 data-tour 标记元素

为要高亮的元素添加稳定的属性:

<button data-tour="logo" onClick={() => setTourActive(true)}>…</button>
<input data-tour="search-input" … />
<div  data-tour="list-item" … />

使用 data-tour 而不是 class——class 常因重构或 Tailwind 而变化;data-tour 更稳定。

步骤 3 — 接入路由(状态 + 触发器 + spots)
import { TourGuide, type TourSpot } from "@/components/ui/tour-guide";
import { useMemo, useState } from "react";

const [tourActive, setTourActive] = useState(false);

const SPOTS = useMemo<TourSpot[]>(() => [
  { selector: "[data-tour='logo']",         label: "Mở hướng dẫn",       labelSide: "bottom" },
  { selector: "[data-tour='search-input']", label: "Tìm kiếm ở đây",     labelSide: "bottom" },
  { selector: "[data-tour='list-item']",    label: "Nhấn để xem chi tiết", labelSide: "bottom" },
  { selector: "[data-tour='action-approve']", label: "Duyệt",            labelSide: "top" },
], []);

// …trong JSX:
<button data-tour="logo" onClick={() => setTourActive(true)}>Hướng dẫn</button>
<TourGuide active={tourActive} onClose={() => setTourActive(false)} spots={SPOTS} dismissText="Nhấn bất kỳ đâu để đóng" />
分支
ID类型条件行为跳过 / 失败重新汇合
B01conditional_required移动端和桌面端布局不同声明 2 个 spots 数组(使用单独的 data-tour='desktop-*' 选择器),根据 window.innerWidth >= 768(或 useMediaQuery)选择混用 1 个数组也能运行,因为 measure() 会忽略隐藏元素W04
B02conditional_required列表异步加载数据加载完成后再打开 tour(或调用 remeasure)—W04
B03user_optional元素太大 / 只需要其中一种效果使用 tooltipOnly(不挖空)或 spotlightOnly + label: ""(不显示 tooltip)—W04
验证与停止条件

在实际运行的页面上,以两种宽度(< 768 和 ≥ 768)目视检查:每个 spot 的挖空和 tooltip 都对应正确元素,pill 位于空白区域,点击任意位置即可关闭,关闭后 body.style.overflow 会恢复。所有路由中声明的 spots 都能正确显示后即可停止。

示例
  • 正向示例: 订单列表页包含 logo、搜索框、订单行、审批按钮 → 在 4 处添加 data-tour,并按步骤 3 配置 SPOTS → 点击 logo 打开 tour:显示 4 个带标签的 spotlight,pill "点击任意位置关闭" 位于空白区域,点击后关闭,页面恢复滚动。
  • 边界情况 / 失败: 列表仍在 fetch 时就打开 tour → [data-tour='list-item'] 对应的 spot 尚未出现在 DOM 中,因此会被 measure() 忽略,tour 中缺少该点 → 数据加载完成后再打开 tour(B02)。
参考 — TourSpot
字段含义
selectorCSS 选择器(优先使用 [data-tour='x']);元素不存在或被隐藏时会自动跳过
labeltooltip 文本;"" + spotlightOnly = 只挖空高亮
labelSidetop / bottom / right(默认:根据位置自动选择)
spotlightOnly挖空并高亮,不显示 tooltip
tooltipOnly显示浮动 tooltip,不挖空(适用于元素太大或桌面端)
参考 — 移动端与桌面端

由于两种布局不同,声明 2 个 spots 数组(使用单独的 data-tour='desktop-*' 选择器),然后根据 window.innerWidth >= 768(或 useMediaQuery)选择。桌面端元素在移动端隐藏时会被 measure() 自动忽略,因此混用 1 个数组也能运行;但分开声明更方便调整标签。

参考 — 注意事项
  • z-index: overlay 为 z-[500],tooltip/hint 为 z-[501]。如果应用中有更高层级的元素,请相应调高。
  • 延迟测量 300ms: 等待布局或动画稳定后再测量。如果列表异步加载,请等数据加载完成后再打开 tour(或调用 remeasure)。
  • 锁定滚动: active 时设置 document.body.style.overflow='hidden',关闭时记得恢复(已在 cleanup 中处理)。
  • 点击关闭: 整个 overlay 设置 onClick={onClose};tooltip/hint 使用 pointer-events-none,因此不会拦截关闭点击。
  • 元素被隐藏: 屏幕外的 spot 会被自动排除——测量前应先滚动到顶部(已实现)。

来源

摘取并泛化自 bonbon DMS 的 components/dms/tour-guide.tsx(已在生产环境运行,支持移动端和桌面端)。

评论

支持 Markdown