Files
ai-kefu-jc/docs/superpowers/plans/2026-06-23-auto-invite-toggle-and-mobile-button.md
T
2026-09-29 14:15:12 +08:00

18 KiB
Raw Blame History

邀请卡自动弹出开关 + 移动端悬浮按钮 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 为嵌入式组件 widget.js 新增两个 data-* 配置开关——data-auto-invite(控制邀请卡是否 1.8s 自动弹出)与 data-mobile(切换到移动端竖排悬浮按钮 D2),默认行为 100% 向后兼容。

Architecture: 复用 widget.js 既有的 script.getAttribute("data-*") 配置范式。data-auto-invite 在 startInviteTimer() 开头加一句短路;data-mobile 走「独立移动端 DOM 模板」分支(桌面模板逐字不动),并配套一份贴角的位置表 positionStylesMobile 与移动端 CSS。

Tech Stack: 原生 JavaScript(ES5 风格 IIFE + Shadow DOM)、静态 HTML。无构建步骤、无打包器。

Spec: docs/superpowers/specs/2026-06-23-auto-invite-toggle-and-mobile-button-design.md


⚠️ 关于测试方式(重要)

本仓库为纯静态 JS 前端,没有任何 JS 测试框架(无 package.json,tests/ 只有空的 __init__.py)。为本改动引入 JS 测试框架违反 YAGNI。

因此本计划不使用自动化 TDD,而是采用手动浏览器验证:每个 Task 末尾用浏览器打开页面、通过 DevTools 检查实际 DOM / 行为。验证标准写明「期望看到的结果」,与自动化断言等价。

