diff --git a/docs/plans/2026-09-20-P4b-悬浮组件与全端语言.md b/docs/plans/2026-09-20-P4b-悬浮组件与全端语言.md new file mode 100644 index 0000000..94c3ef6 --- /dev/null +++ b/docs/plans/2026-09-20-P4b-悬浮组件与全端语言.md @@ -0,0 +1,90 @@ +# P4b 传播组件 + 全端语言验收 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven with subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) 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` + +- [ ] 新增: + - **管理端**(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 支持**(configVersion+照片 max entityVersion 为 ETag;304 支持——省流量,A23) +- [ ] constants: EMBED_* 轮询参数(EMBED_POLL_INTERVAL_MS 默认 30000 env 可调) +- [ ] gen + lint + 全回归绿;⏸ 快速确认点 +- [ ] Commit `feat(contracts): embed slots admin and public endpoints` + +## Task 2: 展示位后端(cn 管理 + ov 公开 + 同步) + +- [ ] 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 不用) +- [ ] 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 + 事件 +- [ ] ov 副本:sync-inbox 应用 slot-state(albums_ov 语义:embed_slots_ov 表——ov 迁移:slotToken PK, enabled, albumPublicToken?, configVersion, settings Json, entityVersion, updatedAt;版本比较同照片规则;解绑=albumPublicToken null) +- [ ] ov 公开端点:GET public embed-slots(读副本 + photos_ov 近期 20 + album 标题副本 resolve(lang 参数回退相册 defaultLocale——albums_ov.translations JSON)+ ETag/304);限速复用 visits 模式(slot token 维度) +- [ ] 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) +- [ ] Commit `feat(api): embed slots with ov replica and public feed` + +## Task 3: embed-loader(packages/embed-loader) + +- [ ] `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-same-origin? NO——iframe 加载项目域需 scripts+popups:sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox",src= `{EMBED_ORIGIN}/embed/{slot}?lang=...&pos=...&interval=...`——EMBED_ORIGIN 从 loader.js 自身 src 推导(document.currentScript.origin),支持 CDN 部署) + - postMessage 白名单:监听 message(origin 必须等于 iframe origin)仅接受 {photoliveEmbed: true, type: resize|collapse|expand, ...}→ 调整 iframe 高度/收起态(收起=高度 0+展示小把手按钮 div——把手也在 loader 侧,点击 expand);其余消息忽略。iframe 内不发其他命令 + - 收起记忆 sessionStorage per slot(会话内不反复弹出,需求 9) + - 不污染宿主:零全局(仅一个标记属性)、无样式注入到宿主元素(把手 div inline style)、动态卸载 API 可选(P4b 略) +- [ ] 单测(node 环境模拟 DOM 太薄——用 Playwright 宿主页测试替代,包内 vitest 仅测 src 解析/参数合并纯函数) +- [ ] viewer 静态服务:apps/viewer/public/embed/v1/loader.js(构建时从 packages/embed-loader/dist 拷贝——vite publicDir 静态放 public/ 即可,脚本 build 步骤 copy+校验 hash;dev 直接可访问) +- [ ] Commit `feat(embed): versioned loader with postMessage whitelist` + +## Task 4: 轮播 iframe 页(apps/viewer /embed/:slotToken) + +- [ ] 路由 /embed/:slotToken(无顶栏的独立精简布局):加载 GET public embed-slots(带当前 configVersion 内存值)→ 渲染轮播:近期照片队列(thumb 小尺寸)按 intervalMs 切换(淡入淡出),点击 → window.open(viewer /a/{albumToken}/p/{photoId}?lang=) 新标签(opener 隔离 noopener) +- [ ] **旧响应丢弃**:每次请求带本地 configVersion,响应版本低或请求发起时版本已变 → 丢弃(竞态防护,A22 核心);轮询 EMBED_POLL_INTERVAL_MS(ETag 304 处理)拉新照片入队尾、旧淘汰;configVersion 变化 → 全量重置渲染 +- [ ] **不可见暂停**(A23 组件部分):document.visibilityState hidden → 停轮询停切换;恢复→立即校准一次;IntersectionObserver 自身不可见(被滚出视口)同样暂停 +- [ ] postMessage 上行:初始+照片变化 → resize 高度;collapse/expand 按钮 → 父窗口收起/展开;lang 参数(auto→相册 defaultLocale) +- [ ] 空态:相册无照片→「照片即将上线」小卡;槽停用/网络错→静默隐藏自身(postMessage collapse 且不弹错误) +- [ ] i18n 复用 viewer 词条(嵌入模式独立轻量 messages 子集) +- [ ] 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) +- [ ] Commit `feat(viewer): embed carousel iframe with visibility pause` + +## Task 5: admin 展示位管理页 + +- [ ] /system/embed-slots:列表(名称/绑定相册/enabled/configVersion/更新时间)+ 新建对话框(名称+默认设置)+ 编辑(设置表单:位置/尺寸/间隔/语言/默认收起)+ 绑定相册选择器(搜索自己管理的相册——admin 全量)+ 启停开关 + **嵌入代码卡**(`