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

366 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 邀请卡自动弹出开关 + 移动端悬浮按钮 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](../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;` 之前,插入一行:
```js
var autoInvite = script.getAttribute("data-auto-invite") !== "false";
```
插入后该区域应为:
```js
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 行),当前为:
```js
function startInviteTimer() {
if (checkInviteStorage()) {
return; // Already dismissed, don't show
}
```
改为(在函数体最开头插入一行 `if (!autoInvite) return;`):
```js
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"`:
```html
<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: 提交**
```bash
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 = ...` 之前,插入:
```js
var isMobile = script.getAttribute("data-mobile") === "true";
```
插入后该区域应为:
```js
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 行):
```js
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" },
};
```
在其**下方**紧接着新增一份移动端位置表:
```js
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;` 之后):
```js
var pos = positionStyles[position] || positionStyles["bottom-right"];
```
改为按 `isMobile` 选择表:
```js
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 行):
```js
".toggle-btn:hover { transform:scale(1.1); }",
```
在其**下方**紧接着插入 5 行移动端按钮样式:
```js
".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 行):
```js
var toggleBtn = document.createElement("button");
toggleBtn.className = "toggle-btn";
toggleBtn.innerHTML = `
```
**改动一**:把 className 改为带 mobile 修饰:
```js
toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : "");
```
**改动二**:把紧跟其后的单行 `toggleBtn.innerHTML = \`...桌面模板...\`;` 改成 `if/else` 分支——在它**前面**插入移动端分支、把它本身包进 `else`。
具体:在 `toggleBtn.className = ...` 这一行之后、原 `toggleBtn.innerHTML = \`` 这一行之前,插入:
```js
if (isMobile) {
toggleBtn.innerHTML = `
<div class="mobile-circle">🤖<span class="mobile-dot"></span></div>
<span class="mobile-label">Chat with us</span>
`;
} else {
```
然后在原桌面模板赋值的结束反引号 `` `; `` **之后**,补上闭合的 `}`:
```js
}
```
改动后该区域结构应为(桌面模板内容逐字不变,仅被包进 `else { ... }`):
```js
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](../../../static/widget.js#L367) 的 `toggleBtn.addEventListener("click", toggle)` 无需改动,点击圆钮或文字均触发 `toggle()`。
- [ ] **Step 5: 手动验证——默认值(向后兼容,桌面款按钮不变)**
`widget-test.html` 此刻未加 `data-mobile` → `isMobile=false` → 走桌面分支。
打开 `static/widget-test.html`(方式 A 或 B),DevTools Console 执行:
```js
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):
```html
<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 执行:
```js
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: 提交**
```bash
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 改成的):
```html
<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` 末尾追加一节:
```markdown
## 邀请卡自动弹出开关 + 移动端悬浮按钮(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: 提交**
```bash
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 即可;未传新属性的部署本来就无任何行为变化(零破坏)。