验证通用方法:

  • 方式 A(推荐,贴近真实部署):启动后端服务(bash deploy.sh 或 docker compose up),浏览器访问页面。
  • 方式 B(最快,免启动服务):直接用浏览器打开本地 HTML 文件(file://)。组件的 WebSocket 会连不上,但不影响本特性的验证——邀请卡弹出与按钮渲染都在初始化时完成,与 WS 无关。
  • 检查 Shadow DOM 内部:DevTools → Elements → 展开组件 Shadow Root,或在 Console 执行下文给出的命令。

文件结构(File Structure)

文件 职责 本次改动
static/widget.js 嵌入式组件(第三方站点通过 <script> 引入,渲染 Shadow DOM 聊天窗) 读取 2 个新属性 + 邀请卡短路 1 行 + 悬浮按钮分两套模板 + 新增移动端 CSS + 新增 positionStylesMobile
static/widget-test.html 嵌入式组件的本地测试/演示页 给组件 <script> 标签加 data-mobile="true"(演示 D2 按钮)
docs/test-checklist.md 手动测试清单 补充本次两个开关的验证条目

边界说明:本次只改 widget.js(嵌入式组件)。独立聊天页 chat.html / chat.js 不在范围内——独立页没有邀请卡逻辑,悬浮按钮也是 widget 独有概念(见 spec §1.3)。


Task 1: data-auto-invite —— 邀请卡自动弹出开关

Files:

  • Modify: static/widget.js(IIFE 顶部新增 autoInvite 读取)

  • Modify: static/widget.js(startInviteTimer() 开头新增短路)

  • Step 1: 新增 autoInvite 配置读取

在 static/widget.js 第 13 行 var email = ... 之后、第 14 行 var wsBase = new URL(script.src).origin; 之前,插入一行:

    var autoInvite = script.getAttribute("data-auto-invite") !== "false";

插入后该区域应为:

    var whatsapp = script.getAttribute("data-whatsapp") || "https://wa.me/+8619037179250";
    var email = script.getAttribute("data-email") || "mailto:info@borderlesscar.com";
    var autoInvite = script.getAttribute("data-auto-invite") !== "false";
    var wsBase = new URL(script.src).origin;

语义:属性缺失、空串或任何非 "false" 值 → autoInvite = true(保持现状自动弹);仅 data-auto-invite="false" → false。

  • Step 2: startInviteTimer() 开头加短路

定位 startInviteTimer 函数(约第 306 行),当前为:

    function startInviteTimer() {
        if (checkInviteStorage()) {
            return; // Already dismissed, don't show
        }

改为(在函数体最开头插入一行 if (!autoInvite) return;):

    function startInviteTimer() {
        if (!autoInvite) return; // 配置关闭则不启动计时器,邀请卡永不自动弹
        if (checkInviteStorage()) {
            return; // Already dismissed, don't show
        }

startInviteTimer 定义在 init() 内部(嵌套函数),autoInvite 在 IIFE 顶层(Step 1),闭包可访问,无需传参。

  • Step 3: 手动验证——默认值(向后兼容,邀请卡仍弹)

widget-test.html 此刻未加 data-auto-invite,等于「未提供」→ autoInvite=true。

打开 static/widget-test.html(方式 A 或 B),等待约 2 秒。 Expected:邀请卡在 ~1.8s 后自动弹出(橙色渐变 header、🤖 图标、两个 topic 按钮),与改动前一致。

  • Step 4: 手动验证——data-auto-invite="false" 不弹(临时)

临时编辑 static/widget-test.html:69 的组件 script 标签,加上 data-auto-invite="false":

  <script src="/static/widget.js" data-position="bottom-right" data-theme="light" data-whatsapp="https://wa.me/8613800138000" data-email="mailto:demo@hesu-auto.com" data-auto-invite="false"></script>

刷新页面,等待 5 秒以上。 Expected:邀请卡不再自动弹出;悬浮按钮照常显示,点击仍能打开聊天面板。

验证通过后,把 data-auto-invite="false" 从该标签删掉(恢复成 Step 3 的状态,保持测试页的邀请卡回归用途)。Task 3 不会重新加它。

  • Step 5: 提交
git add static/widget.js
git commit -m "$(cat <<'EOF'
feat(widget): 新增 data-auto-invite 控制邀请卡自动弹出

默认(true)保持 1.8s 自动弹出;设 data-auto-invite="false" 时
startInviteTimer 直接返回,邀请卡不再自动弹,悬浮按钮照常可用。

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 2: data-mobile —— 移动端竖排悬浮按钮(D2)+ 贴角位置

Files:

  • Modify: static/widget.js(IIFE 顶部新增 isMobile 读取 + positionStylesMobile + pos 选择)

  • Modify: static/widget.js(样式数组新增移动端 CSS)

  • Modify: static/widget.js(toggleBtn 改为按 isMobile 分两套模板)

  • Step 1: 新增 isMobile 配置读取

在 Task 1 新增的 var autoInvite = ... 之后、var wsBase = ... 之前,插入:

    var isMobile = script.getAttribute("data-mobile") === "true";

插入后该区域应为:

    var autoInvite = script.getAttribute("data-auto-invite") !== "false";
    var isMobile = script.getAttribute("data-mobile") === "true";
    var wsBase = new URL(script.src).origin;
  • Step 2: 新增 positionStylesMobile 并切换 pos

定位 positionStyles 定义(约第 16-21 行):

    var positionStyles = {
        "bottom-right": { bottom: "80px", right: "10px" },
        "bottom-left": { bottom: "20px", left: "20px" },
        "top-right": { top: "20px", right: "20px" },
        "top-left": { top: "20px", left: "20px" },
    };

在其下方紧接着新增一份移动端位置表:

    var positionStylesMobile = {
        "bottom-right": { bottom: "20px", right: "16px" },
        "bottom-left":  { bottom: "20px", left:  "16px" },
        "top-right":    { top:    "20px", right: "16px" },
        "top-left":     { top:    "20px", left:  "16px" },
    };

然后定位 pos 计算(约第 29 行,紧接 var c = colors[theme] || colors.light; 之后):

    var pos = positionStyles[position] || positionStyles["bottom-right"];

改为按 isMobile 选择表:

    var pos = (isMobile ? positionStylesMobile : positionStyles)[position]
           || (isMobile ? positionStylesMobile : positionStyles)["bottom-right"];

目的:移动端圆钮仅 56px,沿用桌面 bottom-right 的 bottom:80px 会离底边偏高;移动端改用贴角的 ~20px。桌面端 positionStyles 逐字不动。

  • Step 3: 样式数组新增移动端 CSS

定位样式数组中 .toggle-btn:hover 那一行(约第 47 行):

        ".toggle-btn:hover { transform:scale(1.1); }",

在其下方紧接着插入 5 行移动端按钮样式:

        ".toggle-btn.mobile { flex-direction:column; gap:5px; padding:0; background:transparent; box-shadow:none; border-radius:0; }",
        ".toggle-btn.mobile:hover { transform:scale(1.05); }",
        ".toggle-btn.mobile .mobile-circle { position:relative; width:56px; height:56px; border-radius:50%; background:#f08a31; display:flex; align-items:center; justify-content:center; font-size:28px; box-shadow:0 4px 16px rgba(240,138,49,0.45); }",
        ".toggle-btn.mobile .mobile-dot { position:absolute; bottom:2px; right:2px; width:13px; height:13px; background:#22c55e; border-radius:50%; border:2px solid #f08a31; }",
        ".toggle-btn.mobile .mobile-label { color:#8C4D1D; font-size:10px; font-weight:600; line-height:1.1; }",
  • Step 4: toggleBtn 改为按 isMobile 分两套模板

定位 toggleBtn 创建处(约第 233-235 行):

    var toggleBtn = document.createElement("button");
    toggleBtn.className = "toggle-btn";
    toggleBtn.innerHTML = `

改动一:把 className 改为带 mobile 修饰:

    toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : "");

改动二:把紧跟其后的单行 toggleBtn.innerHTML = \...桌面模板...`;改成if/else分支——在它**前面**插入移动端分支、把它本身包进else`。

具体:在 toggleBtn.className = ... 这一行之后、原 `toggleBtn.innerHTML = `` 这一行之前,插入:

    if (isMobile) {
        toggleBtn.innerHTML = `
  <div class="mobile-circle">🤖<span class="mobile-dot"></span></div>
  <span class="mobile-label">Chat with us</span>
`;
    } else {

然后在原桌面模板赋值的结束反引号 `; 之后,补上闭合的 }:

    }

改动后该区域结构应为(桌面模板内容逐字不变,仅被包进 else { ... }):

    var toggleBtn = document.createElement("button");
    toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : "");
    if (isMobile) {
        toggleBtn.innerHTML = `
  <div class="mobile-circle">🤖<span class="mobile-dot"></span></div>
  <span class="mobile-label">Chat with us</span>
`;
    } else {
        toggleBtn.innerHTML = `
  <div style="position:relative;">
    ... (现有桌面模板,原样不动) ...
  </div>
`;
    }

toggleBtn 仍是接收点击的元素,widget.js:367 的 toggleBtn.addEventListener("click", toggle) 无需改动,点击圆钮或文字均触发 toggle()。

  • Step 5: 手动验证——默认值(向后兼容,桌面款按钮不变)

widget-test.html 此刻未加 data-mobile → isMobile=false → 走桌面分支。

打开 static/widget-test.html(方式 A 或 B),DevTools Console 执行:

const host = document.querySelector('#cc-widget-host');
const btn = host.shadowRoot.querySelector('.toggle-btn');
console.log('class:', btn.className);
console.log('html:', btn.innerHTML.slice(0, 60));

Expected:

  • class: → toggle-btn(不含 mobile)
  • html: → 以桌面模板开头(含 position:relative 的 div)

视觉上:右下角是现有的宽药丸按钮(🤖 图标 + "Chat with us" / "Usually replies quickly" 两行文字),与改动前一致 → 向后兼容验证通过。

  • Step 6: 手动验证——data-mobile="true" 切到 D2(临时)

临时编辑 static/widget-test.html:69,给组件 script 标签加上 data-mobile="true"(保留 Step 1 Task 1 回退后去掉 data-auto-invite 的状态,即当前标签不含 auto-invite):

  <script src="/static/widget.js" data-position="bottom-right" data-theme="light" data-whatsapp="https://wa.me/8613800138000" data-email="mailto:demo@hesu-auto.com" data-mobile="true"></script>

刷新页面,DevTools Console 执行:

const host = document.querySelector('#cc-widget-host');
const btn = host.shadowRoot.querySelector('.toggle-btn');
const container = host.shadowRoot.querySelector('.widget-container');
console.log('class:', btn.className);
console.log('circle:', !!btn.querySelector('.mobile-circle'), 'label:', btn.querySelector('.mobile-label')?.textContent);
console.log('position bottom/right:', getComputedStyle(container).bottom, getComputedStyle(container).right);

Expected:

  • class: → toggle-btn mobile
  • circle: → true,label: → Chat with us
  • position bottom/right: → 20px / 16px(贴角,非 桌面的 80px)

视觉上:右下角是 D2 竖排按钮——56px 橙色圆钮 🤖 在上、"Chat with us" 文字外置在下(非橙色底),且更贴近屏幕右下角。点击圆钮或文字均能开关聊天面板。

验证通过后,保留 data-mobile="true" 不要回退——这正是 Task 3 要定稿的演示状态。

  • Step 7: 手动验证——data-position 其它取值不报错(临时)

把 Step 6 标签里的 data-position 分别临时改成 bottom-left、top-right、top-left,各刷新一次。 Expected:D2 按钮出现在对应角,位置贴角(~20px),无 JS 报错(DevTools Console 无红色)。验证完恢复 data-position="bottom-right"。

  • Step 8: 提交
git add static/widget.js
git commit -m "$(cat <<'EOF'
feat(widget): 新增 data-mobile 切换移动端竖排悬浮按钮(D2)

data-mobile="true" 时悬浮按钮改为竖排样式(56px 圆钮在上、"Chat with us"
文字外置在下),并切换到贴角位置表 positionStylesMobile。桌面款模板与
位置逻辑逐字不动,默认行为完全向后兼容。

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

Task 3: widget-test.html 演示属性 + 更新测试清单

Files:

  • Modify: static/widget-test.html:69(确认 data-mobile="true" 演示属性到位,Task 2 Step 6 已加)

  • Modify: docs/test-checklist.md(补充手动验证条目)

  • Step 1: 确认 widget-test.html 演示属性到位

确认 static/widget-test.html:69 已是(Task 2 Step 6 改成的):

  <script src="/static/widget.js" data-position="bottom-right" data-theme="light" data-whatsapp="https://wa.me/8613800138000" data-email="mailto:demo@hesu-auto.com" data-mobile="true"></script>

若之后被改动过,恢复成上面这样。

故意不加 data-auto-invite="false":widget-test.html 的核心用途就是测试邀请卡自动弹出(页面标题即 "Widget Invite Card Test Page"),加 false 会让邀请卡不弹、破坏其回归用途。data-auto-invite 的关闭路径靠临时编辑验证(见测试清单)。

  • Step 2: 更新测试清单文档

在 docs/test-checklist.md 末尾追加一节:

## 邀请卡自动弹出开关 + 移动端悬浮按钮(2026-06-23)

### data-auto-invite
- [ ] **默认(未传)**:组件邀请卡在 ~1.8s 后自动弹出,与历史一致。
- [ ] **`data-auto-invite="false"`**:邀请卡不再自动弹出;悬浮按钮照常显示,点击仍能打开聊天面板。
- [ ] **空值/非法值**:`data-auto-invite=""` 或 `data-auto-invite="0"` 等价于未传,仍自动弹。
- [ ] **与 data-mobile 独立**:`data-mobile="true" data-auto-invite="false"` 时竖排按钮 + 不弹邀请卡,互不影响。

### data-mobile
- [ ] **默认(未传)**:悬浮按钮为桌面款宽药丸(`toggle-btn`,不含 `mobile`),与历史一致。
- [ ] **`data-mobile="true"`**:悬浮按钮为 D2 竖排(`toggle-btn mobile`,含 `.mobile-circle` + `.mobile-label`="Chat with us"),点击圆钮或文字均能开关聊天面板。
- [ ] **移动端位置贴角**:`data-mobile="true"` + `data-position="bottom-right"` 时容器 `bottom/right` 为 `20px`/`16px`(非桌面 `80px`)。
- [ ] **位置覆盖各角**:`data-mobile="true"` 配合 `data-position` 取 `bottom-left`/`top-right`/`top-left`,按钮出现在对应角、贴角、无 JS 报错。
- [ ] **空值/非法值**:`data-mobile=""` 或 `data-mobile="yes"` 等价于未传,回退桌面款。
  • Step 3: 提交
git add static/widget-test.html docs/test-checklist.md
git commit -m "$(cat <<'EOF'
docs(test): widget-test 演示 data-mobile + 补充两个开关测试清单

Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"

完成标准(Definition of Done)

  • static/widget.js 读取 data-auto-invite 与 data-mobile 两个新属性;不传时默认行为与改动前完全一致(桌面款按钮 + 邀请卡自动弹)。
  • data-auto-invite="false" 时邀请卡不再自动弹出,悬浮按钮仍可用。
  • data-mobile="true" 时悬浮按钮为 D2 竖排样式,且贴角位置生效。
  • 桌面分支(按钮模板、positionStyles)逐字未改,grep 确认无意外改动。
  • 3 个 commit 已提交,每个自包含、可独立验证。
  • docs/test-checklist.md 已补充两个开关的手动验证条目。

风险与回滚

  • 风险点:悬浮按钮是所有嵌入共享的代码路径,Task 2 改动它时若手滑可能影响桌面款。规避:桌面模板逐字包进 else、positionStyles 不动;Task 2 Step 5 专门做桌面款回归验证。
  • 回滚:git revert 对应 commit 即可;未传新属性的部署本来就无任何行为变化(零破坏)。