Files
photo-live/docs/plans/2026-09-20-P4b-悬浮组件与全端语言.md
浅念 22188ce3ee docs: p4b acceptance record
- P4b 验收记录(A11/A22 简化级/A23 组件/A12+T15 证据、偏差 16 项、
  P5 移交清单、测试统计 web 655 +66 / mobile 280 零漂移)
- 总纲 P4b 进度注记:P1-P4b 全部本地阶段完成,仅余 P5 真实环境接入
  (G5/G6 + D03/D05/D08/D09/D10/D13)
- P4b 计划勾选回填 + 完成头注

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 23:59:21 +08:00

94 lines
13 KiB
Markdown
Raw Permalink 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.
# P4b 传播组件 + 全端语言验收 实施计划
> **✅ 阶段已完成(2026-09-20/21,tag `p4b`,基线 17d09bb..HEAD)。** 完成状态的权威记录是 [docs/plans/progress/P4b-验收记录.md](./progress/P4b-验收记录.md);Task 1-7 全部交付、勾选已回填。至此 **P1-P4b 全部本地阶段完成,仅余 P5 真实环境接入**。偏差(计划内 11 + 计划外 5:embed_slot_members 建而未用、展示位权限 admin-only 简化、ETag 四元组、esbuild devDep、contracts 子路径导出、T15 审计发现 4 处漏译等)与 P5 移交(真实域名/CDN 预热/frame-ancestors CSP/展示位授权 UI 按需/轮询参数冻结)见验收记录。
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven with subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** 交付网站悬浮轮播组件:版本化 JS 加载器(data-slot 配置、防重复初始化、postMessage 白名单、不污染宿主)+ 展示位管理(后台绑定相册、configVersion 切换)+ 项目域内轮播 iframe 页(近期照片、旧响应丢弃、不可见暂停、空态)+ 展示位 cn→ov 内容同步;收口 T15 全端语言验收(A12)。
**Architecture:** `packages/embed-loader`(独立发布物,无依赖手写 JS,版本化路径 /embed/v1/loader.js 由 viewer 静态服务)+ 轮播页挂在 apps/viewer 路由 `/embed/:slotToken`(复用 ov 公开 API 与 i18n)+ 展示位为 cn 权威实体(embed_slots/embed_slot_members),经 slot-state 事件同步 ov(contracts 已预留 entityType);公开嵌入 API 由 ov 承接(观众域名、只读副本)。
**Tech Stack:** 既有 + 零新依赖(loader 手写 ES5+ JS;二维码已在 P4a 引入 admin)。
**对应验收:** A11(悬浮/自动更新/正确跳转/不破坏宿主)、A22(后台换绑不改官网、旧响应不串图、展示位权限)、A12/T15(四端语言全验)、A23 组件部分(不可见暂停请求)。
**依赖基线:** tag `p4a`(17d09bb)。
---
## Task 1: 契约扩展(嵌入域)
**Files:** Modify `packages/contracts/openapi.yaml`(+gen)、`src/constants.ts`
- [x] 新增:
- **管理端**(tag embed-slots):`GET /admin/embed-slots`(cursor;items: EmbedSlot{id, publicToken, name, enabled, albumId?, albumTitle?, configVersion, settings Json{position left|right, size sm|md, intervalMs 3000-30000, lang auto|zh-CN|en, collapsedDefault bool}, updatedAt});`POST /admin/embed-slots` {name, settings?} → 201;`PATCH /admin/embed-slots/{id}` {name?, enabled?, settings?, albumId?|null 绑定/解绑} → 200(albumId 变更 → configVersion+1;绑定校验相册管理权:操作者须为该相册 owner/admin 或 slot 授权成员——**简化决策:P4b 权限=仅公司 admin 可管理展示位**(需求"管理员统一管理展示位,创建者只操作被授权展示位"——被授权语义复杂度高,登记偏差:embed_slot_members 表建好但 P4b 不出授权 UI/API,P5/后续按需补));`DELETE` 不做(软性 enabled=false 即下线)
- **公开端**(ov):`GET /public/embed-slots/{token}?lang=` → 200 {slotToken, configVersion, album: {publicToken, title(resolved), defaultLocale}|null, photos: [{photoId, thumb{url,expiresAt}, width, height}](近期已发布 ≤20,按 publishedAt DESC)};404 NOT_FOUND(不存在/停用/未绑定相册→同样 404 防探测,description 说明);**ETag/If-None-Match 支持**(ETag = `"{configVersion}-{photoCount}-{maxPublishedAtMs}-{maxPhotoId}"`——评审修正:含 photoCount 与 publishedAt,新照片发布必变;纯 max entityVersion 会因新照片小版本号而 304 陈旧);304 支持——省流量,A23
- [x] constants: EMBED_* 轮询参数(EMBED_POLL_INTERVAL_MS 默认 30000 env 可调)
- [x] gen + lint + 全回归绿;⏸ 快速确认点
- [x] Commit `feat(contracts): embed slots admin and public endpoints`
## Task 2: 展示位后端(cn 管理 + ov 公开 + 同步)
- [x] cn 迁移(校对手决):embed_slots(id, publicToken unique VarChar(32), name, enabled Bool default true, albumId?, configVersion Int default 1, settings Json, createdAt/updatedAt, entityVersion Int);embed_slot_members(slotId+userId unique, permission VarChar(32)——表建好 P4b 不用)
- [x] cn 管理端点(admin-only):CRUD per 契约;绑定/换绑相册事务:校验相册存在且操作者可管理(admin 恒真)→ albumId 更新 + configVersion+1 + entityVersion+1 + outbox `slot-state` 事件(payload: publicToken, enabled, albumId, albumPublicToken, configVersion, settings, entityVersion——ov 重建副本所需全量);settings/enabled/name 变更同样 bump + 事件
- [x] ov 副本:sync-inbox 应用 slot-state(albums_ov 语义:embed_slots_ov 表——ov 迁移:slotToken PK, enabled, albumPublicToken?, configVersion, settings Json, entityVersion, updatedAt;版本比较同照片规则;解绑=albumPublicToken null)
- [x] ov 公开端点:GET public embed-slots(读副本 + photos_ov 近期 20 + album 标题副本 resolve(lang 参数回退相册 defaultLocale——albums_ov.translations JSON)+ ETag/304);限速复用 visits 模式(slot token 维度)
- [x] e2e(services/api/test/embed.e2e-spec.ts + sync 侧):admin 建槽→绑相册→(flushSync)ov 公开返回照片 20 上限/lang 回退/ETag 304;换绑另一相册→configVersion+1→ov 反映新相册照片(A22 换绑不改官网=loader 只认 token);解绑→404;停用→404;非 admin 403;乱序/重复 slot-state 幂等(复用 harness)
- [x] Commit `feat(api): embed slots with ov replica and public feed`
## Task 3: embed-loader(packages/embed-loader)
- [x] `packages/embed-loader/src/loader.js`(手写 ~200 行,无构建或极简 esbuild 打包——决策:esbuild 已在依赖树(vite),加 devDep esbuild 打包+minify,产物 `dist/v1/loader.js` 提交入库):
- 扫描 `script[src*="/embed/v1/loader.js"]` 自身 data-*(data-slot/data-lang/data-position/data-interval 覆盖)→ 防重复(window.__photoliveEmbed loaded 标记 + 同 slot 去重)→ 每槽创建固定定位 iframe(right/left, 尺寸 per settings.size 映射 px,z-index 2147483000,box-shadow 圆角,sandbox=`"allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"`——**评审修正:必须含 allow-same-origin**,否则 iframe 源为 opaque "null",loader 的 origin 相等白名单会拒掉自己 iframe 的 resize/collapse 消息且 iframe 内 API 跨源需 ACAO *;嵌入 iframe 对宿主天然跨源,allow-same-origin 仅使其保有项目域源,白名单按「消息 origin === iframe contentWindow 的源」严格校验,src= `{EMBED_ORIGIN}/embed/{slot}?lang=...&pos=...&interval=...`——EMBED_ORIGIN 从 loader.js 自身 src 推导(document.currentScript.origin),支持 CDN 部署)
- **窄屏规则**(评审缺口:需求 4.8 移动端可用状态):viewport <480px → 强制 collapsedDefault=true + 尺寸降为 sm;Playwright 移动视口用例覆盖
- postMessage 白名单:监听 message(origin 必须等于 iframe origin)仅接受 {photoliveEmbed: true, type: resize|collapse|expand, ...}→ 调整 iframe 高度/收起态(收起=高度 0+展示小把手按钮 div——把手也在 loader 侧,点击 expand);其余消息忽略。iframe 内不发其他命令
- 收起记忆 sessionStorage per slot(会话内不反复弹出,需求 9);初始轮询失效上限(连续失败 N=5 次退避至 5×interval,参数 env 可调——评审建议)
- 不污染宿主:零全局(仅一个标记属性)、无样式注入到宿主元素(把手 div inline style)、动态卸载 API 可选(P4b 略)
- [x] 单测(node 环境模拟 DOM 太薄——用 Playwright 宿主页测试替代,包内 vitest 仅测 src 解析/参数合并纯函数)
- [x] viewer 静态服务:apps/viewer/public/embed/v1/loader.js(构建时从 packages/embed-loader/dist 拷贝——vite publicDir 静态放 public/ 即可,脚本 build 步骤 copy+校验 hash;dev 直接可访问)
- [x] Commit `feat(embed): versioned loader with postMessage whitelist`
## Task 4: 轮播 iframe 页(apps/viewer /embed/:slotToken)
- [x] 路由 /embed/:slotToken(无顶栏的独立精简布局):加载 GET public embed-slots(带当前 configVersion 内存值)→ 渲染轮播:近期照片队列(thumb 小尺寸)按 intervalMs 切换(淡入淡出),点击 → window.open(viewer /a/{albumToken}/p/{photoId}?lang=) 新标签(opener 隔离 noopener)
- [x] **旧响应丢弃**:每次请求带本地 configVersion,响应版本低或请求发起时版本已变 → 丢弃(竞态防护,A22 核心);轮询 EMBED_POLL_INTERVAL_MS(ETag 304 处理)拉新照片入队尾、旧淘汰;configVersion 变化 → 全量重置渲染
- [x] **不可见暂停**(A23 组件部分):document.visibilityState hidden → 停轮询停切换;恢复→立即校准一次;IntersectionObserver 自身不可见(被滚出视口)同样暂停
- [x] postMessage 上行:初始+照片变化 → resize 高度;collapse/expand 按钮 → 父窗口收起/展开;lang 参数(auto→相册 defaultLocale)
- [x] 空态:相册无照片→「照片即将上线」小卡;槽停用/网络错→静默隐藏自身(postMessage collapse 且不弹错误)
- [x] i18n 复用 viewer 词条(嵌入模式独立轻量 messages 子集)
- [x] vitest(~10:configVersion 丢弃逻辑/队列淘汰/暂停恢复/高度上报纯函数)+ Playwright(viewer e2e/embed.spec.ts):宿主页 fixture(test/host/embed-host.html 一次性静态页引入 loader script 指向 dev viewer)→ iframe 出现右侧→照片轮换(两次 src/opacity 断言)→点击开新页到 viewer 照片→宿主收起按钮→高度 0→sessionStorage 记忆→刷新仍收起;CSP 说明文档(宿主需允许 script-src/frame-src——README-embed.md)
- [x] Commit `feat(viewer): embed carousel iframe with visibility pause`
## Task 5: admin 展示位管理页
- [x] /system/embed-slots:列表(名称/绑定相册/enabled/configVersion/更新时间)+ 新建对话框(名称+默认设置)+ 编辑(设置表单:位置/尺寸/间隔/语言/默认收起)+ 绑定相册选择器(搜索自己管理的相册——admin 全量)+ 启停开关 + **嵌入代码卡**(`<script src="{EMBED_ORIGIN}/embed/v1/loader.js" data-slot="{token}" defer>` 复制;EMBED_ORIGIN env 配置)
- [x] vitest(~6:表单→payload、绑定变更提示 configVersion 将递增、代码卡拼接)+ Playwright(admin e2e:建槽→绑相册→嵌入代码出现→(经 ov API 断言公开数据)→换绑→configVersion+1)
- [x] Commit `feat(admin): embed slot management with code snippet`
## Task 6: T15 全端语言验收 + 漏译修复
- [x] 验收清单执行(脚本化+人工核对结合):
- viewer:词条完整性已有 arb 测试;**运行时抽查**(Playwright 遍历主要页面截图 zh/en 对比关键文案存在性——已有各 spec 覆盖,汇总核对)
- admin:模板 i18n zh/en 键数一致断言(新增 vitest 扫描 locale 文件键集合相等)+ P4a/P4b 新页面 zh/en 逐屏抽查(Playwright 切语言跑 admin 主旅程 en 版一遍)
- mobile:arb 完整性测试已有(280 内);语言切换 IT 腿已过(引用 P3 记录)
- embed:iframe lang 参数两语言渲染断言(Task 4 Playwright 内已含,汇总)
- **后端错误码文案映射表核对**(三端错误码→本地化键覆盖率断言脚本——api/admin/viewer 各自的映射文件遍历 ErrorCode 枚举全集,缺失即 fail(A12 主要提示无漏译的机器验证)
- [x] 发现漏译即修(预期少量);登记任何无法机器验证的残留(截图类)
- [x] Commit `feat(i18n): T15 full-stack language audit with coverage assertions`
## Task 7: 回归门 + P4b 验收记录
- [x] 全仓回归绿 + mobile 不降级;记录数字
- [x] `docs/plans/progress/P4b-验收记录.md`(A11/A22(**简化级:展示位权限 admin-only**,镜像 P2 A25 部分模式)/A23 组件/A12+T15 证据、偏差表[embed_slot_members 建而未用/权限简化 admin-only/ETag 四元组实现/esbuild devDep]、P5 移交[真实域名/CDN 预热/展示位授权 UI 按需/**embed 页 frame-ancestors CSP 配置**/初始轮询失效上限参数化])
- [x] 总纲 P4b 进度注记(P4b 完成 → **P1-P4b 全部完成,仅余 P5 真实环境接入**);`git tag p4b`;Commit `docs: p4b acceptance record`
---
## 执行注意
- 沿用全部既有约定(校对手决/gen 先行/reset 隔离/串行/偏差登记/trailer)。
- loader.js 是对外发布物:无 console.log、错误静默、≤5KB 目标;版本路径 /embed/v1/ 长期稳定(v2 才破坏性变更)。
- 轮播页加载的是**小尺寸**资源:ov 公开端点 photos 用 thumb(小图)——与 viewer 大图 browse 区分(省流量,需求 4.8)。
- 宿主测试页(embed-host.html)仅测试资产,不入 viewer 生产路由。
- postMessage 双向白名单是安全边界(技术方案 §9):origin 校验缺失=Critical 级审查项。