From 8f2c21d25aae8d99ebb1107a69510d14e2cf4029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Sun, 20 Sep 2026 23:02:07 +0800 Subject: [PATCH] feat(viewer): embed carousel iframe with visibility pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P4b Task 4:宿主 loader 以 iframe 载入的轮播页(/embed/:slotToken,裸布局)。 - src/embed/feed.ts:纯函数集——A22 旧响应裁决(dispatch seq + configVersion 双闸:过期/回看丢弃、等值差分合并、升值全量重置)、照片队列差分(新照片 追加队尾、缺席淘汰、thumb 签名刷新)、interval/高度夹取、轮询退避; - src/embed/useEmbedFeed.ts:取数编排(If-None-Match → 304 零处理)、轮询 (查询 interval 覆盖 → EMBED_POLL_INTERVAL_MS,连续 5 失败 ×5 退避,成功 归零;404/410 静默 collapse 隐藏自身)、A23 不可见暂停(document 隐藏 + IntersectionObserver 滚出视口 + 本地收起;恢复立即校准)、轮播 intervalMs 切换;__embedPollMs e2e 注入点; - src/embed/postMessage.ts:上行 resize/collapse(referrer origin 收敛,缺失 退 '*'——载荷无敏感数据,宿主侧 loader source+origin 双校验)、下行 expand (event.source === window.parent 白名单); - EmbedPage.vue:交叉淡入淡出(仅 thumb 小图省流量)、点击 window.open 单图 深链(noopener)、计数圆点、「照片即将上线」空态卡、×收起;lang 查询 (auto→相册 defaultLocale)驱动嵌入词条子集; - api client:fetchPublicEmbedSlot(304/ETag 分支,不走 apiRequest); - e2e:宿主夹具页 public/__test__/embed-host.html(仅测试资产)+ embed.spec 7 用例(轮换/新标签深链/收起记忆/窄屏 sm/轮询增量/换绑重置/空态/幽灵槽); helpers 增展示位 admin API 种子 + flushOutbox; - vitest 18 用例(裁决矩阵/队列差分/夹取/退避/304/可见性状态机)。 配套修正:api CORS exposedHeaders ETag(跨源浏览器可读,304 语义成立); contracts 新增浏览器安全子路径 ./constants(根出口 re-export internal-sync 的 node:crypto 会打进浏览器包);eslint ignore embed-loader 产物拷贝 (Task 3 起 viewer lint 红,先修)。 Co-Authored-By: Claude Code --- apps/viewer/e2e/embed.spec.ts | 226 +++++++++++++ apps/viewer/e2e/helpers.ts | 84 ++++- apps/viewer/public/__test__/embed-host.html | 49 +++ apps/viewer/src/App.vue | 17 +- apps/viewer/src/__tests__/embed-feed.spec.ts | 339 +++++++++++++++++++ apps/viewer/src/api/client.ts | 60 ++++ apps/viewer/src/embed/feed.ts | 110 ++++++ apps/viewer/src/embed/postMessage.ts | 50 +++ apps/viewer/src/embed/useEmbedFeed.ts | 284 ++++++++++++++++ apps/viewer/src/i18n/locales/en.ts | 6 + apps/viewer/src/i18n/locales/zh-CN.ts | 6 + apps/viewer/src/pages/EmbedPage.vue | 189 +++++++++++ apps/viewer/src/router/index.ts | 4 + eslint.config.js | 3 + packages/contracts/package.json | 6 +- services/api/src/shared/configure-app.ts | 5 +- 16 files changed, 1433 insertions(+), 5 deletions(-) create mode 100644 apps/viewer/e2e/embed.spec.ts create mode 100644 apps/viewer/public/__test__/embed-host.html create mode 100644 apps/viewer/src/__tests__/embed-feed.spec.ts create mode 100644 apps/viewer/src/embed/feed.ts create mode 100644 apps/viewer/src/embed/postMessage.ts create mode 100644 apps/viewer/src/embed/useEmbedFeed.ts create mode 100644 apps/viewer/src/pages/EmbedPage.vue diff --git a/apps/viewer/e2e/embed.spec.ts b/apps/viewer/e2e/embed.spec.ts new file mode 100644 index 0000000..c7455e2 --- /dev/null +++ b/apps/viewer/e2e/embed.spec.ts @@ -0,0 +1,226 @@ +import { expect, test, type Page } from '@playwright/test'; +import { + cleanupSeededAlbums, + createEmbedSlot, + flushOutbox, + flushSync, + makePublicToken, + seedAlbum, + seedPhoto, + updateEmbedSlot, +} from './helpers'; + +// P4b Task 4 嵌入轮播组件 Playwright(真实后端:cn 8080 + ov 8081 + viewer dev +// 5175 由 playwright.config webServer 拉起或复用)。宿主夹具页 +// /__test__/embed-host.html(viewer publicDir 静态资产,仅测试用)引入真实 +// loader.js(/embed/v1/loader.js)→ iframe 载 /embed/{slot} → 走 ov 公开端点。 +// 展示位种子经 cn admin API 真实路径(seedAdmin + POST/PATCH),slot.updated +// 事件以 flushOutbox 排空(不随相册 bootstrap 走,见 helpers 注)。 +// 断言锚点:loader 侧 wrapper(iframe 父 div,inline style)/ 把手(div[role=button]); +// iframe 内 viewer 侧 CSS 类名。CSP:夹具页不设(宿主真实 CSP 要求见 +// packages/embed-loader/README-embed.md,此仅文档化)。 + +const MINUTE = 60_000; + +interface SeededFeed { + slotToken: string; + slotId: string; + albumToken: string; + photoIds: string[]; +} + +/** 相册(zh 默认 + en 双语)+ 3 张错峰发布照片 + 展示位绑定 + ov 投影。 */ +async function seedFeed(alias: string): Promise { + const album = await seedAlbum({ + alias, + defaultLocale: 'zh-CN', + translations: [ + { locale: 'zh-CN', title: `${alias} 中文相册`, description: '嵌入轮播' }, + { locale: 'en', title: `${alias} EN Album`, description: 'embed feed' }, + ], + }); + const base = Date.now() - 10 * MINUTE; + const photoIds = [ + await seedPhoto(album.id, `${alias}-p1`, { publishedAt: new Date(base - 2 * MINUTE) }), + await seedPhoto(album.id, `${alias}-p2`, { publishedAt: new Date(base - MINUTE) }), + await seedPhoto(album.id, `${alias}-p3`, { publishedAt: new Date(base) }), + ]; + await flushSync(album.id); + const slot = await createEmbedSlot(`viewer-e2e-${alias}`); + await updateEmbedSlot(slot.id, { albumId: album.id }); + await flushOutbox(); + return { slotToken: slot.token, slotId: slot.id, albumToken: album.publicToken, photoIds }; +} + +/** loader wrapper(iframe 父 div)inline height(px 字串;''=未置)。 */ +function wrapperHeight(page: Page): Promise { + return page.locator('iframe').evaluate((frame) => (frame.parentElement as HTMLElement).style.height); +} + +test.describe.serial('embed carousel widget (P4b Task 4)', () => { + test.afterAll(async () => { + await cleanupSeededAlbums(); + }); + + test('host page shows carousel, rotates photos and opens the viewer photo in a new tab', async ({ page }) => { + const feed = await seedFeed('main'); + + await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`); + const frame = page.frameLocator('iframe'); + + // lang=auto → 相册 defaultLocale(zh-CN)标题。 + await expect(frame.locator('.embed-title')).toHaveText('main 中文相册'); + // 轮播照片就位(3 张,thumb 为真实签名 URL)。 + await expect(frame.locator('.embed-photo img').first()).toBeVisible(); + await expect(frame.locator('.embed-photo')).toHaveCount(3); + await expect(frame.locator('.embed-dot')).toHaveCount(3); + + // intervalMs 切换:当前展示照片变化(首个为 publishedAt 最新 p3)。 + const firstSrc = await frame.locator('.embed-photo.active img').getAttribute('src'); + await expect + .poll(async () => frame.locator('.embed-photo.active img').getAttribute('src'), { timeout: 10_000 }) + .not.toBe(firstSrc); + + // 点击照片 → 新标签打开 viewer 单图深链(noopener;lang 跟随 resolvedLocale)。 + const popupPromise = page.context().waitForEvent('page'); + await frame.locator('.embed-photo.active').click(); + const popup = await popupPromise; + await expect(popup).toHaveURL(new RegExp(`/a/${feed.albumToken}/p/`)); + await expect(popup).toHaveURL(/lang=zh-CN/); + await popup.close(); + }); + + test('collapse button hides the widget, persists after reload, and the handle expands it', async ({ page }) => { + const feed = await seedFeed('collapse'); + + await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`); + const frame = page.frameLocator('iframe'); + await expect(frame.locator('.embed-title')).toHaveText('collapse 中文相册'); + + // iframe 内 × → postMessage collapse → loader wrapper 高度 0 + 把手出现 + 会话记忆。 + await frame.locator('.embed-close').click(); + await expect.poll(() => wrapperHeight(page)).toBe('0px'); + await expect(page.locator('div[role="button"]')).toBeVisible(); + await expect + .poll(() => page.evaluate((slot) => sessionStorage.getItem(`pl-embed-collapsed:${slot}`), feed.slotToken)) + .toBe('1'); + + // 刷新仍收起(会话内不反复弹出,需求 9)。 + await page.reload(); + await expect.poll(() => wrapperHeight(page)).toBe('0px'); + await expect(page.locator('div[role="button"]')).toBeVisible(); + + // 把手点击 → loader 展开回记忆高度,并向 iframe 发 expand(恢复轮询)。 + await page.locator('div[role="button"]').click(); + await expect + .poll(async () => (await wrapperHeight(page)) !== '0px') + .toBe(true); + await expect(frame.locator('.embed-title')).toHaveText('collapse 中文相册'); + }); + + test('narrow viewport starts collapsed and downgrades to sm size', async ({ page }) => { + const feed = await seedFeed('narrow'); + + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}`); + + // <480px:强制 collapsedDefault + sm(280px 宽,计划评审缺口:移动端可用)。 + await expect(page.locator('div[role="button"]')).toBeVisible(); + await expect.poll(() => wrapperHeight(page)).toBe('0px'); + const width = await page + .locator('iframe') + .evaluate((frame) => (frame.parentElement as HTMLElement).style.width); + expect(width).toBe('280px'); + + // 把手展开可用(移动端不因默认收起而不可达)。 + await page.locator('div[role="button"]').click(); + await expect + .poll(async () => (await wrapperHeight(page)) !== '0px') + .toBe(true); + }); + + test('newly published photo appears within the poll window', async ({ page }) => { + const album = await seedAlbum({ + alias: 'poll', + translations: [{ locale: 'zh-CN', title: '轮询增量相册' }], + }); + await seedPhoto(album.id, 'poll-p1', { publishedAt: new Date(Date.now() - MINUTE) }); + await flushSync(album.id); + const slot = await createEmbedSlot('viewer-e2e-poll'); + await updateEmbedSlot(slot.id, { albumId: album.id }); + await flushOutbox(); + + // ?poll=2000 → 夹具页向 iframe 注入 __embedPollMs(同源;轮询 2s)。 + await page.goto(`/__test__/embed-host.html?slot=${slot.token}&poll=2000`); + const frame = page.frameLocator('iframe'); + await expect(frame.locator('.embed-photo')).toHaveCount(1); + + // 新照片发布(真实管线同步 ov,ETag 必变 → 200 增量)→ 追加队尾(A11 数据路径)。 + await seedPhoto(album.id, 'poll-p2', { publishedAt: new Date() }); + await flushSync(album.id); + await expect + .poll(() => frame.locator('.embed-photo').count(), { timeout: 20_000 }) + .toBe(2); + }); + + test('rebinding to another album resets the feed (configVersion bump, A22)', async ({ page }) => { + const albumA = await seedAlbum({ + alias: 'rebind-a', + translations: [{ locale: 'zh-CN', title: '换绑前相册' }], + }); + await seedPhoto(albumA.id, 'rebind-a-p1', { publishedAt: new Date(Date.now() - 2 * MINUTE) }); + await seedPhoto(albumA.id, 'rebind-a-p2', { publishedAt: new Date(Date.now() - MINUTE) }); + const albumB = await seedAlbum({ + alias: 'rebind-b', + translations: [{ locale: 'zh-CN', title: '换绑后相册' }], + }); + await seedPhoto(albumB.id, 'rebind-b-p1', { publishedAt: new Date(Date.now()) }); + await flushSync(albumA.id); + await flushSync(albumB.id); + const slot = await createEmbedSlot('viewer-e2e-rebind'); + await updateEmbedSlot(slot.id, { albumId: albumA.id }); + await flushOutbox(); + + await page.goto(`/__test__/embed-host.html?slot=${slot.token}&poll=2000`); + const frame = page.frameLocator('iframe'); + await expect(frame.locator('.embed-title')).toHaveText('换绑前相册'); + await expect(frame.locator('.embed-photo')).toHaveCount(2); + + // 换绑:configVersion+1 → 轮询发现版本升 → 全量重置(新相册标题 + 新照片集, + // 旧相册照片不残留——A22「旧响应不串图」)。 + await updateEmbedSlot(slot.id, { albumId: albumB.id }); + await flushOutbox(); + await expect + .poll(async () => frame.locator('.embed-title').textContent(), { timeout: 20_000 }) + .toBe('换绑后相册'); + await expect + .poll(() => frame.locator('.embed-photo').count(), { timeout: 20_000 }) + .toBe(1); + }); + + test('album without photos shows the coming-soon card', async ({ page }) => { + const album = await seedAlbum({ + alias: 'emptyslot', + translations: [{ locale: 'zh-CN', title: '空相册展示位' }], + }); + await flushSync(album.id); + const slot = await createEmbedSlot('viewer-e2e-empty'); + await updateEmbedSlot(slot.id, { albumId: album.id }); + await flushOutbox(); + + await page.goto(`/__test__/embed-host.html?slot=${slot.token}`); + const frame = page.frameLocator('iframe'); + await expect(frame.locator('.embed-title')).toHaveText('空相册展示位'); + await expect(frame.locator('.embed-empty')).toHaveText('照片即将上线'); + }); + + test('ghost slot collapses silently with no error UI inside the iframe', async ({ page }) => { + await page.goto(`/__test__/embed-host.html?slot=${makePublicToken()}`); + + // 404(不存在/停用/未绑定同码防探测)→ postMessage collapse:wrapper 高度 0 + + // 把手出现;iframe 内无错误 UI(root 隐藏,不弹错误)。 + await expect.poll(() => wrapperHeight(page), { timeout: 15_000 }).toBe('0px'); + await expect(page.locator('div[role="button"]')).toBeVisible(); + await expect(page.frameLocator('iframe').locator('.embed-root')).toHaveCount(0); + }); +}); diff --git a/apps/viewer/e2e/helpers.ts b/apps/viewer/e2e/helpers.ts index 3a69a52..57966ab 100644 --- a/apps/viewer/e2e/helpers.ts +++ b/apps/viewer/e2e/helpers.ts @@ -19,6 +19,7 @@ import { signSyncBody, } from '@photo/contracts'; import { deliverPendingOnce } from '../../../services/sync/src/sender'; +import { seedAdmin } from '../../../services/api/src/scripts/seed-admin'; // 仓库根 .env 唯一来源(同 services/api test/load-env.ts 约定)。必须在 // PrismaClient / createStorageFromEnv 构造前执行——两者均在首次查询/调用时读 env。 @@ -235,13 +236,84 @@ export async function flushSync(albumId: string): Promise { `flushSync bootstrap 失败(${bootstrap.status}):${(await bootstrap.text()).slice(0, 300)}`, ); } + await flushOutbox(); +} + +/** 仅排空 outbox(P4b Task 4 embed e2e):slot.updated 事件不随相册 bootstrap + * 走(bootstrap 清单恒不含 slot-state),展示位创建/绑定的同步须单独排空。 */ +export async function flushOutbox(): Promise { await deliverPendingOnce({ prisma, ovApiUrl: process.env.OV_API_URL ?? 'http://127.0.0.1:8081', - internalKey, + internalKey: process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me', }); } +// ── P4b Task 4 展示位种子(经 cn admin API 真实路径,非直插行)── + +const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me'; + +const seededSlotIds: string[] = []; +let adminTokenPromise: Promise | null = null; + +/** seeded admin 幂等 + 登录(journey.spec 同款;会话进程内复用)。 */ +async function loginAdmin(): Promise { + if (adminTokenPromise === null) { + adminTokenPromise = (async () => { + await seedAdmin(prisma, ADMIN_ACCOUNT, ADMIN_PASSWORD); + const res = await fetch( + `${(process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '')}/v1/auth/password`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ account: ADMIN_ACCOUNT, password: ADMIN_PASSWORD }), + }, + ); + if (!res.ok) { + throw new Error(`embed seed 登录失败(${res.status})`); + } + return ((await res.json()) as { sessionToken: string }).sessionToken; + })(); + } + return adminTokenPromise; +} + +/** 经 cn admin API 创建展示位(未绑定态;settings 缺省),登记待清理。 */ +export async function createEmbedSlot(name: string): Promise<{ id: string; token: string }> { + const token = await loginAdmin(); + const res = await fetch( + `${(process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '')}/v1/admin/embed-slots`, + { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ name }), + }, + ); + if (!res.ok) { + throw new Error(`createEmbedSlot 失败(${res.status}):${(await res.text()).slice(0, 300)}`); + } + const body = (await res.json()) as { id: string; publicToken: string }; + seededSlotIds.push(body.id); + return { id: body.id, token: body.publicToken }; +} + +/** 经 cn admin API 更新展示位(绑定/换绑相册、启停、设置——body 直传契约形状)。 */ +export async function updateEmbedSlot(id: string, body: Record): Promise { + const token = await loginAdmin(); + const res = await fetch( + `${(process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '')}/v1/admin/embed-slots/${id}`, + { + method: 'PATCH', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + throw new Error(`updateEmbedSlot 失败(${res.status}):${(await res.text()).slice(0, 300)}`); + } +} + /** 模拟已分享深链的照片下线(Task 8 e2e):软删(visibility=deleted + * entityVersion 自增)——硬删会让 cn 行缺席、bootstrap 快照无从发墓碑,ov 副本 * 将永远留着旧发布行;软删 + flushSync 让 photo.deleted 事件走真实管线撤下。 @@ -260,6 +332,16 @@ export async function deleteSeededPhoto(photoId: string): Promise { * dev 桶内残余有界(同 api e2e 约定)。 */ export async function cleanupSeededAlbums(): Promise { try { + // 展示位种子(P4b):绑定态 slot.updated 行随相册圈定(albumId 在种子相册集) + // 已被下方 outbox 清理覆盖;未绑定创建行以 albumId='' 占位——仅清「未投递」 + // 残留(已投递行 inert;未投递残留会污染后续 deliverPendingOnce 排空判定)。 + if (seededSlotIds.length > 0) { + await prisma.outbox_events.deleteMany({ + where: { type: 'slot.updated', albumId: '', deliveredAt: null }, + }); + await prisma.embed_slots.deleteMany({ where: { id: { in: seededSlotIds } } }); + seededSlotIds.length = 0; + } if (seededAlbumIds.length > 0) { const photos = await prisma.photos.findMany({ where: { albumId: { in: seededAlbumIds } }, diff --git a/apps/viewer/public/__test__/embed-host.html b/apps/viewer/public/__test__/embed-host.html new file mode 100644 index 0000000..cd79023 --- /dev/null +++ b/apps/viewer/public/__test__/embed-host.html @@ -0,0 +1,49 @@ + + + + + + + embed host fixture + + +

Host page (embed e2e fixture)

+

宿主夹具页:loader 注入区。

+ + + diff --git a/apps/viewer/src/App.vue b/apps/viewer/src/App.vue index e45feb1..3111f28 100644 --- a/apps/viewer/src/App.vue +++ b/apps/viewer/src/App.vue @@ -1,11 +1,17 @@