# 邀请卡自动弹出开关 + 移动端悬浮按钮 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` | 嵌入式组件(第三方站点通过 ` ``` 刷新页面,等待 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 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 = `
🤖
Chat with us `; } else { ``` 然后在原桌面模板赋值的结束反引号 `` `; `` **之后**,补上闭合的 `}`: ```js } ``` 改动后该区域结构应为(桌面模板内容逐字不变,仅被包进 `else { ... }`): ```js var toggleBtn = document.createElement("button"); toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : ""); if (isMobile) { toggleBtn.innerHTML = `
🤖
Chat with us `; } else { toggleBtn.innerHTML = `
... (现有桌面模板,原样不动) ...
`; } ``` > `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 ``` 刷新页面,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 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 ``` 若之后被改动过,恢复成上面这样。 > 故意**不加** `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 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 即可;未传新属性的部署本来就无任何行为变化(零破坏)。