49c0e5559c
Co-Authored-By: Claude Code <noreply@anthropic.com>
366 lines
18 KiB
Markdown
366 lines
18 KiB
Markdown
# 邀请卡自动弹出开关 + 移动端悬浮按钮 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 即可;未传新属性的部署本来就无任何行为变化(零破坏)。
|