diff --git a/apps/viewer/e2e/embed-crossorigin.spec.ts b/apps/viewer/e2e/embed-crossorigin.spec.ts new file mode 100644 index 0000000..c0f0ce0 --- /dev/null +++ b/apps/viewer/e2e/embed-crossorigin.spec.ts @@ -0,0 +1,206 @@ +import { createServer, type Server } from 'node:http'; +import { expect, test, type Page } from '@playwright/test'; +import { + cleanupSeededAlbums, + createEmbedSlot, + flushOutbox, + flushSync, + makePublicToken, + seedAlbum, + seedPhoto, + updateEmbedSlot, +} from './helpers'; + +// UI-01 Task 6 跨源宿主 Playwright:宿主页与 viewer/iframe 真正不同源 +// (宿主 127.0.0.1:3999 ← 自建 node:http 静态页;loader/iframe/ov 均 5175/8081)。 +// 覆盖:跨源渲染、postMessage 双向白名单(非登记 iframe 来源的伪造消息被忽略)、 +// ghost 槽 destroy 无残留、同侧多槽纵排(8px 间距堆叠)、敌意宿主全局 CSS 下 +// 仍可用(loader 仅 inline style 零污染的逆向断言)、窄视口默认收起。 +// 加速取数不依赖 __embedPollMs(跨源不可写):本文件用例均为首屏行为,无需轮询。 + +const HOST_PORT = 3999; +const HOST_ORIGIN = `http://127.0.0.1:${HOST_PORT}`; +const VIEWER_ORIGIN = 'http://127.0.0.1:5175'; +const MINUTE = 60_000; + +interface SeededFeed { + slotToken: string; + slotId: string; +} + +async function seedFeed(alias: string): Promise { + const album = await seedAlbum({ + alias, + defaultLocale: 'zh-CN', + translations: [{ locale: 'zh-CN', title: `${alias} 中文相册` }], + }); + await seedPhoto(album.id, `${alias}-p1`, { publishedAt: new Date(Date.now() - 2 * MINUTE) }); + await seedPhoto(album.id, `${alias}-p2`, { publishedAt: new Date(Date.now() - MINUTE) }); + await flushSync(album.id); + const slot = await createEmbedSlot(`viewer-e2e-xorigin-${alias}`); + await updateEmbedSlot(slot.id, { albumId: album.id }); + await flushOutbox(); + return { slotToken: slot.token, slotId: slot.id }; +} + +/** 跨源宿主夹具页:inline HTML 由测试写入 currentHtml(每个用例自定义变体)。 */ +function hostHtml(options: { slots: string[]; hostileCss?: boolean }): string { + const tags = options.slots + .map((slot) => ``) + .join('\n '); + const hostile = options.hostileCss + ? `* { margin: 0 !important; padding: 0 !important; box-sizing: content-box !important; } + iframe { border: 10px solid red !important; background: #ff0 !important; }` + : ''; + return ` + + + + + cross-origin host fixture + ${hostile ? `` : ''} + + +

Host page (cross-origin, port ${HOST_PORT})

+ ${tags} + +`; +} + +let currentHtml = ''; +const server: Server = createServer((_req, res) => { + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.end(currentHtml); +}); + +async function openHost(page: Page, html: string): Promise { + currentHtml = html; + await page.goto(HOST_ORIGIN); +} + +/** wrapper(iframe 父 div)inline 样式属性值。 */ +function wrapperStyle(page: Page, prop: string): Promise { + return page.locator('iframe').evaluate( + (frame, p) => ((frame.parentElement as HTMLElement).style as unknown as Record)[p], + prop, + ); +} + +test.describe.serial('embed loader on a cross-origin host (UI-01 Task 6)', () => { + test.beforeAll(async () => { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(HOST_PORT, '127.0.0.1', () => resolve()); + }); + }); + + test.afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + await cleanupSeededAlbums(); + }); + + test('renders the widget from a cross-origin host page', async ({ page }) => { + const feed = await seedFeed('xmain'); + await openHost(page, hostHtml({ slots: [feed.slotToken] })); + + // iframe 以 title 定位(跨源 frameLocator 仍可穿透断言 DOM)。 + const frame = page.frameLocator('iframe[title="PhotoLive photos"]'); + await expect(frame.locator('.embed-title')).toHaveText('xmain 中文相册'); + await expect(frame.locator('.embed-photo.active img')).toBeVisible(); + + // loader 几何:fixed 贴右下(默认 offset 16/24),不依赖宿主样式。 + await expect.poll(() => wrapperStyle(page, 'position')).toBe('fixed'); + await expect.poll(() => wrapperStyle(page, 'right')).toBe('16px'); + await expect.poll(() => wrapperStyle(page, 'bottom')).toBe('24px'); + }); + + test('postMessages not originating from the registered iframe are ignored', async ({ page }) => { + const feed = await seedFeed('xmsg'); + await openHost(page, hostHtml({ slots: [feed.slotToken] })); + const frame = page.frameLocator('iframe[title="PhotoLive photos"]'); + await expect(frame.locator('.embed-title')).toHaveText('xmsg 中文相册'); + + // 宿主页脚本伪造 collapse/destroy(source=宿主 window ≠ 登记 iframe):白名单 + // 拒收——不收起、不销毁、不出现把手。 + await page.evaluate(() => { + window.postMessage({ photoliveEmbed: true, type: 'collapse' }, '*'); + window.postMessage({ photoliveEmbed: true, type: 'destroy' }, '*'); + window.postMessage({ photoliveEmbed: true, type: 'config', configVersion: 99, settings: {} }, '*'); + }); + await page.waitForTimeout(800); + + expect(await page.locator('iframe').count()).toBe(1); + expect(await page.locator('div[role="button"]').count()).toBe(0); + expect(await wrapperStyle(page, 'height')).not.toBe('0px'); + await expect(frame.locator('.embed-title')).toHaveText('xmsg 中文相册'); + }); + + test('ghost slot destroys the wrapper, handle and session key cross-origin', async ({ page }) => { + const ghost = makePublicToken(); + await openHost(page, hostHtml({ slots: [ghost] })); + + await expect + .poll(() => page.locator('iframe').count(), { timeout: 15_000 }) + .toBe(0); + await expect(page.locator('div[role="button"]')).toHaveCount(0); + expect(await page.evaluate((slot) => sessionStorage.getItem(`pl-embed-collapsed:${slot}`), ghost)).toBeNull(); + }); + + test('multiple slots on the same side stack vertically with an 8px gap', async ({ page }) => { + const feedA = await seedFeed('xstacka'); + const feedB = await seedFeed('xstackb'); + await openHost(page, hostHtml({ slots: [feedA.slotToken, feedB.slotToken] })); + + // 两个 iframe 各自渲染(src 定位到各自槽)。 + await expect( + page.frameLocator(`iframe[src*="${feedA.slotToken}"]`).locator('.embed-title'), + ).toHaveText('xstacka 中文相册'); + await expect( + page.frameLocator(`iframe[src*="${feedB.slotToken}"]`).locator('.embed-title'), + ).toHaveText('xstackb 中文相册'); + + // 纵排:首个槽 bottom=offsetBottom(24);后续槽 bottom = 24 + 首槽高 + 8。 + // 轮询至两个 wrapper 高度均已由 resize 消息落定且堆叠算式成立再读值复核。 + const readBoxes = () => + page.evaluate(() => + Array.from(document.querySelectorAll('iframe')).map((f) => { + const s = (f.parentElement as HTMLElement).style; + return { bottom: parseInt(s.bottom, 10), height: parseInt(s.height, 10) }; + }), + ); + await expect + .poll(async () => { + const boxes = await readBoxes(); + return boxes.length === 2 && boxes[1].bottom === 24 + boxes[0].height + 8; + }, { timeout: 15_000 }) + .toBe(true); + const boxes = await readBoxes(); + expect(boxes[0].bottom).toBe(24); + expect(boxes[0].height).toBeGreaterThan(0); + expect(boxes[1].bottom).toBe(24 + boxes[0].height + 8); + }); + + test('widget survives hostile host-global CSS (inline styles only)', async ({ page }) => { + const feed = await seedFeed('xcss'); + await openHost(page, hostHtml({ slots: [feed.slotToken], hostileCss: true })); + + // 敌意全局 reset(!*important* 覆盖、iframe 边框/底色强改)下:组件仍渲染、 + // 仍 fixed 定位在默认几何(loader 全 inline style,不依赖宿主 CSS 约定)。 + const frame = page.frameLocator('iframe[title="PhotoLive photos"]'); + await expect(frame.locator('.embed-title')).toHaveText('xcss 中文相册'); + await expect(frame.locator('.embed-photo.active img')).toBeVisible(); + await expect.poll(() => wrapperStyle(page, 'position')).toBe('fixed'); + await expect.poll(() => wrapperStyle(page, 'right')).toBe('16px'); + await expect.poll(() => wrapperStyle(page, 'bottom')).toBe('24px'); + }); + + test('narrow viewport starts collapsed with the handle on a cross-origin host', async ({ page }) => { + const feed = await seedFeed('xnarrow'); + await page.setViewportSize({ width: 375, height: 667 }); + await openHost(page, hostHtml({ slots: [feed.slotToken] })); + + await expect(page.locator('div[role="button"]')).toBeVisible(); + await expect.poll(() => wrapperStyle(page, 'height')).toBe('0px'); + await expect.poll(() => wrapperStyle(page, 'width')).toBe('280px'); + }); +}); diff --git a/apps/viewer/e2e/embed.spec.ts b/apps/viewer/e2e/embed.spec.ts index 700030b..4d77eb7 100644 --- a/apps/viewer/e2e/embed.spec.ts +++ b/apps/viewer/e2e/embed.spec.ts @@ -10,14 +10,15 @@ import { updateEmbedSlot, } from './helpers'; -// P4b Task 4 嵌入轮播组件 Playwright(真实后端:cn 8080 + ov 8081 + viewer dev +// UI-01 Task 6 嵌入轮播组件 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 要求见 +// iframe 内 viewer 侧 CSS 类名(.embed-carousel/.embed-stage/.embed-indicator 等, +// 见 src/pages/embed/*)。CSP:夹具页不设(宿主真实 CSP 要求见 // packages/embed-loader/README-embed.md,此仅文档化)。 const MINUTE = 60_000; @@ -52,12 +53,19 @@ async function seedFeed(alias: string): Promise { 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); +/** loader wrapper(iframe 父 div)inline 样式属性值(''=未置)。 */ +function wrapperStyle(page: Page, prop: string): Promise { + return page.locator('iframe').evaluate( + (frame, p) => ((frame.parentElement as HTMLElement).style as unknown as Record)[p], + prop, + ); } -test.describe.serial('embed carousel widget (P4b Task 4)', () => { +async function activeSrc(page: Page): Promise { + return page.frameLocator('iframe').locator('.embed-photo.active img').getAttribute('src'); +} + +test.describe.serial('embed carousel widget (UI-01 Task 6)', () => { test.afterAll(async () => { await cleanupSeededAlbums(); }); @@ -68,18 +76,14 @@ test.describe.serial('embed carousel widget (P4b Task 4)', () => { await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`); const frame = page.frameLocator('iframe'); - // lang=auto → 相册 defaultLocale(zh-CN)标题。 + // 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); + await expect(frame.locator('.embed-photo.active img')).toBeVisible(); + await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03'); // 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); + const firstSrc = await activeSrc(page); + await expect.poll(() => activeSrc(page), { timeout: 10_000 }).not.toBe(firstSrc); // 点击照片 → 新标签打开 viewer 单图深链(noopener;lang 跟随 resolvedLocale)。 const popupPromise = page.context().waitForEvent('page'); @@ -99,7 +103,7 @@ test.describe.serial('embed carousel widget (P4b Task 4)', () => { await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&lang=en`); const frame = page.frameLocator('iframe'); await expect(frame.locator('.embed-title')).toHaveText('langen EN Album'); - await expect(frame.locator('.embed-photo').first()).toBeVisible(); + await expect(frame.locator('.embed-photo.active img')).toBeVisible(); // 点击 → 新标签深链携带 lang=en(resolvedLocale)。 const popupPromise = page.context().waitForEvent('page'); @@ -119,7 +123,7 @@ test.describe.serial('embed carousel widget (P4b Task 4)', () => { // iframe 内 × → postMessage collapse → loader wrapper 高度 0 + 把手出现 + 会话记忆。 await frame.locator('.embed-close').click(); - await expect.poll(() => wrapperHeight(page)).toBe('0px'); + await expect.poll(() => wrapperStyle(page, 'height')).toBe('0px'); await expect(page.locator('div[role="button"]')).toBeVisible(); await expect .poll(() => page.evaluate((slot) => sessionStorage.getItem(`pl-embed-collapsed:${slot}`), feed.slotToken)) @@ -127,13 +131,13 @@ test.describe.serial('embed carousel widget (P4b Task 4)', () => { // 刷新仍收起(会话内不反复弹出,需求 9)。 await page.reload(); - await expect.poll(() => wrapperHeight(page)).toBe('0px'); + await expect.poll(() => wrapperStyle(page, 'height')).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') + .poll(async () => (await wrapperStyle(page, 'height')) !== '0px') .toBe(true); await expect(frame.locator('.embed-title')).toHaveText('collapse 中文相册'); }); @@ -146,16 +150,13 @@ test.describe.serial('embed carousel widget (P4b Task 4)', () => { // <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 expect.poll(() => wrapperStyle(page, 'height')).toBe('0px'); + await expect.poll(() => wrapperStyle(page, 'width')).toBe('280px'); // 把手展开可用(移动端不因默认收起而不可达)。 await page.locator('div[role="button"]').click(); await expect - .poll(async () => (await wrapperHeight(page)) !== '0px') + .poll(async () => (await wrapperStyle(page, 'height')) !== '0px') .toBe(true); }); @@ -173,14 +174,15 @@ test.describe.serial('embed carousel widget (P4b Task 4)', () => { // ?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); + await expect(frame.locator('.embed-indicator')).toHaveText('01 / 01'); - // 新照片发布(真实管线同步 ov,ETag 必变 → 200 增量)→ 追加队尾(A11 数据路径)。 + // 新照片发布(真实管线同步 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); + .poll(async () => (await frame.locator('.embed-indicator').textContent()) ?? '', { timeout: 20_000 }) + .toMatch(/02$/); }); test('rebinding to another album resets the feed (configVersion bump, A22)', async ({ page }) => { @@ -204,18 +206,18 @@ test.describe.serial('embed carousel widget (P4b Task 4)', () => { 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); + await expect(frame.locator('.embed-indicator')).toHaveText('01 / 02'); // 换绑:configVersion+1 → 轮询发现版本升 → 全量重置(新相册标题 + 新照片集, // 旧相册照片不残留——A22「旧响应不串图」)。 await updateEmbedSlot(slot.id, { albumId: albumB.id }); await flushOutbox(); await expect - .poll(async () => frame.locator('.embed-title').textContent(), { timeout: 20_000 }) + .poll(async () => (await frame.locator('.embed-title').textContent()) ?? '', { timeout: 20_000 }) .toBe('换绑后相册'); await expect - .poll(() => frame.locator('.embed-photo').count(), { timeout: 20_000 }) - .toBe(1); + .poll(async () => (await frame.locator('.embed-indicator').textContent()) ?? '', { timeout: 20_000 }) + .toBe('01 / 01'); }); test('album without photos shows the coming-soon card', async ({ page }) => { @@ -230,17 +232,151 @@ test.describe.serial('embed carousel widget (P4b Task 4)', () => { 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('照片即将上线'); + await expect(frame.locator('.embed-heading')).toHaveText('活动照片'); }); - test('ghost slot collapses silently with no error UI inside the iframe', async ({ page }) => { - await page.goto(`/__test__/embed-host.html?slot=${makePublicToken()}`); + test('ghost slot is destroyed with no residue and no error UI (404 anti-probe)', async ({ page }) => { + const ghost = makePublicToken(); + await page.goto(`/__test__/embed-host.html?slot=${ghost}`); - // 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); + // 404(不存在/停用/未绑定同码防探测)→ postMessage destroy:宿主移除 wrapper + // 与把手(不留无效按钮)、清会话键;iframe 内无错误 UI。 + await expect + .poll(() => page.locator('iframe').count(), { timeout: 15_000 }) + .toBe(0); + await expect(page.locator('div[role="button"]')).toHaveCount(0); + expect(await page.evaluate((slot) => sessionStorage.getItem(`pl-embed-collapsed:${slot}`), ghost)).toBeNull(); + }); + + // ── 暂停条件全集(§5.2:自动暂停离开即恢复;手动暂停不自动恢复)── + + test('footer pause button stops rotation; play resumes it', async ({ page }) => { + const feed = await seedFeed('pause'); + await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`); + const frame = page.frameLocator('iframe'); + await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03'); + const srcBefore = await activeSrc(page); + + await frame.getByRole('button', { name: '暂停轮播' }).click(); + await page.waitForTimeout(4600); + await expect(frame.locator('.embed-photo.active img')).toHaveAttribute('src', srcBefore ?? ''); + + await frame.getByRole('button', { name: '播放轮播' }).click(); + // 点击把键盘焦点留在按钮上——焦点暂停(§5.2)仍在生效属预期;点击宿主页 + // 移出焦点后自动恢复轮换。 + await page.mouse.click(5, 5); + await expect.poll(() => activeSrc(page), { timeout: 6_000 }).not.toBe(srcBefore); + }); + + test('pointer hover pauses rotation and leaving resumes it', async ({ page }) => { + const feed = await seedFeed('hover'); + await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`); + const frame = page.frameLocator('iframe'); + await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03'); + const srcBefore = await activeSrc(page); + + await frame.locator('.embed-carousel').hover(); + await page.waitForTimeout(4600); + await expect(frame.locator('.embed-photo.active img')).toHaveAttribute('src', srcBefore ?? ''); + + await page.mouse.move(0, 0); + await expect.poll(() => activeSrc(page), { timeout: 6_000 }).not.toBe(srcBefore); + }); + + test('keyboard focus inside the component pauses rotation', async ({ page }) => { + const feed = await seedFeed('focus'); + await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`); + const frame = page.frameLocator('iframe'); + await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03'); + const srcBefore = await activeSrc(page); + + await frame.locator('.embed-photo-open').focus(); + await page.waitForTimeout(4600); + await expect(frame.locator('.embed-photo.active img')).toHaveAttribute('src', srcBefore ?? ''); + + await frame.locator('.embed-photo-open').blur(); + await expect.poll(() => activeSrc(page), { timeout: 6_000 }).not.toBe(srcBefore); + }); + + test('hidden page pauses rotation and returning resumes it', async ({ page }) => { + const feed = await seedFeed('hidden'); + await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`); + const frame = page.frameLocator('iframe'); + await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03'); + const srcBefore = await activeSrc(page); + + // jsdom 式覆写在真实 Chromium 同样可用:defineProperty 覆写 document.hidden + // 后派发 visibilitychange(iframe 内 useEmbedFeed + EmbedCarousel 双订阅)。 + const child = page.frames().find((f) => f.url().includes('/embed/')); + expect(child).toBeDefined(); + await child!.evaluate(() => { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.waitForTimeout(4600); + await expect(frame.locator('.embed-photo.active img')).toHaveAttribute('src', srcBefore ?? ''); + + await child!.evaluate(() => { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => false }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await expect.poll(() => activeSrc(page), { timeout: 6_000 }).not.toBe(srcBefore); + }); + + // ── reduced motion(§5.2:默认暂停 + 手动切换;无过渡)── + + test('prefers-reduced-motion defaults to paused with manual prev/next', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + const feed = await seedFeed('motion'); + await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`); + const frame = page.frameLocator('iframe'); + await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03'); + + // 默认暂停:不自动切换(按钮态为「播放」);手动上一张/下一张可用。 + await expect(frame.getByRole('button', { name: '播放轮播' })).toBeVisible(); + await page.waitForTimeout(4600); + await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03'); + + await frame.getByRole('button', { name: '下一张' }).click(); + await expect(frame.locator('.embed-indicator')).toHaveText('02 / 03'); + await frame.getByRole('button', { name: '上一张' }).click(); + await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03'); + }); + + // ── 后台设置动态生效(§5.4:configVersion 升值 → config 消息 → loader 二次 + // 校验后应用非显式字段;iframe 内 imageFit 经响应 settings 生效)── + + test('admin settings changes apply live via the config message', async ({ page }) => { + const feed = await seedFeed('cfg'); + await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&poll=2000`); + const frame = page.frameLocator('iframe'); + await expect(frame.locator('.embed-indicator')).toHaveText('01 / 03'); + await expect.poll(() => wrapperStyle(page, 'right')).toBe('16px'); + + await updateEmbedSlot(feed.slotId, { + settings: { + position: 'right', + size: 'md', + intervalMs: 5000, + lang: 'auto', + collapsedDefault: false, + imageFit: 'cover', + offsetX: 60, + offsetBottom: 90, + zIndex: 2000, + }, + }); + await flushOutbox(); + + // loader 侧:wrapper 偏移/层级随 config 消息更新(无 data-* 显式覆盖时)。 + await expect.poll(() => wrapperStyle(page, 'right'), { timeout: 20_000 }).toBe('60px'); + await expect.poll(() => wrapperStyle(page, 'bottom')).toBe('90px'); + await expect.poll(() => wrapperStyle(page, 'zIndex')).toBe('2000'); + + // iframe 侧:imageFit=cover 应用到当前展示图(inline object-fit)。 + await expect + .poll(() => frame.locator('.embed-photo.active img').evaluate((el) => el.style.objectFit)) + .toBe('cover'); }); }); diff --git a/apps/viewer/public/__test__/embed-host.html b/apps/viewer/public/__test__/embed-host.html index cd79023..153d7d1 100644 --- a/apps/viewer/public/__test__/embed-host.html +++ b/apps/viewer/public/__test__/embed-host.html @@ -23,7 +23,7 @@ s.src = '/embed/v1/loader.js'; s.defer = true; s.dataset.slot = slot; - var map = { lang: 'lang', interval: 'interval', position: 'position' }; + var map = { lang: 'lang', interval: 'interval', position: 'position', poll: 'poll' }; for (var key in map) { var value = q.get(key); if (value) s.dataset[map[key]] = value; diff --git a/apps/viewer/public/embed/v1/loader.js b/apps/viewer/public/embed/v1/loader.js index c2a04fb..175d2c7 100644 --- a/apps/viewer/public/embed/v1/loader.js +++ b/apps/viewer/public/embed/v1/loader.js @@ -1 +1 @@ -(()=>{var N=["left","right"],H=["sm","md"],U=["auto","zh-CN","en"],p={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1},I={sm:280,md:340},_=3e3,O=3e4,R=60,V=640;function h(n,l,s){return n.indexOf(l)>=0?l:s}function D(n){let l=parseInt(n,10);return isNaN(l)?p.intervalMs:Math.min(O,Math.max(_,l))}function S(n,l){let s=n||{},u=typeof s.slot=="string"?s.slot.replace(/^\s+|\s+$/g,""):"",c=!!(l&&l.narrow);return{slot:u||null,lang:h(U,s.lang,p.lang),position:h(N,s.position,p.position),intervalMs:D(s.interval),size:c?"sm":h(H,s.size,p.size),collapsedDefault:c?!0:p.collapsedDefault}}function E(n,l,s){return n+"/embed/"+encodeURIComponent(l)+"?lang="+encodeURIComponent(s.lang)+"&pos="+encodeURIComponent(s.position)+"&interval="+String(s.intervalMs)}function M(n){if(n==null)return null;let l=Number(n);return isNaN(l)||!isFinite(l)?null:Math.min(V,Math.max(R,Math.round(l)))}(function(){"use strict";try{let x=function(e){try{return n.sessionStorage.getItem(l+e)==="1"}catch(t){return!1}},b=function(e,t){try{t?n.sessionStorage.setItem(l+e,"1"):n.sessionStorage.removeItem(l+e)}catch(i){}},A=function(){if(document.currentScript&&document.currentScript.src)return document.currentScript;var e=document.querySelectorAll('script[src*="/embed/v1/loader.js"]');return e.length?e[e.length-1]:null},g=function(e){try{return new URL(e.src,n.location.href).origin}catch(t){return null}},z=function(e){return e==="zh-CN"?"\u5C55\u5F00\u7167\u7247\u8F6E\u64AD":"Show photo carousel"},C=function(e){var t=document.createElement("div"),i=e.config.position==="left"?"left":"right";t.setAttribute("role","button"),t.setAttribute("tabindex","0"),t.setAttribute("aria-label",z(e.config.lang));var o=t.style;o.position="fixed",o.bottom="24px",o[i]="16px",o.width="44px",o.height="44px",o.borderRadius="50%",o.background="rgba(15,23,42,0.88)",o.boxShadow="0 2px 10px rgba(0,0,0,0.3)",o.zIndex=s,o.cursor="pointer",o.display="flex",o.alignItems="center",o.justifyContent="center",t.innerHTML='';function r(a){if(a.type==="keydown"){var d=a.key;if(d!=="Enter"&&d!==" "&&d!=="Spacebar")return;a.preventDefault()}w(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(f){}}return t.addEventListener("click",r),t.addEventListener("keydown",r),t},v=function(e){e.collapsed||(e.collapsed=!0,e.wrapper.style.height="0px",e.wrapper.style.boxShadow="none",e.handle||(e.handle=C(e),document.body.appendChild(e.handle)),e.handle.style.display="flex",b(e.config.slot,!0))},w=function(e){e.collapsed&&(e.collapsed=!1,e.wrapper.style.height=e.height+"px",e.wrapper.style.boxShadow="0 4px 16px rgba(0,0,0,0.18)",e.handle&&(e.handle.style.display="none"),b(e.config.slot,!1))},L=function(e,t){var i=document.createElement("div"),o=t.position==="left"?"left":"right",r=i.style;r.position="fixed",r.bottom="24px",r[o]="16px",r.width=I[t.size]+"px",r.height=u+"px",r.zIndex=s,r.borderRadius="12px",r.boxShadow="0 4px 16px rgba(0,0,0,0.18)",r.overflow="hidden";var a=document.createElement("iframe");a.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),a.setAttribute("src",E(e,t.slot,t)),a.setAttribute("referrerpolicy","no-referrer-when-downgrade"),a.setAttribute("title","PhotoLive photos");var d=a.style;d.width="100%",d.height="100%",d.border="0",d.display="block",i.appendChild(a),document.body.appendChild(i);var f={config:t,wrapper:i,frame:a,frameOrigin:g(a)||e,height:u,collapsed:!1,handle:null};c.push(f),(x(t.slot)||t.collapsedDefault)&&v(f)},y=function(){var e=A();if(e){var t=g(e);if(t){var i=!1;try{i=!!n.matchMedia&&n.matchMedia("(max-width: 479px)").matches}catch(d){i=!1}for(var o=document.querySelectorAll('script[src*="/embed/v1/loader.js"]'),r=0;r{var k=["left","right"],H=["sm","md"],L=["auto","zh-CN","en"],R=["contain","cover"],f={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1,imageFit:"contain",offsetX:16,offsetBottom:24,zIndex:1e3},F={sm:280,md:320},W=3e3,G=3e4,C=60,ce=640,Z=0,q=120,$=0,j=240,J=1,P=2147483e3,Q=["resize","collapse","expand","config","destroy"];function x(t,i,o){return t.indexOf(i)>=0?i:o}function m(t,i,o,s){let u=Number(t);return isNaN(u)||!isFinite(u)?s:Math.min(o,Math.max(i,Math.round(u)))}function c(t,i){let o=t[i];return o!=null&&o!==""}function ue(t){return m(t,W,G,f.intervalMs)}function Y(t,i){let o=t||{},s=typeof o.slot=="string"?o.slot.replace(/^\s+|\s+$/g,""):"",u=!!(i&&i.narrow);return{slot:s||null,lang:c(o,"lang")?x(L,o.lang,f.lang):f.lang,position:c(o,"position")?x(k,o.position,f.position):f.position,intervalMs:c(o,"interval")?ue(o.interval):f.intervalMs,size:u?"sm":c(o,"size")?x(H,o.size,f.size):f.size,collapsedDefault:f.collapsedDefault,imageFit:c(o,"imageFit")?x(R,o.imageFit,f.imageFit):f.imageFit,offsetX:c(o,"offsetX")?m(o.offsetX,Z,q,f.offsetX):f.offsetX,offsetBottom:c(o,"offsetBottom")?m(o.offsetBottom,$,j,f.offsetBottom):f.offsetBottom,zIndex:c(o,"zIndex")?m(o.zIndex,J,P,f.zIndex):f.zIndex,pollMs:c(o,"poll")?m(o.poll,250,6e4,null):null,narrow:u,explicit:{lang:c(o,"lang"),position:c(o,"position"),interval:c(o,"interval"),size:!u&&c(o,"size"),imageFit:c(o,"imageFit"),offsetX:c(o,"offsetX"),offsetBottom:c(o,"offsetBottom"),zIndex:c(o,"zIndex")}}}function K(t){return!t||typeof t!="object"?null:{position:x(k,t.position,f.position),size:x(H,t.size,f.size),intervalMs:m(t.intervalMs,W,G,f.intervalMs),lang:x(L,t.lang,f.lang),collapsedDefault:typeof t.collapsedDefault=="boolean"?t.collapsedDefault:f.collapsedDefault,imageFit:x(R,t.imageFit,f.imageFit),offsetX:m(t.offsetX,Z,q,f.offsetX),offsetBottom:m(t.offsetBottom,$,j,f.offsetBottom),zIndex:m(t.zIndex,J,P,f.zIndex)}}function ee(t,i){let o=K(i);if(o===null)return t;let s=t.explicit;return{slot:t.slot,lang:s.lang?t.lang:o.lang,position:s.position?t.position:o.position,intervalMs:s.interval?t.intervalMs:o.intervalMs,size:t.narrow?"sm":s.size?t.size:o.size,collapsedDefault:t.narrow?!0:o.collapsedDefault,imageFit:s.imageFit?t.imageFit:o.imageFit,offsetX:s.offsetX?t.offsetX:o.offsetX,offsetBottom:s.offsetBottom?t.offsetBottom:o.offsetBottom,zIndex:s.zIndex?t.zIndex:o.zIndex,pollMs:t.pollMs,narrow:t.narrow,explicit:s}}function te(t,i){if(!t||typeof t!="object")return null;let o=Number(t.configVersion);if(isNaN(o)||!isFinite(o))return null;let s=i==null?0:Number(i);if(o<=s)return null;let u=K(t.settings);return u===null?null:{configVersion:Math.round(o),settings:u}}function oe(t,i,o){let s=t+"/embed/"+encodeURIComponent(i)+"?lang="+encodeURIComponent(o.lang)+"&pos="+encodeURIComponent(o.position);return o.explicit.interval&&(s+="&interval="+String(o.intervalMs)),o.explicit.imageFit&&(s+="&fit="+encodeURIComponent(o.imageFit)),o.pollMs!==null&&o.pollMs!==void 0&&(s+="&poll="+String(o.pollMs)),s}function re(t){if(t==null)return null;let i=Number(t);return isNaN(i)||!isFinite(i)?null:Math.min(ce,Math.max(C,Math.round(i)))}(function(){"use strict";try{let B=function(e){try{return t.sessionStorage.getItem(i+e)==="1"}catch(r){return!1}},N=function(e,r){try{r?t.sessionStorage.setItem(i+e,"1"):t.sessionStorage.removeItem(i+e)}catch(n){}},S=function(e){try{return t.sessionStorage.getItem(i+e)!==null}catch(r){return!1}},ne=function(){if(document.currentScript&&document.currentScript.src)return document.currentScript;var e=document.querySelectorAll('script[src*="/embed/v1/loader.js"]');return e.length?e[e.length-1]:null},_=function(e){try{return new URL(e.src,t.location.href).origin}catch(r){return null}},ae=function(e){var r=e==="zh-CN";if(!r&&e==="auto")try{r=/^zh/i.test(t.navigator.language||"")}catch(n){r=!1}return r?"\u5C55\u5F00\u7167\u7247\u8F6E\u64AD":"Show photo carousel"},ie=function(e){var r=e==="zh-CN";if(!r&&e==="auto")try{r=/^zh/i.test(t.navigator.language||"")}catch(n){r=!1}return r?"\u7167\u7247":"Photos"},le=function(e){var r=document.createElement("div");r.setAttribute("role","button"),r.setAttribute("tabindex","0"),r.setAttribute("aria-label",ae(e.config.lang));var n=r.style;n.position="fixed",n.height=u+"px",n.minWidth=u+"px",n.boxSizing="border-box",n.padding="0 14px",n.borderRadius=u/2+"px",n.background="rgba(15,23,42,0.88)",n.boxShadow="0 2px 10px rgba(0,0,0,0.3)",n.zIndex=String(e.config.zIndex),n.cursor="pointer",n.display="flex",n.alignItems="center",n.justifyContent="center",n.gap="6px",n.font='500 13px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif',n.color="#ffffff",n.whiteSpace="nowrap",r.innerHTML=''+ie(e.config.lang)+"";function p(a){if(a.type==="keydown"){var l=a.key;if(l!=="Enter"&&l!==" "&&l!=="Spacebar")return;a.preventDefault()}e.userChoice=!0,E(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(d){}}return r.addEventListener("click",p),r.addEventListener("keydown",p),r},fe=function(e){return e.collapsed?u:e.height},I=function(e){var r=e.config.position==="left"?"left":"right",n=r==="left"?"right":"left",p=e.narrow?"sm":e.config.size,a=F[p]||F.md,l=t.innerWidth-e.config.offsetX-8;ll&&(a=l);var d=e.wrapper.style;if(d.width=a+"px",d.zIndex=String(e.config.zIndex),d[r]=e.config.offsetX+"px",d[n]="",e.handle){var h=e.handle.style;h.zIndex=String(e.config.zIndex),h[r]=e.config.offsetX+"px",h[n]=""}},T=function(){for(var e={left:[],right:[]},r=0;r0&&z+C>t.innerHeight&&A(y,!1),y.wrapper.style.bottom=z+"px",y.handle&&(y.handle.style.bottom=z+"px"),d=z}},v=function(){if(w!==null||typeof t.requestAnimationFrame!="function"){T();return}w=t.requestAnimationFrame(function(){w=null,T()})},A=function(e,r){e.collapsed||(e.collapsed=!0,e.wrapper.style.height="0px",e.wrapper.style.boxShadow="none",e.handle||(e.handle=le(e),document.body.appendChild(e.handle),I(e)),e.handle.style.display="flex",r!==!1&&N(e.config.slot,!0))},b=function(e,r){A(e,r),v()},E=function(e){e.collapsed&&(e.collapsed=!1,e.wrapper.style.height=e.height+"px",e.wrapper.style.boxShadow="0 4px 16px rgba(0,0,0,0.18)",e.handle&&(e.handle.style.display="none"),N(e.config.slot,!1),v())},se=function(e){if(!e.destroyed){e.destroyed=!0,e.handle&&e.handle.parentNode&&e.handle.parentNode.removeChild(e.handle),e.wrapper.parentNode&&e.wrapper.parentNode.removeChild(e.wrapper);try{t.sessionStorage.removeItem(i+e.config.slot)}catch(n){}var r=g.indexOf(e);r>=0&&g.splice(r,1),delete M[e.config.slot],g.length===0&&t.removeEventListener("message",O),v()}},pe=function(e,r,n){e.backendVersion=r,e.config=ee(e.config,n),I(e),!e.userChoice&&!S(e.config.slot)&&(e.config.collapsedDefault?b(e,!1):e.narrow||E(e)),v()},de=function(e,r){var n=document.createElement("div"),p=n.style;p.position="fixed",p.bottom=r.offsetBottom+"px",p.height=o+"px",p.borderRadius="12px",p.boxShadow="0 4px 16px rgba(0,0,0,0.18)",p.overflow="hidden";var a=document.createElement("iframe");a.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),a.setAttribute("src",oe(e,r.slot,r)),a.setAttribute("referrerpolicy","no-referrer-when-downgrade"),a.setAttribute("title","PhotoLive photos");var l=a.style;l.width="100%",l.height="100%",l.border="0",l.display="block",n.appendChild(a),document.body.appendChild(n);var d={config:r,wrapper:n,frame:a,frameOrigin:_(a)||e,height:o,collapsed:!1,narrow:r.narrow,handle:null,userChoice:!1,backendVersion:0,destroyed:!1};return g.push(d),I(d),S(r.slot)?B(r.slot)&&b(d,!1):d.narrow&&b(d,!1),d},O=function(e){try{var r=e.data;if(!r||r.photoliveEmbed!==!0)return;var n=r.type;if(Q.indexOf(n)<0)return;for(var p=0;p ({ + preloadEmbedImage: vi.fn(() => Promise.resolve()), +})); + +const mockedPreload = vi.mocked(preloadEmbedImage); + +function makePhoto(id: string): PublicEmbedPhoto { + return { + photoId: id, + thumb: { url: `https://img.test/${id}.jpg`, expiresAt: '2099-01-01T00:00:00.000Z' }, + width: 240, + height: 150, + }; +} + +const PHOTOS = [makePhoto('a'), makePhoto('b'), makePhoto('c')]; + +const mounted: VueWrapper[] = []; + +interface CarouselOptions { + photos?: PublicEmbedPhoto[]; + intervalMs?: number; + active?: boolean; + reducedMotion?: boolean; + albumUrl?: string | null; +} + +function mountCarousel(options: CarouselOptions = {}): VueWrapper { + const wrapper = mount(EmbedCarousel, { + props: { + photos: options.photos ?? PHOTOS, + intervalMs: options.intervalMs ?? 5000, + imageFit: 'contain', + active: options.active ?? true, + reducedMotion: options.reducedMotion ?? false, + albumUrl: options.albumUrl ?? 'https://viewer.test/a/tok?lang=zh-CN', + albumTitle: '测试相册', + }, + global: { plugins: [createViewerI18n('zh-CN')] }, + }); + mounted.push(wrapper); + return wrapper; +} + +/** 推进到下一帧已提交(decode 微任务 + 340ms 交叉淡入兜底定时器)。 */ +async function settle(wrapper: VueWrapper, ms = 0): Promise { + await vi.advanceTimersByTimeAsync(ms); + await wrapper.vm.$nextTick(); + await vi.advanceTimersByTimeAsync(400); + await wrapper.vm.$nextTick(); +} + +function currentSrc(wrapper: VueWrapper): string | null { + const img = wrapper.find('.embed-photo.active img').element as HTMLImageElement | undefined; + return img?.getAttribute('src') ?? null; +} + +function indicator(wrapper: VueWrapper): string { + return wrapper.get('.embed-indicator').text(); +} + +/** jsdom document.hidden 只读:defineProperty 覆写(用例后还原)。 */ +function setHidden(hidden: boolean): void { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden }); +} + +describe('EmbedCarousel switching orchestration', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockedPreload.mockReset(); + mockedPreload.mockImplementation(() => Promise.resolve()); + }); + + afterEach(() => { + while (mounted.length > 0) mounted.pop()?.unmount(); + vi.useRealTimers(); + setHidden(false); + }); + + it('advances one photo per intervalMs cycling the queue', async () => { + const wrapper = mountCarousel({ intervalMs: 3000 }); + await settle(wrapper, 0); + expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); + expect(indicator(wrapper)).toBe('01 / 03'); + + await settle(wrapper, 3000); + expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg'); + expect(indicator(wrapper)).toBe('02 / 03'); + + await settle(wrapper, 3000); + await settle(wrapper, 3000); + expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); // 循环 + }); + + it('pauses while the pointer hovers and resumes after leaving (auto pause only)', async () => { + const wrapper = mountCarousel({ intervalMs: 3000 }); + await settle(wrapper, 0); + + await wrapper.find('.embed-carousel').trigger('pointerenter'); + await settle(wrapper, 6000); + expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); // 悬停期间不切换 + + await wrapper.find('.embed-carousel').trigger('pointerleave'); + await settle(wrapper, 3000); + expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg'); // 离开自动恢复 + }); + + it('manual pause does not auto-resume; play toggles back', async () => { + const wrapper = mountCarousel({ intervalMs: 3000 }); + await settle(wrapper, 0); + + const pauseButton = wrapper.findAll('.embed-foot-btn').find((b) => b.attributes('aria-label') === '暂停轮播'); + expect(pauseButton).toBeDefined(); + await pauseButton!.trigger('click'); + await settle(wrapper, 9000); + expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); // 手动暂停不自动恢复 + + const playButton = wrapper.findAll('.embed-foot-btn').find((b) => b.attributes('aria-label') === '播放轮播'); + await playButton!.trigger('click'); + await settle(wrapper, 3000); + expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg'); + }); + + it('pauses while keyboard focus is inside the component', async () => { + const wrapper = mountCarousel({ intervalMs: 3000 }); + await settle(wrapper, 0); + + await wrapper.find('.embed-photo-open').trigger('focusin'); + await settle(wrapper, 9000); + expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); + + await wrapper.find('.embed-photo-open').trigger('focusout'); + await settle(wrapper, 3000); + expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg'); + }); + + it('pauses while the page is hidden and resumes on return', async () => { + const wrapper = mountCarousel({ intervalMs: 3000 }); + await settle(wrapper, 0); + + setHidden(true); + document.dispatchEvent(new Event('visibilitychange')); + await settle(wrapper, 9000); + expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); + + setHidden(false); + document.dispatchEvent(new Event('visibilitychange')); + await settle(wrapper, 3000); + expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg'); + }); + + it('pauses when inactive (collapsed/invisible) and resumes with active', async () => { + const wrapper = mountCarousel({ intervalMs: 3000, active: false }); + await settle(wrapper, 9000); + expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); + + await wrapper.setProps({ active: true }); + await settle(wrapper, 3000); + expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg'); + }); + + it('reduced motion: paused by default with manual prev/next navigation', async () => { + const wrapper = mountCarousel({ intervalMs: 3000, reducedMotion: true }); + await settle(wrapper, 9000); + expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); // 默认暂停 + + const next = wrapper.findAll('.embed-foot-btn').find((b) => b.attributes('aria-label') === '下一张'); + const prev = wrapper.findAll('.embed-foot-btn').find((b) => b.attributes('aria-label') === '上一张'); + expect(next).toBeDefined(); + expect(prev).toBeDefined(); + await next!.trigger('click'); + await settle(wrapper, 0); + expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg'); // 取消过渡:立即提交 + await prev!.trigger('click'); + await settle(wrapper, 0); + expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); + }); + + it('preloads only the next photo ahead (not the whole queue)', async () => { + const wrapper = mountCarousel({ intervalMs: 3000 }); + await settle(wrapper, 0); + const urls = (mockedPreload as Mock).mock.calls.map((call) => call[0]); + // 切换 a 的展示 + 预热 b;c 不请求(仅当前+下一张,§5.2)。 + expect(urls.filter((u) => u === 'https://img.test/a.jpg').length).toBeGreaterThan(0); + expect(urls).toContain('https://img.test/b.jpg'); + expect(urls).not.toContain('https://img.test/c.jpg'); + }); + + it('decode failure keeps the current photo and skips ahead; 3 consecutive failures pause then retry', async () => { + mockedPreload.mockImplementation((url: string) => + url === 'https://img.test/b.jpg' ? Promise.reject(new Error('decode failed')) : Promise.resolve(), + ); + // 双照片队列:b 持续失败 → 失败后跳回 a(幂等,不计新成功)→ 连续计满 3。 + const wrapper = mountCarousel({ photos: [makePhoto('a'), makePhoto('b')], intervalMs: 3000 }); + await settle(wrapper, 0); + expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); + expect(indicator(wrapper)).toBe('01 / 02'); + + // 失败 1:保留 a(跳回 a 无新展示,计数不清零)。 + await settle(wrapper, 3000); + expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); + // 失败 2、3 → 连续失败保护:暂停自动切换,保留当前帧。 + await settle(wrapper, 3000); + await settle(wrapper, 3000); + expect(currentSrc(wrapper)).toBe('https://img.test/a.jpg'); + + // b 恢复可用:t=12000 解除暂停 → interval 重启;15000 周期幂等重定向 a + // (触发暂停的失败不推进,索引停在 b)→ 18000 周期切 b、18340 提交。 + // settle 到 19200(>18340 且 < 21000 下一周期边界)。 + mockedPreload.mockImplementation(() => Promise.resolve()); + await settle(wrapper, 8200); + expect(currentSrc(wrapper)).toBe('https://img.test/b.jpg'); + expect(indicator(wrapper)).toBe('02 / 02'); + }); +}); + +describe('EmbedStage', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockedPreload.mockReset(); + mockedPreload.mockImplementation(() => Promise.resolve()); + }); + + afterEach(() => { + while (mounted.length > 0) mounted.pop()?.unmount(); + vi.useRealTimers(); + }); + + function mountStage(options: { photos?: PublicEmbedPhoto[]; index?: number; imageFit?: 'contain' | 'cover'; reducedMotion?: boolean } = {}): VueWrapper { + const wrapper = mount(EmbedStage, { + props: { + photos: options.photos ?? PHOTOS, + index: options.index ?? 0, + imageFit: options.imageFit ?? 'contain', + reducedMotion: options.reducedMotion ?? false, + }, + global: { plugins: [createViewerI18n('zh-CN')] }, + }); + mounted.push(wrapper); + return wrapper; + } + + it('applies object-fit from config (contain default, cover optional)', async () => { + const contain = mountStage({ imageFit: 'contain' }); + await vi.advanceTimersByTimeAsync(0); + await contain.vm.$nextTick(); + expect((contain.get('.embed-photo img').element as HTMLImageElement).style.objectFit).toBe('contain'); + + const cover = mountStage({ imageFit: 'cover' }); + await vi.advanceTimersByTimeAsync(0); + await cover.vm.$nextTick(); + expect((cover.get('.embed-photo img').element as HTMLImageElement).style.objectFit).toBe('cover'); + }); + + it('exposes a single tabbable open-photo button; the transition layer is hidden from a11y', async () => { + const wrapper = mountStage({ index: 1 }); + await vi.advanceTimersByTimeAsync(0); + await wrapper.vm.$nextTick(); + await wrapper.setProps({ index: 2 }); + await vi.advanceTimersByTimeAsync(0); + await wrapper.vm.$nextTick(); + + // 当前帧:group/slide 语义 + 序号标签 + 唯一可交互开图按钮。 + const active = wrapper.get('.embed-photo.active'); + expect(active.attributes('role')).toBe('group'); + expect(active.attributes('aria-roledescription')).toBe('slide'); + expect(active.attributes('aria-label')).toBe('第 3 张,共 3 张'); + expect(active.find('button.embed-photo-open').exists()).toBe(true); + expect(active.get('img').attributes('alt')).toBe('活动照片,第 3 张'); + + // 过渡层(淡入中的 c):在场且 aria-hidden + tabindex=-1 + 无按钮。 + const next = wrapper.find('.embed-photo--next'); + expect(next.exists()).toBe(true); + expect(next.attributes('aria-hidden')).toBe('true'); + expect(next.attributes('tabindex')).toBe('-1'); + expect(next.find('button').exists()).toBe(false); + }); + + it('emits open-photo with the photoId when the open button is clicked', async () => { + const wrapper = mountStage({ index: 0 }); + await vi.advanceTimersByTimeAsync(0); + await wrapper.vm.$nextTick(); + await wrapper.get('.embed-photo-open').trigger('click'); + expect(wrapper.emitted('open-photo')?.[0]).toEqual(['a']); + }); +}); + +describe('EmbedFooterBar', () => { + function mountFooter(props: Partial['$props']> = {}): VueWrapper { + const wrapper = mount(EmbedFooterBar, { + props: { + title: '测试相册', + index: 2, + count: 12, + paused: false, + reducedMotion: false, + albumUrl: 'https://viewer.test/a/tok?lang=zh-CN', + ...props, + }, + global: { plugins: [createViewerI18n('zh-CN')] }, + }); + mounted.push(wrapper); + return wrapper; + } + + it('shows the padded current-index / queue-count indicator (not album totals)', () => { + const wrapper = mountFooter({ index: 2, count: 12 }); + expect(wrapper.get('.embed-indicator').text()).toBe('03 / 12'); + }); + + it('renders the view-album link with noopener/noreferrer and _blank', () => { + const wrapper = mountFooter(); + const link = wrapper.get('.embed-foot-link'); + expect(link.attributes('href')).toBe('https://viewer.test/a/tok?lang=zh-CN'); + expect(link.attributes('target')).toBe('_blank'); + expect(link.attributes('rel')).toContain('noopener'); + expect(link.attributes('rel')).toContain('noreferrer'); + expect(link.text()).toBe('查看相册'); + }); + + it('exposes the full album title while the visual clamp lives in CSS', () => { + const wrapper = mountFooter(); + // 两行截断为 scoped CSS(-webkit-line-clamp);完整标题经 title 提示可达。 + expect(wrapper.get('.embed-title').attributes('title')).toBe('测试相册'); + expect(wrapper.get('.embed-title').classes()).toContain('embed-title'); + }); + + it('hides manual prev/next unless reduced motion', () => { + const plain = mountFooter({ reducedMotion: false }); + expect(plain.findAll('.embed-foot-btn').length).toBe(1); // 仅暂停/播放 + const reduced = mountFooter({ reducedMotion: true }); + expect(reduced.findAll('.embed-foot-btn').length).toBe(3); // prev/next/暂停 + }); +}); diff --git a/apps/viewer/src/__tests__/embed-feed.spec.ts b/apps/viewer/src/__tests__/embed-feed.spec.ts index 5b55942..e1876e0 100644 --- a/apps/viewer/src/__tests__/embed-feed.spec.ts +++ b/apps/viewer/src/__tests__/embed-feed.spec.ts @@ -2,22 +2,24 @@ import { mount, type VueWrapper } from '@vue/test-utils'; import { defineComponent, ref } from 'vue'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Mock } from 'vitest'; -import { ApiError, type PublicEmbedPhoto, type PublicEmbedSlot } from '../api/client'; +import { ApiError, type EmbedSlotSettings, type PublicEmbedPhoto, type PublicEmbedSlot } from '../api/client'; import { postEmbedMessage } from '../embed/postMessage'; import { clampEmbedHeight, clampEmbedIntervalMs, classifyEmbedResponse, + hasExpiringThumb, mergePhotoQueue, pollIntervalAfterFailures, resolveEmbedLocale, } from '../embed/feed'; import { useEmbedFeed, type EmbedFeed, type UseEmbedFeedOptions } from '../embed/useEmbedFeed'; -// 嵌入轮播数据流(P4b Task 4):纯函数矩阵(A22 旧响应丢弃/队列差分/参数夹取/ -// 退避)+ useEmbedFeed 编排(304 ETag 链路、404 静默 collapse、可见性暂停恢复、 -// 轮播切换门控)。postMessage 模块替身(jsdom 无父窗,postEmbedMessage 断言上行 -// 载荷;onEmbedExpand 捕获 handler 供用例模拟宿主 expand);IntersectionObserver/ +// 嵌入轮播数据流(P4b Task 4;UI-01 解耦重构):纯函数矩阵(A22 旧响应丢弃/ +// 队列差分/参数夹取/退避/304 过期签名判定)+ useEmbedFeed 编排(ETag 链路、 +// 404 静默 destroy、config 消息、可见性暂停恢复、轮询 30s 基准与轮播解耦)。 +// postMessage 模块替身(jsdom 无父窗,postEmbedMessage 断言上行载荷; +// onEmbedExpand 捕获 handler 供用例模拟宿主 expand);IntersectionObserver/ // ResizeObserver 在 jsdom 缺席,composable 已按特性探测跳过(高度上报走 // clampEmbedHeight 纯函数覆盖)。 const expandHandlers = vi.hoisted(() => new Set<() => void>()); @@ -32,19 +34,35 @@ vi.mock('../embed/postMessage', () => ({ const mockedPost = vi.mocked(postEmbedMessage); -function makePhoto(id: string, url?: string): PublicEmbedPhoto { +function makePhoto(id: string, url?: string, expiresAt?: string): PublicEmbedPhoto { return { photoId: id, - thumb: { url: url ?? `https://img.test/${id}.jpg`, expiresAt: '2026-10-01T00:00:00.000Z' }, + thumb: { url: url ?? `https://img.test/${id}.jpg`, expiresAt: expiresAt ?? '2099-01-01T00:00:00.000Z' }, width: 240, height: 150, }; } +function makeSettings(overrides: Partial = {}): EmbedSlotSettings { + return { + position: 'right', + size: 'md', + intervalMs: 5000, + lang: 'auto', + collapsedDefault: false, + imageFit: 'contain', + offsetX: 16, + offsetBottom: 24, + zIndex: 1000, + ...overrides, + }; +} + function makeSlot(overrides: Partial = {}): PublicEmbedSlot { return { slotToken: 'slottest0000000000000000aa', configVersion: 1, + settings: makeSettings(), album: { publicToken: 'tokfixture0000000000aaaa', title: '嵌入测试相册', @@ -132,20 +150,33 @@ describe('embed feed pure helpers', () => { expect(pollIntervalAfterFailures(5, 30000)).toBe(150000); expect(pollIntervalAfterFailures(9, 30000)).toBe(150000); }); + + it('detects expiring thumbs with skew for the 304 force-refresh path (UI-01 §5.5)', () => { + const now = Date.parse('2026-09-21T12:00:00.000Z'); + const fresh = [makePhoto('a', undefined, '2026-09-21T13:00:00.000Z')]; + const stale = [makePhoto('a', undefined, '2026-09-21T12:00:20.000Z')]; // 20s 后过期 ≤ 30s skew + const expired = [makePhoto('a', undefined, '2026-09-21T11:00:00.000Z')]; + const unparsable = [makePhoto('a', undefined, 'not-a-date')]; + expect(hasExpiringThumb([], now)).toBe(false); + expect(hasExpiringThumb(fresh, now)).toBe(false); + expect(hasExpiringThumb(stale, now)).toBe(true); + expect(hasExpiringThumb(expired, now)).toBe(true); + expect(hasExpiringThumb(unparsable, now)).toBe(false); + }); }); // —— useEmbedFeed 编排 —— const mounted: VueWrapper[] = []; -function mountFeed(options: { fetcher: Mock; langParam?: string; intervalParamMs?: number | null }): EmbedFeed { +function mountFeed(options: { fetcher: Mock; langParam?: string; pollParamMs?: number | null }): EmbedFeed { let feed!: EmbedFeed; const Host = defineComponent({ setup() { feed = useEmbedFeed({ slotToken: 'slote2etest0000000000aa', langParam: options.langParam ?? 'auto', - intervalParamMs: options.intervalParamMs ?? null, + pollParamMs: options.pollParamMs ?? null, rootEl: ref(null), fetcher: options.fetcher as UseEmbedFeedOptions['fetcher'], }); @@ -183,6 +214,7 @@ describe('useEmbedFeed orchestration', () => { expect(feed.photos.map((p) => p.photoId)).toEqual(['p1', 'p2']); expect(feed.album?.title).toBe('嵌入测试相册'); expect(feed.resolvedLocale).toBe('zh-CN'); // auto → defaultLocale + expect(feed.settings).toEqual(makeSettings()); // 下一轮带上 If-None-Match;304(data null)→ 队列零变化、不重置版本。 fetcher.mockResolvedValue({ data: null, etag: '"e1"' }); @@ -192,6 +224,28 @@ describe('useEmbedFeed orchestration', () => { expect(feed.status).toBe('ready'); }); + it('polls at the contract 30s base (carousel interval never drives fetches, UI-01 §5.5)', async () => { + const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' }); + mountFeed({ fetcher }); + await vi.advanceTimersByTimeAsync(0); + expect(fetcher).toHaveBeenCalledTimes(1); + + // 3s/8s 处零请求(若轮播间隔渗入轮询此处即失败);30s 到点第 2 次。 + await vi.advanceTimersByTimeAsync(3000); + await vi.advanceTimersByTimeAsync(5000); + expect(fetcher).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(22_000); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('honors the dev poll override for accelerated polling', async () => { + const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' }); + mountFeed({ fetcher, pollParamMs: 2000 }); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(2000); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it('merges an equal-version response: new photo appended at tail, urls refreshed', async () => { const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' }); const feed = mountFeed({ fetcher }); @@ -206,18 +260,25 @@ describe('useEmbedFeed orchestration', () => { await vi.advanceTimersByTimeAsync(30_000); expect(feed.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p3']); expect(feed.photos[0]!.thumb.url).toBe('https://img.test/p1-new.jpg'); - expect(feed.index).toBe(0); // 合并不打断当前展示 }); - it('fully resets when configVersion rises (rebind, A22)', async () => { + it('fully resets when configVersion rises and forwards settings via config message (UI-01)', async () => { const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' }); const feed = mountFeed({ fetcher }); await vi.advanceTimersByTimeAsync(0); - feed.postResize(); // no-op(rootEl null)——仅确认不抛 + // 首包(reset 分支)即上行 config(宿主 loader 应用后台设置)。 + expect(mockedPost).toHaveBeenCalledWith({ + photoliveEmbed: true, + type: 'config', + configVersion: 1, + settings: makeSettings(), + }); + const updated = makeSettings({ position: 'left', offsetX: 60, imageFit: 'cover', zIndex: 2500 }); fetcher.mockResolvedValue({ data: makeSlot({ configVersion: 2, + settings: updated, album: { publicToken: 'tokother00000000000000bbbb', title: '换绑相册', defaultLocale: 'en' }, photos: [makePhoto('q1')], }), @@ -226,7 +287,14 @@ describe('useEmbedFeed orchestration', () => { await vi.advanceTimersByTimeAsync(30_000); expect(feed.photos.map((p) => p.photoId)).toEqual(['q1']); // 旧照片不残留 expect(feed.album?.title).toBe('换绑相册'); - expect(feed.resolvedLocale).toBe('en'); // 相册 defaultLocale 变化生效 + expect(feed.resolvedLocale).toBe('en'); + expect(feed.settings).toEqual(updated); + expect(mockedPost).toHaveBeenCalledWith({ + photoliveEmbed: true, + type: 'config', + configVersion: 2, + settings: updated, + }); }); it('discards a stale in-flight response (seq guard)', async () => { @@ -253,13 +321,13 @@ describe('useEmbedFeed orchestration', () => { expect(feed.photos.map((p) => p.photoId)).toEqual(['new1', 'new2']); }); - it('goes silent on 404: no UI state, collapse posted to the host loader', async () => { + it('goes silent on 404: destroy posted (host removes card and handle), polling stops', async () => { const fetcher = vi.fn().mockRejectedValue(new ApiError('NOT_FOUND', '展示位不存在或不可用', 404, null)); const feed = mountFeed({ fetcher }); await vi.advanceTimersByTimeAsync(0); expect(feed.status).toBe('gone'); - expect(mockedPost).toHaveBeenCalledWith({ photoliveEmbed: true, type: 'collapse' }); + expect(mockedPost).toHaveBeenCalledWith({ photoliveEmbed: true, type: 'destroy' }); // gone 后彻底停止轮询。 await vi.advanceTimersByTimeAsync(300_000); expect(fetcher).toHaveBeenCalledTimes(1); @@ -287,53 +355,68 @@ describe('useEmbedFeed orchestration', () => { it('pauses polling while hidden and refreshes immediately on return (A23)', async () => { const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' }); - mountFeed({ fetcher }); + const feed = mountFeed({ fetcher }); await vi.advanceTimersByTimeAsync(0); + expect(feed.active).toBe(true); const callsBeforeHide = fetcher.mock.calls.length; setHidden(true); document.dispatchEvent(new Event('visibilitychange')); await vi.advanceTimersByTimeAsync(120_000); expect(fetcher).toHaveBeenCalledTimes(callsBeforeHide); // 隐藏期间零轮询 + expect(feed.active).toBe(false); // 轮播切换闸门同步关闭 setHidden(false); document.dispatchEvent(new Event('visibilitychange')); await vi.advanceTimersByTimeAsync(0); expect(fetcher).toHaveBeenCalledTimes(callsBeforeHide + 1); // 恢复立即校准一次 + expect(feed.active).toBe(true); }); - it('advances the carousel on intervalMs and freezes it while hidden', async () => { - const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' }); - const feed = mountFeed({ fetcher, intervalParamMs: 3000 }); + it('force-refreshes past the etag when a 304 coincides with expiring signed urls (UI-01 §5.5)', async () => { + // 首包 URL 已临期(1 分钟后过期 < 30s skew)。 + const expiring = new Date(Date.now() + 60_000).toISOString(); + const first = makeSlot({ photos: [makePhoto('p1', 'https://img.test/p1-old.jpg', expiring)] }); + const refreshed = makeSlot({ + photos: [makePhoto('p1', 'https://img.test/p1-new.jpg', new Date(Date.now() + 3600_000).toISOString())], + }); + const fetcher = vi.fn().mockResolvedValue({ data: first, etag: '"e1"' }); + const feed = mountFeed({ fetcher }); await vi.advanceTimersByTimeAsync(0); - expect(feed.index).toBe(0); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(feed.photos[0]!.thumb.url).toBe('https://img.test/p1-old.jpg'); - await vi.advanceTimersByTimeAsync(3000); - expect(feed.index).toBe(1); - await vi.advanceTimersByTimeAsync(3000); - expect(feed.index).toBe(0); // 两张循环 - - setHidden(true); - document.dispatchEvent(new Event('visibilitychange')); + // 30s 轮询 304(内容未变)但签名临期 → 绕过 ETag 立即强取一次新 URL。 + fetcher + .mockResolvedValueOnce({ data: null, etag: '"e1"' }) // 轮询 → 304 + .mockResolvedValueOnce({ data: refreshed, etag: '"e1"' }); // 强取 → 200 新签名 await vi.advanceTimersByTimeAsync(30_000); - expect(feed.index).toBe(0); // 暂停期间不切换 + expect(fetcher).toHaveBeenCalledTimes(3); + expect(fetcher).toHaveBeenNthCalledWith(2, 'slote2etest0000000000aa', { lang: 'auto', etag: '"e1"' }); + expect(fetcher).toHaveBeenNthCalledWith(3, 'slote2etest0000000000aa', { lang: 'auto', etag: null }); // 绕过 ETag + expect(feed.photos[0]!.thumb.url).toBe('https://img.test/p1-new.jpg'); + + // 强取后恢复正常节奏(30s 基准,不重复强取)。 + fetcher.mockResolvedValue({ data: null, etag: '"e1"' }); + await vi.advanceTimersByTimeAsync(500); + expect(fetcher).toHaveBeenCalledTimes(3); }); - it('collapse stops timers and expand from the parent resumes with a resize repost', async () => { + it('collapse stops polling and posting; expand resumes with an immediate refresh', async () => { const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' }); - const feed = mountFeed({ fetcher, intervalParamMs: 3000 }); + const feed = mountFeed({ fetcher }); await vi.advanceTimersByTimeAsync(0); - const callsAfterLoad = fetcher.mock.calls.length; + const calls = fetcher.mock.calls.length; feed.requestCollapse(); expect(mockedPost).toHaveBeenCalledWith({ photoliveEmbed: true, type: 'collapse' }); - await vi.advanceTimersByTimeAsync(60_000); - expect(fetcher).toHaveBeenCalledTimes(callsAfterLoad); // 收起期间零轮询 + expect(feed.active).toBe(false); + await vi.advanceTimersByTimeAsync(120_000); + expect(fetcher).toHaveBeenCalledTimes(calls); // 收起期间零轮询 - // 宿主把手 expand(onEmbedExpand 捕获的 handler):恢复(立即校准 + 重发高度, - // rootEl null → resize 跳过不抛)。 - expandHandlers.forEach((handler) => handler()); + for (const handler of expandHandlers) handler(); // 宿主把手 expand await vi.advanceTimersByTimeAsync(0); - expect(fetcher).toHaveBeenCalledTimes(callsAfterLoad + 1); + expect(fetcher).toHaveBeenCalledTimes(calls + 1); // 恢复立即校准 + expect(feed.active).toBe(true); }); }); diff --git a/apps/viewer/src/api/client.ts b/apps/viewer/src/api/client.ts index 2a1a1c0..7fca60c 100644 --- a/apps/viewer/src/api/client.ts +++ b/apps/viewer/src/api/client.ts @@ -20,6 +20,7 @@ export type DownloadVariant = components['schemas']['DownloadVariant']; export type PublicEmbedSlot = components['schemas']['PublicEmbedSlot']; export type PublicEmbedAlbum = components['schemas']['PublicEmbedAlbum']; export type PublicEmbedPhoto = components['schemas']['PublicEmbedPhoto']; +export type EmbedSlotSettings = components['schemas']['EmbedSlotSettings']; /** 公开照片排序模式(sort=hot 仅 ov 实现:likeTotal 倒序短期快照,§7)。 */ export type SortMode = 'latest' | 'hot'; diff --git a/apps/viewer/src/embed/feed.ts b/apps/viewer/src/embed/feed.ts index a1b134e..f24d3fa 100644 --- a/apps/viewer/src/embed/feed.ts +++ b/apps/viewer/src/embed/feed.ts @@ -96,9 +96,29 @@ export function mergePhotoQueue(current: PublicEmbedPhoto[], incoming: PublicEmb return { photos, added: added.map((photo) => photo.photoId), removed }; } -/** 轮询基础间隔:e2e/宿主覆盖(查询 interval,夹取)→ 契约缺省 30s。 */ -export function basePollIntervalMs(intervalParamMs: number | null): number { - return clampEmbedIntervalMs(intervalParamMs) ?? EMBED_POLL_INTERVAL_MS; +/** 轮询基础间隔(UI-01 §5.5 轮播与取数彻底解耦):恒为契约缺省 30s—— + * 轮播 intervalMs(查询覆盖)绝不缩短取数周期;e2e/宿主加速走运行时覆盖 + * (__embedPollMs / dev 构建 poll 查询,见 useEmbedFeed pollBaseMs)。 */ +export function basePollIntervalMs(): number { + return EMBED_POLL_INTERVAL_MS; +} + +/** + * 队列中是否存在临期/过期签名 URL(UI-01 §5.5):304 路径的兜底判定——ETag + * 四元组不含签名时效,内容未变仍 304 时缩略 URL 可能已过期;提前 skew 判定, + * 命中即由调用方绕过 ETag 强制取新(每轮一次)。 + */ +export const THUMB_REFRESH_SKEW_MS = 30_000; + +export function hasExpiringThumb( + photos: PublicEmbedPhoto[], + nowMs: number = Date.now(), + skewMs: number = THUMB_REFRESH_SKEW_MS, +): boolean { + return photos.some((photo) => { + const expiry = Date.parse(photo.thumb.expiresAt); + return Number.isFinite(expiry) && expiry - skewMs <= nowMs; + }); } /** diff --git a/apps/viewer/src/embed/images.ts b/apps/viewer/src/embed/images.ts new file mode 100644 index 0000000..7acf7b8 --- /dev/null +++ b/apps/viewer/src/embed/images.ts @@ -0,0 +1,36 @@ +// apps/viewer/src/embed/images.ts —— 嵌入轮播图片预加载(UI-01 §5.2)。 +// 「新图 decode 成功再切换」:Stage 在切换前经本函数预热(不在 DOM 插层), +// 成功才挂过渡层交叉淡入;失败由调用方保留上一张并尝试后续图片。 +// jsdom 无 Image.decode/load 事件:decode 缺席且 onload 不触发时按成功放行 +// (组件测试经 vi.mock 替身控制成败)。 +export function preloadEmbedImage(url: string): Promise { + return new Promise((resolve, reject) => { + if (typeof Image === 'undefined') { + resolve(); + return; + } + const img = new Image(); + img.decoding = 'async'; + let settled = false; + const ok = (): void => { + if (!settled) { + settled = true; + resolve(); + } + }; + const bad = (): void => { + if (!settled) { + settled = true; + reject(new Error('embed image decode failed')); + } + }; + img.onload = ok; + img.onerror = bad; + // 先置 src 再 decode:无源 decode() 立即以 EncodingError 拒绝(真实浏览器 + // 全量误判失败——jsdom 无 decode 不受影响)。 + img.src = url; + if (typeof img.decode === 'function') { + img.decode().then(ok, bad); + } + }); +} diff --git a/apps/viewer/src/embed/postMessage.ts b/apps/viewer/src/embed/postMessage.ts index 31605ea..1800f0c 100644 --- a/apps/viewer/src/embed/postMessage.ts +++ b/apps/viewer/src/embed/postMessage.ts @@ -1,20 +1,31 @@ // apps/viewer/src/embed/postMessage.ts —— 嵌入 iframe ↔ 宿主 loader 消息通道 -// (P4b Task 4)。协议与白名单模型见 packages/embed-loader(README-embed.md): -// 载荷恒为 {photoliveEmbed: true, type: 'resize'|'collapse'|..., ...}。 +// (P4b Task 4;UI-01 扩展 config/destroy)。协议与白名单模型见 +// packages/embed-loader(README-embed.md):载荷恒为 +// {photoliveEmbed: true, type: 'resize'|'collapse'|'config'|'destroy'|...}。 // -// 上行(iframe → 宿主):resize(高度)/ collapse(收起,宿主侧高度 0 + 把手)。 +// 上行(iframe → 宿主): +// resize(高度)/ collapse(用户收起,宿主侧高度 0 + 把手); +// config(后台设置动态应用,UI-01:configVersion + settings 归一全集, +// 宿主侧再验证一次——仅 position/size/offsetX/offsetBottom/zIndex/ +// collapsedDefault 等白名单字段,不传任意 CSS/HTML); +// destroy(服务端终止展示:槽 404/410/停用/解绑——宿主移除卡片+把手, +// 与「用户收起」语义区分,§5.3)。 // 目标 origin 尽量收敛到 document.referrer 的源(loader 的 iframe 带 // no-referrer-when-downgrade,宿主正常配置下可得完整来源 URL);referrer 被宿主 -// 隐去时退回 '*'——载荷仅含高度/收起意图,无敏感数据,且宿主侧 loader 以 +// 隐去时退回 '*'——载荷仅含展示意图/公开设置,无敏感数据,且宿主侧 loader 以 // 「event.source === 已登记 iframe && event.origin === iframe 源」双重严格校验, // 通配目标不构成越权面。 // // 下行(宿主 → iframe):expand(把手点击恢复)——只认 event.source === // window.parent 且载荷形状匹配(source 校验即充分:宿主页内任意脚本无法伪造 // parent 窗口引用;loader 对上行另有 origin 全等白名单)。 +import type { EmbedSlotSettings } from '../api/client'; + export type EmbedUpMessage = | { photoliveEmbed: true; type: 'resize'; height: number } - | { photoliveEmbed: true; type: 'collapse' }; + | { photoliveEmbed: true; type: 'collapse' } + | { photoliveEmbed: true; type: 'config'; configVersion: number; settings: EmbedSlotSettings } + | { photoliveEmbed: true; type: 'destroy' }; /** 宿主源(referrer 推导);取不到(被隐去/直接打开)为 null。 */ export function embedParentOrigin(): string | null { diff --git a/apps/viewer/src/embed/useEmbedFeed.ts b/apps/viewer/src/embed/useEmbedFeed.ts index 7234491..92e4ffd 100644 --- a/apps/viewer/src/embed/useEmbedFeed.ts +++ b/apps/viewer/src/embed/useEmbedFeed.ts @@ -1,33 +1,40 @@ -// apps/viewer/src/embed/useEmbedFeed.ts —— 嵌入轮播数据流编排(P4b Task 4)。 -// 职责(纯函数在 feed.ts,消息通道在 postMessage.ts): +// apps/viewer/src/embed/useEmbedFeed.ts —— 嵌入轮播数据流编排(P4b Task 4; +// UI-01 重构:取数与轮播彻底解耦)。 +// 职责(纯函数在 feed.ts,消息通道在 postMessage.ts,图片预热在 images.ts): // · 取数:GET public embed-slots(If-None-Match → 304 无变化零处理); // · A22 竞态防护:每次 dispatch 记 seq + 本地 configVersion,resolve 时过期 // (新请求已发出)或版本低于本地 → 丢弃;等值 → 照片队列差分合并(新照片 // 追加队尾、缺席淘汰、thumb 签名刷新);升值(换绑/设置变更)→ 全量重置; -// · 轮询:基础间隔 = 查询 interval 覆盖(夹取 3000-30000)否则契约 -// EMBED_POLL_INTERVAL_MS;连续失败达 EMBED_POLL_MAX_FAILURES 后 ×退避倍数, +// · 轮询(UI-01 §5.5):基准恒为契约 EMBED_POLL_INTERVAL_MS(30s)——轮播 +// intervalMs 不影响取数周期(轮播切换是纯本地内存队列行为,见 +// EmbedCarousel.vue);连续失败达 EMBED_POLL_MAX_FAILURES 后 ×退避倍数, // 成功归零;404/410(槽停用/解绑/不存在,防探测同码)→ 静默 gone + -// postMessage collapse,其余错误静默退避重试(不弹错误 UI,A11); +// postMessage destroy(宿主移除卡片与把手,§5.3 与用户收起语义区分), +// 其余错误静默退避重试(不弹错误 UI,A11); +// · 304 过期签名兜底:ETag 四元组不含签名时效,304 且队列缩略临期/过期 → +// 绕过 ETag 立即强取一次新 URL(每轮最多一次,§5.5); +// · 后台设置(UI-01):settings 随响应携带;configVersion 升值(reset)时经 +// config 消息交宿主 loader 动态应用(loader 侧二次校验 + 版本闸); // · 不可见暂停(A23):document 隐藏 / IntersectionObserver 自身滚出视口 / -// 本地收起 → 停轮询停切换;恢复 → 立即校准一次再续常态; -// · 轮播:intervalMs 切换索引(仅 thumb 小图,UI 在 EmbedPage.vue)。 -// e2e 注入点:window.__embedPollMs(轮询间隔覆盖,宿主页同源写入;沿用 -// __viewerRealtimePollMs 惰性读取约定,运行中改动下一轮生效)。 +// 本地收起 → 停轮询(active=false 供 Carousel 停切换);恢复 → 立即校准 +// 一次再续常态。 +// e2e 注入点:window.__embedPollMs(轮询间隔覆盖,宿主页同源写入)与 dev 构建 +// 的 poll 查询参数(跨源宿主可用;生产构建忽略——import.meta.env.DEV 门控)。 import { onBeforeUnmount, onMounted, reactive, ref, type Ref } from 'vue'; import { ApiError, fetchPublicEmbedSlot, + type EmbedSlotSettings, type PublicEmbedAlbum, type PublicEmbedPhoto, type PublicEmbedSlot, } from '../api/client'; import type { ViewerLocale } from '../i18n'; +import { EMBED_POLL_INTERVAL_MS } from '@photo/contracts/constants'; import { - basePollIntervalMs, clampEmbedHeight, - clampEmbedIntervalMs, classifyEmbedResponse, - EMBED_CAROUSEL_DEFAULT_MS, + hasExpiringThumb, mergePhotoQueue, pollIntervalAfterFailures, resolveEmbedLocale, @@ -40,15 +47,15 @@ export type EmbedFeedStatus = | 'ready' /** 相册无照片(或契约前瞻 album null):「照片即将上线」小卡。 */ | 'empty' - /** 槽 404(停用/解绑/不存在):无 UI,postMessage collapse 后隐藏自身。 */ + /** 槽 404(停用/解绑/不存在):无 UI,postMessage destroy 后隐藏自身。 */ | 'gone'; export interface UseEmbedFeedOptions { slotToken: string; /** loader 传入 lang 查询(auto|zh-CN|en)。 */ langParam: string; - /** loader 传入 interval 查询(毫秒;null=未提供)。 */ - intervalParamMs: number | null; + /** dev 构建的 poll 查询(毫秒;null=未提供/生产)。仅加速取数轮询。 */ + pollParamMs: number | null; /** 页面根元素(高度上报 / 可见性观测)。 */ rootEl: Ref; /** 测试注入;缺省真实 fetchPublicEmbedSlot。 */ @@ -61,14 +68,17 @@ function overrideNumber(key: string): number | null { } export function useEmbedFeed(options: UseEmbedFeedOptions) { - const { slotToken, langParam, intervalParamMs, rootEl } = options; + const { slotToken, langParam, rootEl } = options; const fetcher = options.fetcher ?? fetchPublicEmbedSlot; const status = ref('loading'); const album = ref(null); const photos = ref([]); - const index = ref(0); const resolvedLocale = ref(null); + /** 后台展示设置(UI-01;旧 ov 投影缺字段 → null,按默认渲染)。 */ + const settings = ref(null); + /** 轮播活动闸门(收起/隐藏/不可见/gone → false;Carousel 据此停切换)。 */ + const active = ref(true); let etag: string | null = null; let configVersion: number | null = null; @@ -80,18 +90,15 @@ export function useEmbedFeed(options: UseEmbedFeedOptions) { let pageVisible = true; let intersecting = true; let pollTimer: ReturnType | null = null; - let carouselTimer: ReturnType | null = null; let observer: ResizeObserver | null = null; let visibilityObserver: IntersectionObserver | null = null; let offExpand: (() => void) | null = null; let resizeFrame: number | null = null; + /** 轮询基准(§5.5 解耦):运行时覆盖(同源注入)→ dev poll 查询 → 契约 30s。 + * 轮播 intervalMs 绝不进入本式。 */ function pollBaseMs(): number { - return overrideNumber('__embedPollMs') ?? basePollIntervalMs(intervalParamMs); - } - - function carouselMs(): number { - return clampEmbedIntervalMs(intervalParamMs) ?? EMBED_CAROUSEL_DEFAULT_MS; + return overrideNumber('__embedPollMs') ?? options.pollParamMs ?? EMBED_POLL_INTERVAL_MS; } /** 活动闸门:未卸载、未收起、非 gone、页面可见且自身在视口内。 */ @@ -99,29 +106,45 @@ export function useEmbedFeed(options: UseEmbedFeedOptions) { return alive && !collapsed && status.value !== 'gone' && pageVisible && intersecting; } + function syncActive(): void { + active.value = running(); + } + function stopTimers(): void { if (pollTimer !== null) { clearTimeout(pollTimer); pollTimer = null; } - if (carouselTimer !== null) { - clearInterval(carouselTimer); - carouselTimer = null; - } + syncActive(); } - function ensureCarousel(): void { - if (carouselTimer !== null) clearInterval(carouselTimer); - carouselTimer = null; - if (!running() || photos.value.length < 2) return; - carouselTimer = setInterval( + function fatal(): void { + status.value = 'gone'; + stopTimers(); + // 服务端终止展示 ≠ 用户收起:宿主移除卡片+把手+会话键,不留无效按钮。 + postEmbedMessage({ photoliveEmbed: true, type: 'destroy' }); + } + + function scheduleNextPoll(): void { + if (!running() || pollTimer !== null) return; + pollTimer = setTimeout( () => { - index.value = (index.value + 1) % photos.value.length; + pollTimer = null; + void pollNow(); }, - carouselMs(), + pollIntervalAfterFailures(failures, pollBaseMs()), ); } + /** settings 应用 + config 消息(configVersion 升值时宿主 wrapper 动态更新)。 */ + function applySettings(version: number, data: PublicEmbedSlot): void { + const s = data.settings ?? null; + settings.value = s; + if (s !== null) { + postEmbedMessage({ photoliveEmbed: true, type: 'config', configVersion: version, settings: s }); + } + } + function applyResponse(dispatchSeq: number, dispatchVersion: number | null, data: PublicEmbedSlot): void { const decision = classifyEmbedResponse({ dispatchSeq, @@ -134,48 +157,35 @@ export function useEmbedFeed(options: UseEmbedFeedOptions) { if (decision === 'reset') { configVersion = data.configVersion; photos.value = data.photos; - index.value = 0; + applySettings(data.configVersion, data); } else { const merged = mergePhotoQueue(photos.value, data.photos); photos.value = merged.photos; - if (index.value >= merged.photos.length) index.value = 0; } album.value = data.album; resolvedLocale.value = resolveEmbedLocale(langParam, data.album?.defaultLocale); const hasContent = data.album !== null && photos.value.length > 0; status.value = hasContent ? 'ready' : 'empty'; - ensureCarousel(); + syncActive(); } - function fatal(): void { - status.value = 'gone'; - stopTimers(); - postEmbedMessage({ photoliveEmbed: true, type: 'collapse' }); - } - - function scheduleNextPoll(): void { - if (!running() || pollTimer !== null) return; - pollTimer = setTimeout( - () => { - pollTimer = null; - void pollNow(); - }, - pollIntervalAfterFailures(failures, pollBaseMs()), - ); - } - - async function pollNow(): Promise { + async function pollNow(forceRefresh = false): Promise { if (!running()) return; const dispatchSeq = ++seq; const dispatchVersion = configVersion; try { - const result = await fetcher(slotToken, { lang: langParam, etag }); + const result = await fetcher(slotToken, { lang: langParam, etag: forceRefresh ? null : etag }); // 竞态/暂停双闸:发起后有新请求(seq 前进)或已暂停(隐藏/收起)→ 丢弃。 if (dispatchSeq !== seq || !running()) return; failures = 0; if (result.etag !== null) etag = result.etag; - // 304(data null):内容未变,队列/版本零处理。 - if (result.data !== null) applyResponse(dispatchSeq, dispatchVersion, result.data); + if (result.data !== null) { + applyResponse(dispatchSeq, dispatchVersion, result.data); + } else if (!forceRefresh && hasExpiringThumb(photos.value)) { + // 304 且队列签名临期/过期:绕过 ETag 立即强取一次(seq 前进,本轮 + // 不再调度;200 必带新签名 URL,§5.5)。 + return pollNow(true); + } } catch (error) { if (dispatchSeq !== seq || !running()) return; if (error instanceof ApiError && (error.status === 404 || error.status === 410)) { @@ -191,7 +201,7 @@ export function useEmbedFeed(options: UseEmbedFeedOptions) { function resumeIfActive(): void { if (!running()) return; void pollNow(); // 恢复即校准一次(pollNow 完成后续排下一轮) - ensureCarousel(); + syncActive(); } function requestCollapse(): void { @@ -205,7 +215,7 @@ export function useEmbedFeed(options: UseEmbedFeedOptions) { collapsed = false; if (!running()) return; void pollNow(); - ensureCarousel(); + syncActive(); postResize(); } @@ -273,8 +283,9 @@ export function useEmbedFeed(options: UseEmbedFeedOptions) { status, album, photos, - index, resolvedLocale, + settings, + active, requestCollapse, /** 直接触发一次高度上报(expand 后重发 / 测试)。 */ postResize, diff --git a/apps/viewer/src/i18n/locales/en.ts b/apps/viewer/src/i18n/locales/en.ts index 298632a..bb94fd6 100644 --- a/apps/viewer/src/i18n/locales/en.ts +++ b/apps/viewer/src/i18n/locales/en.ts @@ -24,6 +24,15 @@ export default { comingSoon: 'Photos coming soon', openPhoto: 'Open photo', close: 'Collapse widget', + widgetTitle: 'Event photos', + carouselLabel: 'Photo carousel', + slideOf: 'Photo {n} of {m}', + photoAlt: 'Event photo {n}', + viewAlbum: 'View album', + pause: 'Pause carousel', + play: 'Play carousel', + prev: 'Previous photo', + next: 'Next photo', }, download: { action: 'Download photo', diff --git a/apps/viewer/src/i18n/locales/zh-CN.ts b/apps/viewer/src/i18n/locales/zh-CN.ts index 15804be..4a7f722 100644 --- a/apps/viewer/src/i18n/locales/zh-CN.ts +++ b/apps/viewer/src/i18n/locales/zh-CN.ts @@ -19,11 +19,21 @@ export default { }, lang: { switch: '切换语言' }, prefs: { reduceMotion: '减少动画' }, - // 嵌入轮播 iframe 页(P4b Task 4):独立于宿主语言的词条子集(lang 查询驱动)。 + // 嵌入轮播 iframe 页(P4b Task 4;UI-01 补全):独立于宿主语言的词条子集 + // (lang 查询驱动)。图片无真实描述时只给上下文标签(§5.3 不编造画面)。 embed: { comingSoon: '照片即将上线', openPhoto: '查看照片大图', close: '收起组件', + widgetTitle: '活动照片', + carouselLabel: '照片轮播', + slideOf: '第 {n} 张,共 {m} 张', + photoAlt: '活动照片,第 {n} 张', + viewAlbum: '查看相册', + pause: '暂停轮播', + play: '播放轮播', + prev: '上一张', + next: '下一张', }, download: { action: '下载图片', diff --git a/apps/viewer/src/pages/EmbedPage.vue b/apps/viewer/src/pages/EmbedPage.vue index 3a02422..1b5e77b 100644 --- a/apps/viewer/src/pages/EmbedPage.vue +++ b/apps/viewer/src/pages/EmbedPage.vue @@ -1,16 +1,22 @@ + + diff --git a/apps/viewer/src/pages/embed/EmbedFooterBar.vue b/apps/viewer/src/pages/embed/EmbedFooterBar.vue new file mode 100644 index 0000000..2d80556 --- /dev/null +++ b/apps/viewer/src/pages/embed/EmbedFooterBar.vue @@ -0,0 +1,150 @@ + + + + + + diff --git a/apps/viewer/src/pages/embed/EmbedStage.vue b/apps/viewer/src/pages/embed/EmbedStage.vue new file mode 100644 index 0000000..10c17e8 --- /dev/null +++ b/apps/viewer/src/pages/embed/EmbedStage.vue @@ -0,0 +1,210 @@ + + + + + + diff --git a/packages/embed-loader/README-embed.md b/packages/embed-loader/README-embed.md index b4f4b74..e12bd0c 100644 --- a/packages/embed-loader/README-embed.md +++ b/packages/embed-loader/README-embed.md @@ -16,10 +16,37 @@ - `data-slot`(必填):展示位 publicToken;缺失时加载器静默不渲染任何内容。 - `data-lang`(可选):`auto`(缺省,按相册 defaultLocale)| `zh-CN` | `en`,非法值回退 `auto`。 - `data-position`(可选):`right`(缺省)| `left`,悬浮侧。 -- `data-interval`(可选):轮播间隔毫秒,夹取到 3000–30000,缺省 5000。 +- `data-interval`(可选):轮播切换间隔毫秒,夹取到 3000–30000,缺省 5000。 + 仅影响轮播切换,不影响取数轮询(恒 30s 契约基准)。 +- `data-image-fit`(可选,UI-01):`contain`(缺省,4:3 舞台完整显示)| `cover` + (填满裁切)。 +- `data-offset-x`(可选,UI-01):距宿主边缘水平偏移 px,0–120,缺省 16。 +- `data-offset-bottom`(可选,UI-01):距宿主底部垂直偏移 px,0–240,缺省 24。 +- `data-z-index`(可选,UI-01):wrapper 层级,1–2147483000,缺省 1000。宿主页面 + 自身存在高层级元素(如全屏遮罩)时按需调高;把手与卡片共用该层级。 +- `data-poll`(可选,仅测试):加速取数轮询毫秒数,经 iframe URL `&poll=` 透传, + **仅 dev 构建生效**(生产构建忽略)。e2e 用;生产接入不需要。 同一页面引入多个不同 `data-slot` 的标签即嵌入多个展示位;同一 `data-slot` 重复出现时 -后者静默忽略。位置/尺寸/语言等展示位级设置以后台配置为准,data-* 为宿主侧覆盖。 +后者静默忽略。 + +### 配置优先级(UI-01 §5.4) + +任一字段:**宿主 data-\* 显式提供 > 后台展示位设置 > 契约默认值**。未提供的 +data-\* 不视为覆盖,后台设置变更经 iframe 轮询发现后以 `config` 消息动态应用 +(loader 二次校验:仅白名单字段 + 版本单调递增,不信任任意 CSS/HTML)。 + +## 多槽与响应式(UI-01) + +- **同侧多槽纵排**:同一侧(left/right)多个展示位按引入顺序自下而上堆叠, + 间距 8px;视口高度不足时后引入的槽自动收起为把手(不写用户偏好——空间约束 + 非用户选择)。 +- **窗口变化**:`resize` / `orientationchange` 重新评估全部几何;宽度取「配置宽 + 与视口剩余宽」的较小值(极窄视口下限 180px 防溢出)。 +- **窄屏**(viewport <480px):强制小尺寸(280px 宽)且默认收起为把手。 +- **收起把手**:图片图标 + 「照片 / Photos」胶囊按钮,高 44px、有效点击区 + ≥44×44、可键盘操作(Enter/Space)。收起状态会话记忆在 `sessionStorage` + (键 `pl-embed-collapsed:{slot}`),刷新后保持,不反复弹出。 ## CSP 要求(宿主页) @@ -28,14 +55,21 @@ | `script-src` | `{EMBED_ORIGIN}` | 加载 loader.js | | `frame-src` | `{EMBED_ORIGIN}` | 悬浮轮播 iframe(`{EMBED_ORIGIN}/embed/{slot}`) | -组件自身不内联脚本、不加载其他域资源。`frame-ancestors` 由**我们**的 iframe 页在 P5 -配置(限制可嵌入站点),不是宿主的关注点。 +组件自身不内联脚本;iframe 内图片来自对象存储签名域名(`img-src` 无需为组件放开 +项目域)。`frame-ancestors` 由**我们**的 iframe 页配置(限制可嵌入站点),不是宿主 +的关注点。 -## 窄屏行为 +## 生命周期消息(iframe → 宿主) -viewport 宽度 <480px 时:组件强制降为小尺寸(280px 宽)并默认收起为侧边圆形把手, -点击把手展开。会话内收起状态记忆在 `sessionStorage`(键 `pl-embed-collapsed:{slot}`), -刷新后保持收起,不反复弹出。 +| type | 触发 | loader 行为 | +| --- | --- | --- | +| `resize` | iframe 内容高度变化 | wrapper 高度夹取 60–640px 应用 | +| `collapse` | 用户点击 iframe 内 × | 收起为把手 + 会话记忆 | +| `expand` | (保留) | — | +| `config` | 后台设置变更(configVersion 升值) | 校验后动态应用非 data-\* 显式字段 | +| `destroy` | 槽 404/410/停用/解绑(服务端终止展示) | **移除 wrapper + 把手 + 会话键**,不留无效按钮(与用户收起语义不同) | + +宿主 → iframe 仅 `expand`(把手点击,目标源严格为 iframe 源)。 ## 安全模型 @@ -46,19 +80,21 @@ viewport 宽度 <480px 时:组件强制降为小尺寸(280px 宽)并默认 iframe 保有**项目域自己的**源,不会获得宿主页源。 - **postMessage 双向白名单**:loader 仅接受「`event.source` 等于已登记 iframe 的 `contentWindow` 且 `event.origin` 严格等于该 iframe src 源」的消息,且载荷须为 - `{photoliveEmbed: true, type: resize|collapse|expand, ...}`;resize 高度夹取 - 60–640px。向 iframe 发消息(把手展开)同样以 iframe 源为目标,绝不使用 `*`。 -- **零信任宿主数据**:slot token 经 encodeURIComponent 进 URL;lang/position/interval - 白名单校验后拼入查询串。 + `{photoliveEmbed: true, type: resize|collapse|expand|config|destroy, ...}`; + resize 高度夹取 60–640px,config 经版本闸 + 字段白名单校验。向 iframe 发消息 + (把手展开)同样以 iframe 源为目标,绝不使用 `*`。 +- **零信任宿主数据**:slot token 经 encodeURIComponent 进 URL; + lang/position/interval/imageFit/offset/zIndex 白名单或夹取校验后才拼入查询串。 - **存储与 Cookie**:loader 不读写 Cookie、不写宿主 localStorage;仅上述 - sessionStorage 收起标记。无埋点、无对外数据上报。 -- **DOM 足迹**:仅向 body 追加 wrapper 与收起把手两个 div(全部 inline style, - 不注入样式表/类名,不影响宿主 CSS)。 + sessionStorage 收起标记(destroy 时一并清除)。无埋点、无对外数据上报。 +- **DOM 足迹与样式免疫**:仅向 body 追加 wrapper 与收起把手两个 div(全部 inline + style,不注入样式表/类名,不影响宿主 CSS,也不受宿主全局样式影响——敌意 reset + 下组件仍保持 fixed 定位与几何)。 ## 产物与发布 - 发布物即文件本身(不走 npm):`packages/embed-loader/dist/v1/loader.js` - (esbuild bundle + minify,IIFE,target es2018,≤5KB 目标)。 + (esbuild bundle + minify,IIFE,target es2018)。 - **viewer 构建携带**:`pnpm --filter @photo/embed-loader run sync` 将产物拷贝至 `apps/viewer/public/embed/v1/loader.js`(sha256 校验)。viewer 的 vite publicDir 使 dev server 直接可访问、`vite build` 自动将其带入 dist/。两份产物均提交入库, @@ -73,5 +109,6 @@ pnpm --filter @photo/embed-loader run test # params 纯函数单测(vitest pnpm --filter @photo/embed-loader run sync # 拷贝至 viewer public/(含 hash 校验) ``` -宿主页交互行为(iframe 出现/收起/记忆/窄屏)的端到端覆盖见 viewer Playwright -`e2e/embed.spec.ts`(P4b Task 4,宿主 fixture 页)。 +宿主页交互行为(iframe 出现/收起/记忆/窄屏/多槽堆叠/跨源宿主/postMessage 白名单/ +后台设置动态生效)的端到端覆盖见 viewer Playwright `e2e/embed.spec.ts` 与 +`e2e/embed-crossorigin.spec.ts`(宿主 fixture 页 / 3999 端口跨源静态页)。 diff --git a/packages/embed-loader/dist/v1/loader.js b/packages/embed-loader/dist/v1/loader.js index c2a04fb..175d2c7 100644 --- a/packages/embed-loader/dist/v1/loader.js +++ b/packages/embed-loader/dist/v1/loader.js @@ -1 +1 @@ -(()=>{var N=["left","right"],H=["sm","md"],U=["auto","zh-CN","en"],p={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1},I={sm:280,md:340},_=3e3,O=3e4,R=60,V=640;function h(n,l,s){return n.indexOf(l)>=0?l:s}function D(n){let l=parseInt(n,10);return isNaN(l)?p.intervalMs:Math.min(O,Math.max(_,l))}function S(n,l){let s=n||{},u=typeof s.slot=="string"?s.slot.replace(/^\s+|\s+$/g,""):"",c=!!(l&&l.narrow);return{slot:u||null,lang:h(U,s.lang,p.lang),position:h(N,s.position,p.position),intervalMs:D(s.interval),size:c?"sm":h(H,s.size,p.size),collapsedDefault:c?!0:p.collapsedDefault}}function E(n,l,s){return n+"/embed/"+encodeURIComponent(l)+"?lang="+encodeURIComponent(s.lang)+"&pos="+encodeURIComponent(s.position)+"&interval="+String(s.intervalMs)}function M(n){if(n==null)return null;let l=Number(n);return isNaN(l)||!isFinite(l)?null:Math.min(V,Math.max(R,Math.round(l)))}(function(){"use strict";try{let x=function(e){try{return n.sessionStorage.getItem(l+e)==="1"}catch(t){return!1}},b=function(e,t){try{t?n.sessionStorage.setItem(l+e,"1"):n.sessionStorage.removeItem(l+e)}catch(i){}},A=function(){if(document.currentScript&&document.currentScript.src)return document.currentScript;var e=document.querySelectorAll('script[src*="/embed/v1/loader.js"]');return e.length?e[e.length-1]:null},g=function(e){try{return new URL(e.src,n.location.href).origin}catch(t){return null}},z=function(e){return e==="zh-CN"?"\u5C55\u5F00\u7167\u7247\u8F6E\u64AD":"Show photo carousel"},C=function(e){var t=document.createElement("div"),i=e.config.position==="left"?"left":"right";t.setAttribute("role","button"),t.setAttribute("tabindex","0"),t.setAttribute("aria-label",z(e.config.lang));var o=t.style;o.position="fixed",o.bottom="24px",o[i]="16px",o.width="44px",o.height="44px",o.borderRadius="50%",o.background="rgba(15,23,42,0.88)",o.boxShadow="0 2px 10px rgba(0,0,0,0.3)",o.zIndex=s,o.cursor="pointer",o.display="flex",o.alignItems="center",o.justifyContent="center",t.innerHTML='';function r(a){if(a.type==="keydown"){var d=a.key;if(d!=="Enter"&&d!==" "&&d!=="Spacebar")return;a.preventDefault()}w(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(f){}}return t.addEventListener("click",r),t.addEventListener("keydown",r),t},v=function(e){e.collapsed||(e.collapsed=!0,e.wrapper.style.height="0px",e.wrapper.style.boxShadow="none",e.handle||(e.handle=C(e),document.body.appendChild(e.handle)),e.handle.style.display="flex",b(e.config.slot,!0))},w=function(e){e.collapsed&&(e.collapsed=!1,e.wrapper.style.height=e.height+"px",e.wrapper.style.boxShadow="0 4px 16px rgba(0,0,0,0.18)",e.handle&&(e.handle.style.display="none"),b(e.config.slot,!1))},L=function(e,t){var i=document.createElement("div"),o=t.position==="left"?"left":"right",r=i.style;r.position="fixed",r.bottom="24px",r[o]="16px",r.width=I[t.size]+"px",r.height=u+"px",r.zIndex=s,r.borderRadius="12px",r.boxShadow="0 4px 16px rgba(0,0,0,0.18)",r.overflow="hidden";var a=document.createElement("iframe");a.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),a.setAttribute("src",E(e,t.slot,t)),a.setAttribute("referrerpolicy","no-referrer-when-downgrade"),a.setAttribute("title","PhotoLive photos");var d=a.style;d.width="100%",d.height="100%",d.border="0",d.display="block",i.appendChild(a),document.body.appendChild(i);var f={config:t,wrapper:i,frame:a,frameOrigin:g(a)||e,height:u,collapsed:!1,handle:null};c.push(f),(x(t.slot)||t.collapsedDefault)&&v(f)},y=function(){var e=A();if(e){var t=g(e);if(t){var i=!1;try{i=!!n.matchMedia&&n.matchMedia("(max-width: 479px)").matches}catch(d){i=!1}for(var o=document.querySelectorAll('script[src*="/embed/v1/loader.js"]'),r=0;r{var k=["left","right"],H=["sm","md"],L=["auto","zh-CN","en"],R=["contain","cover"],f={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1,imageFit:"contain",offsetX:16,offsetBottom:24,zIndex:1e3},F={sm:280,md:320},W=3e3,G=3e4,C=60,ce=640,Z=0,q=120,$=0,j=240,J=1,P=2147483e3,Q=["resize","collapse","expand","config","destroy"];function x(t,i,o){return t.indexOf(i)>=0?i:o}function m(t,i,o,s){let u=Number(t);return isNaN(u)||!isFinite(u)?s:Math.min(o,Math.max(i,Math.round(u)))}function c(t,i){let o=t[i];return o!=null&&o!==""}function ue(t){return m(t,W,G,f.intervalMs)}function Y(t,i){let o=t||{},s=typeof o.slot=="string"?o.slot.replace(/^\s+|\s+$/g,""):"",u=!!(i&&i.narrow);return{slot:s||null,lang:c(o,"lang")?x(L,o.lang,f.lang):f.lang,position:c(o,"position")?x(k,o.position,f.position):f.position,intervalMs:c(o,"interval")?ue(o.interval):f.intervalMs,size:u?"sm":c(o,"size")?x(H,o.size,f.size):f.size,collapsedDefault:f.collapsedDefault,imageFit:c(o,"imageFit")?x(R,o.imageFit,f.imageFit):f.imageFit,offsetX:c(o,"offsetX")?m(o.offsetX,Z,q,f.offsetX):f.offsetX,offsetBottom:c(o,"offsetBottom")?m(o.offsetBottom,$,j,f.offsetBottom):f.offsetBottom,zIndex:c(o,"zIndex")?m(o.zIndex,J,P,f.zIndex):f.zIndex,pollMs:c(o,"poll")?m(o.poll,250,6e4,null):null,narrow:u,explicit:{lang:c(o,"lang"),position:c(o,"position"),interval:c(o,"interval"),size:!u&&c(o,"size"),imageFit:c(o,"imageFit"),offsetX:c(o,"offsetX"),offsetBottom:c(o,"offsetBottom"),zIndex:c(o,"zIndex")}}}function K(t){return!t||typeof t!="object"?null:{position:x(k,t.position,f.position),size:x(H,t.size,f.size),intervalMs:m(t.intervalMs,W,G,f.intervalMs),lang:x(L,t.lang,f.lang),collapsedDefault:typeof t.collapsedDefault=="boolean"?t.collapsedDefault:f.collapsedDefault,imageFit:x(R,t.imageFit,f.imageFit),offsetX:m(t.offsetX,Z,q,f.offsetX),offsetBottom:m(t.offsetBottom,$,j,f.offsetBottom),zIndex:m(t.zIndex,J,P,f.zIndex)}}function ee(t,i){let o=K(i);if(o===null)return t;let s=t.explicit;return{slot:t.slot,lang:s.lang?t.lang:o.lang,position:s.position?t.position:o.position,intervalMs:s.interval?t.intervalMs:o.intervalMs,size:t.narrow?"sm":s.size?t.size:o.size,collapsedDefault:t.narrow?!0:o.collapsedDefault,imageFit:s.imageFit?t.imageFit:o.imageFit,offsetX:s.offsetX?t.offsetX:o.offsetX,offsetBottom:s.offsetBottom?t.offsetBottom:o.offsetBottom,zIndex:s.zIndex?t.zIndex:o.zIndex,pollMs:t.pollMs,narrow:t.narrow,explicit:s}}function te(t,i){if(!t||typeof t!="object")return null;let o=Number(t.configVersion);if(isNaN(o)||!isFinite(o))return null;let s=i==null?0:Number(i);if(o<=s)return null;let u=K(t.settings);return u===null?null:{configVersion:Math.round(o),settings:u}}function oe(t,i,o){let s=t+"/embed/"+encodeURIComponent(i)+"?lang="+encodeURIComponent(o.lang)+"&pos="+encodeURIComponent(o.position);return o.explicit.interval&&(s+="&interval="+String(o.intervalMs)),o.explicit.imageFit&&(s+="&fit="+encodeURIComponent(o.imageFit)),o.pollMs!==null&&o.pollMs!==void 0&&(s+="&poll="+String(o.pollMs)),s}function re(t){if(t==null)return null;let i=Number(t);return isNaN(i)||!isFinite(i)?null:Math.min(ce,Math.max(C,Math.round(i)))}(function(){"use strict";try{let B=function(e){try{return t.sessionStorage.getItem(i+e)==="1"}catch(r){return!1}},N=function(e,r){try{r?t.sessionStorage.setItem(i+e,"1"):t.sessionStorage.removeItem(i+e)}catch(n){}},S=function(e){try{return t.sessionStorage.getItem(i+e)!==null}catch(r){return!1}},ne=function(){if(document.currentScript&&document.currentScript.src)return document.currentScript;var e=document.querySelectorAll('script[src*="/embed/v1/loader.js"]');return e.length?e[e.length-1]:null},_=function(e){try{return new URL(e.src,t.location.href).origin}catch(r){return null}},ae=function(e){var r=e==="zh-CN";if(!r&&e==="auto")try{r=/^zh/i.test(t.navigator.language||"")}catch(n){r=!1}return r?"\u5C55\u5F00\u7167\u7247\u8F6E\u64AD":"Show photo carousel"},ie=function(e){var r=e==="zh-CN";if(!r&&e==="auto")try{r=/^zh/i.test(t.navigator.language||"")}catch(n){r=!1}return r?"\u7167\u7247":"Photos"},le=function(e){var r=document.createElement("div");r.setAttribute("role","button"),r.setAttribute("tabindex","0"),r.setAttribute("aria-label",ae(e.config.lang));var n=r.style;n.position="fixed",n.height=u+"px",n.minWidth=u+"px",n.boxSizing="border-box",n.padding="0 14px",n.borderRadius=u/2+"px",n.background="rgba(15,23,42,0.88)",n.boxShadow="0 2px 10px rgba(0,0,0,0.3)",n.zIndex=String(e.config.zIndex),n.cursor="pointer",n.display="flex",n.alignItems="center",n.justifyContent="center",n.gap="6px",n.font='500 13px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif',n.color="#ffffff",n.whiteSpace="nowrap",r.innerHTML=''+ie(e.config.lang)+"";function p(a){if(a.type==="keydown"){var l=a.key;if(l!=="Enter"&&l!==" "&&l!=="Spacebar")return;a.preventDefault()}e.userChoice=!0,E(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(d){}}return r.addEventListener("click",p),r.addEventListener("keydown",p),r},fe=function(e){return e.collapsed?u:e.height},I=function(e){var r=e.config.position==="left"?"left":"right",n=r==="left"?"right":"left",p=e.narrow?"sm":e.config.size,a=F[p]||F.md,l=t.innerWidth-e.config.offsetX-8;ll&&(a=l);var d=e.wrapper.style;if(d.width=a+"px",d.zIndex=String(e.config.zIndex),d[r]=e.config.offsetX+"px",d[n]="",e.handle){var h=e.handle.style;h.zIndex=String(e.config.zIndex),h[r]=e.config.offsetX+"px",h[n]=""}},T=function(){for(var e={left:[],right:[]},r=0;r0&&z+C>t.innerHeight&&A(y,!1),y.wrapper.style.bottom=z+"px",y.handle&&(y.handle.style.bottom=z+"px"),d=z}},v=function(){if(w!==null||typeof t.requestAnimationFrame!="function"){T();return}w=t.requestAnimationFrame(function(){w=null,T()})},A=function(e,r){e.collapsed||(e.collapsed=!0,e.wrapper.style.height="0px",e.wrapper.style.boxShadow="none",e.handle||(e.handle=le(e),document.body.appendChild(e.handle),I(e)),e.handle.style.display="flex",r!==!1&&N(e.config.slot,!0))},b=function(e,r){A(e,r),v()},E=function(e){e.collapsed&&(e.collapsed=!1,e.wrapper.style.height=e.height+"px",e.wrapper.style.boxShadow="0 4px 16px rgba(0,0,0,0.18)",e.handle&&(e.handle.style.display="none"),N(e.config.slot,!1),v())},se=function(e){if(!e.destroyed){e.destroyed=!0,e.handle&&e.handle.parentNode&&e.handle.parentNode.removeChild(e.handle),e.wrapper.parentNode&&e.wrapper.parentNode.removeChild(e.wrapper);try{t.sessionStorage.removeItem(i+e.config.slot)}catch(n){}var r=g.indexOf(e);r>=0&&g.splice(r,1),delete M[e.config.slot],g.length===0&&t.removeEventListener("message",O),v()}},pe=function(e,r,n){e.backendVersion=r,e.config=ee(e.config,n),I(e),!e.userChoice&&!S(e.config.slot)&&(e.config.collapsedDefault?b(e,!1):e.narrow||E(e)),v()},de=function(e,r){var n=document.createElement("div"),p=n.style;p.position="fixed",p.bottom=r.offsetBottom+"px",p.height=o+"px",p.borderRadius="12px",p.boxShadow="0 4px 16px rgba(0,0,0,0.18)",p.overflow="hidden";var a=document.createElement("iframe");a.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),a.setAttribute("src",oe(e,r.slot,r)),a.setAttribute("referrerpolicy","no-referrer-when-downgrade"),a.setAttribute("title","PhotoLive photos");var l=a.style;l.width="100%",l.height="100%",l.border="0",l.display="block",n.appendChild(a),document.body.appendChild(n);var d={config:r,wrapper:n,frame:a,frameOrigin:_(a)||e,height:o,collapsed:!1,narrow:r.narrow,handle:null,userChoice:!1,backendVersion:0,destroyed:!1};return g.push(d),I(d),S(r.slot)?B(r.slot)&&b(d,!1):d.narrow&&b(d,!1),d},O=function(e){try{var r=e.data;if(!r||r.photoliveEmbed!==!0)return;var n=r.type;if(Q.indexOf(n)<0)return;for(var p=0;p 引入)。 // 约束:零依赖、零全局(仅版本标记属性)、无 console、一切错误静默不抛(A11 不破坏宿主)。 // 安全模型(postMessage 双向白名单 / sandbox 取舍)见同包 README-embed.md。 +// UI-01(§5):imageFit/offsetX/offsetBottom/zIndex 配置链(data-* 显式 > iframe +// config 消息 > 默认)、收起把手图标+文字、destroy 生命周期、同侧多槽纵排、 +// 窗口 resize/横竖屏重排、宽度取配置宽与视口剩余宽的较小值。 import { parseEmbedConfig, buildEmbedUrl, clampHeight, + mergeBackendConfig, + parseConfigMessage, SIZE_WIDTH, + MESSAGE_TYPES, + HEIGHT_MIN, } from './params.js'; (function () { @@ -19,8 +26,10 @@ import { w.__photoliveEmbedV1 = '1'; var COLLAPSE_KEY = 'pl-embed-collapsed:'; - var Z_INDEX = '2147483000'; var DEFAULT_HEIGHT = 96; // 首条 resize 消息前的占位高度 + var STACK_GAP = 8; // 同侧多槽纵排间距(UI-01 §5.4) + var HANDLE_MIN = 44; // 收起把手有效高度/最小宽度(§5.3 ≥44×44) + var MIN_WRAPPER_WIDTH = 180; // 极窄视口防溢出下限 function readCollapsed(slot) { try { @@ -37,6 +46,14 @@ import { /* Safari 隐私模式等 storage 异常:静默放弃记忆 */ } } + /** 会话内是否已有用户收起/展开选择(无选择时后台 collapsedDefault 才生效)。 */ + function hasChoice(slot) { + try { + return w.sessionStorage.getItem(COLLAPSE_KEY + slot) !== null; + } catch { + return false; + } + } // 自发现:currentScript(defer 下同步执行仍可用)→ 兜底最后一个匹配标签。 function selfScript() { @@ -54,42 +71,67 @@ import { var instances = []; var seenSlots = {}; + var relayoutFrame = null; function handleLabel(lang) { - if (lang === 'zh-CN') return '展开照片轮播'; - if (lang === 'en') return 'Show photo carousel'; - return 'Show photo carousel'; + var zh = lang === 'zh-CN'; + if (!zh && lang === 'auto') { + try { + zh = /^zh/i.test(w.navigator.language || ''); + } catch { + zh = false; + } + } + return zh ? '展开照片轮播' : 'Show photo carousel'; + } + function handleText(lang) { + var zh = lang === 'zh-CN'; + if (!zh && lang === 'auto') { + try { + zh = /^zh/i.test(w.navigator.language || ''); + } catch { + zh = false; + } + } + return zh ? '照片' : 'Photos'; } + // 收起把手:图片图标 + Photos/照片 文案(§5.1),胶囊形,高 44px、 + // 有效点击区 ≥44×44;全部 inline style(零宿主 CSS 污染)。 function createHandle(inst) { var h = document.createElement('div'); - var side = inst.config.position === 'left' ? 'left' : 'right'; h.setAttribute('role', 'button'); h.setAttribute('tabindex', '0'); h.setAttribute('aria-label', handleLabel(inst.config.lang)); var s = h.style; s.position = 'fixed'; - s.bottom = '24px'; - s[side] = '16px'; - s.width = '44px'; - s.height = '44px'; - s.borderRadius = '50%'; + s.height = HANDLE_MIN + 'px'; + s.minWidth = HANDLE_MIN + 'px'; + s.boxSizing = 'border-box'; + s.padding = '0 14px'; + s.borderRadius = HANDLE_MIN / 2 + 'px'; s.background = 'rgba(15,23,42,0.88)'; s.boxShadow = '0 2px 10px rgba(0,0,0,0.3)'; - s.zIndex = Z_INDEX; + s.zIndex = String(inst.config.zIndex); s.cursor = 'pointer'; s.display = 'flex'; s.alignItems = 'center'; s.justifyContent = 'center'; + s.gap = '6px'; + s.font = '500 13px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif'; + s.color = '#ffffff'; + s.whiteSpace = 'nowrap'; h.innerHTML = - ''; + '' + + '' + handleText(inst.config.lang) + ''; function activate(ev) { if (ev.type === 'keydown') { var k = ev.key; if (k !== 'Enter' && k !== ' ' && k !== 'Spacebar') return; ev.preventDefault(); } + inst.userChoice = true; expandInstance(inst); // 通知 iframe 内部恢复轮播/可见行为;目标源严格限定 iframe 源(绝不 '*')。 try { @@ -108,7 +150,70 @@ import { return h; } - function collapseInstance(inst) { + /** 布局占位高度:收起态计把手 44px(多槽纵排的堆叠依据)。 */ + function effectiveHeight(inst) { + return inst.collapsed ? HANDLE_MIN : inst.height; + } + + /** wrapper 几何:宽度取配置宽与视口剩余宽的较小值(§5.4 无溢出)。 */ + function applyWrapperBox(inst) { + var side = inst.config.position === 'left' ? 'left' : 'right'; + var other = side === 'left' ? 'right' : 'left'; + var sizeKey = inst.narrow ? 'sm' : inst.config.size; + var width = SIZE_WIDTH[sizeKey] || SIZE_WIDTH.md; + var available = w.innerWidth - inst.config.offsetX - 8; + if (available < MIN_WRAPPER_WIDTH) available = MIN_WRAPPER_WIDTH; + if (width > available) width = available; + var ws = inst.wrapper.style; + ws.width = width + 'px'; + ws.zIndex = String(inst.config.zIndex); + ws[side] = inst.config.offsetX + 'px'; + ws[other] = ''; + if (inst.handle) { + var hs = inst.handle.style; + hs.zIndex = String(inst.config.zIndex); + hs[side] = inst.config.offsetX + 'px'; + hs[other] = ''; + } + } + + // 同侧多槽纵排(§5.4):按登记顺序自下而上堆叠,间距 8px;可用高度不足时 + // 后续槽收起(不写用户偏好——空间约束非用户选择)。 + function relayout() { + var sides = { left: [], right: [] }; + for (var i = 0; i < instances.length; i++) { + var inst = instances[i]; + sides[inst.config.position === 'left' ? 'left' : 'right'].push(inst); + } + var keys = ['left', 'right']; + for (var s = 0; s < keys.length; s++) { + var list = sides[keys[s]]; + var cursorBottom = 0; + for (var j = 0; j < list.length; j++) { + var it = list[j]; + var bottom = j === 0 ? it.config.offsetBottom : cursorBottom + effectiveHeight(list[j - 1]) + STACK_GAP; + // 可用高度不足:后续槽收起(不写用户偏好——空间约束非用户选择)。 + if (j > 0 && bottom + HEIGHT_MIN > w.innerHeight) setCollapsed(it, false); + it.wrapper.style.bottom = bottom + 'px'; + if (it.handle) it.handle.style.bottom = bottom + 'px'; + cursorBottom = bottom; + } + } + } + + function scheduleRelayout() { + if (relayoutFrame !== null || typeof w.requestAnimationFrame !== 'function') { + relayout(); + return; + } + relayoutFrame = w.requestAnimationFrame(function () { + relayoutFrame = null; + relayout(); + }); + } + + /** 收起核心(不触发重排调度——relayout 内部空间不足收起时避免再排)。 */ + function setCollapsed(inst, persist) { if (inst.collapsed) return; inst.collapsed = true; inst.wrapper.style.height = '0px'; @@ -116,9 +221,15 @@ import { if (!inst.handle) { inst.handle = createHandle(inst); document.body.appendChild(inst.handle); + applyWrapperBox(inst); } inst.handle.style.display = 'flex'; - writeCollapsed(inst.config.slot, true); + if (persist !== false) writeCollapsed(inst.config.slot, true); + } + + function collapseInstance(inst, persist) { + setCollapsed(inst, persist); + scheduleRelayout(); } function expandInstance(inst) { @@ -128,18 +239,47 @@ import { inst.wrapper.style.boxShadow = '0 4px 16px rgba(0,0,0,0.18)'; if (inst.handle) inst.handle.style.display = 'none'; writeCollapsed(inst.config.slot, false); + scheduleRelayout(); + } + + /** 服务端终止展示(停用/解绑/404/410):移除卡片与把手,不留无效按钮。 */ + function destroyInstance(inst) { + if (inst.destroyed) return; + inst.destroyed = true; + if (inst.handle && inst.handle.parentNode) inst.handle.parentNode.removeChild(inst.handle); + if (inst.wrapper.parentNode) inst.wrapper.parentNode.removeChild(inst.wrapper); + try { + w.sessionStorage.removeItem(COLLAPSE_KEY + inst.config.slot); + } catch { + /* 静默 */ + } + var i = instances.indexOf(inst); + if (i >= 0) instances.splice(i, 1); + delete seenSlots[inst.config.slot]; + if (instances.length === 0) w.removeEventListener('message', onMessage); + scheduleRelayout(); + } + + /** 后台设置应用(config 消息已过 parseConfigMessage 版本闸):非 data-* + * 显式字段立即生效(位置/偏移/层级/尺寸 → wrapper;collapsedDefault 在无 + * 用户选择时应用)。 */ + function applyConfig(inst, version, settings) { + inst.backendVersion = version; + inst.config = mergeBackendConfig(inst.config, settings); + applyWrapperBox(inst); + if (!inst.userChoice && !hasChoice(inst.config.slot)) { + if (inst.config.collapsedDefault) collapseInstance(inst, false); + else if (!inst.narrow) expandInstance(inst); + } + scheduleRelayout(); } function createInstance(origin, config) { var wrapper = document.createElement('div'); - var side = config.position === 'left' ? 'left' : 'right'; var ws = wrapper.style; ws.position = 'fixed'; - ws.bottom = '24px'; - ws[side] = '16px'; - ws.width = SIZE_WIDTH[config.size] + 'px'; + ws.bottom = config.offsetBottom + 'px'; ws.height = DEFAULT_HEIGHT + 'px'; - ws.zIndex = Z_INDEX; ws.borderRadius = '12px'; ws.boxShadow = '0 4px 16px rgba(0,0,0,0.18)'; ws.overflow = 'hidden'; @@ -172,21 +312,32 @@ import { frameOrigin: originOf(frame) || origin, height: DEFAULT_HEIGHT, collapsed: false, + narrow: config.narrow, handle: null, + userChoice: false, + backendVersion: 0, + destroyed: false, }; instances.push(inst); - // 收起记忆 / 窄屏默认收起:会话内不反复弹出(需求 9)。 - if (readCollapsed(config.slot) || config.collapsedDefault) collapseInstance(inst); + applyWrapperBox(inst); + // 收起记忆 / 无用户选择时的默认(窄屏强制收起 + 后台 collapsedDefault 经 + // config 消息到达;会话内用户选择优先,不反复弹出)。 + if (hasChoice(config.slot)) { + if (readCollapsed(config.slot)) collapseInstance(inst, false); + } else if (inst.narrow) { + collapseInstance(inst, false); + } + return inst; } - // postMessage 白名单:仅 photoliveEmbed===true 且 type∈{resize,collapse,expand}, - // 且 event.source 命中已登记 iframe 且 event.origin 严格等于该 iframe 的源。 - w.addEventListener('message', function (event) { + // postMessage 白名单:仅 photoliveEmbed===true 且 type∈MESSAGE_TYPES,且 + // event.source 命中已登记 iframe 且 event.origin 严格等于该 iframe 的源。 + function onMessage(event) { try { var data = event.data; if (!data || data.photoliveEmbed !== true) return; var type = data.type; - if (type !== 'resize' && type !== 'collapse' && type !== 'expand') return; + if (MESSAGE_TYPES.indexOf(type) < 0) return; for (var i = 0; i < instances.length; i++) { var inst = instances[i]; if (!inst.frame.contentWindow || event.source !== inst.frame.contentWindow) continue; @@ -196,30 +347,57 @@ import { if (h !== null) { inst.height = h; if (!inst.collapsed) inst.wrapper.style.height = h + 'px'; + scheduleRelayout(); } } else if (type === 'collapse') { - collapseInstance(inst); - } else { + inst.userChoice = true; // iframe × 按钮是用户选择(会话记忆 + 抵御后续默认) + collapseInstance(inst, true); + } else if (type === 'expand') { + inst.userChoice = true; expandInstance(inst); + } else if (type === 'config') { + var parsed = parseConfigMessage(data, inst.backendVersion); + if (parsed !== null) applyConfig(inst, parsed.configVersion, parsed.settings); + } else { + destroyInstance(inst); } return; } } catch { /* 静默 */ } - }); + } + + function isNarrow() { + try { + return !!w.matchMedia && w.matchMedia('(max-width: 479px)').matches; + } catch { + return false; + } + } + + // 响应式(§5.4):窗口 resize / 横竖屏切换 → 重新评估窄屏降级与全部几何。 + function onViewportChange() { + var narrow = isNarrow(); + for (var i = 0; i < instances.length; i++) { + var inst = instances[i]; + if (inst.narrow !== narrow) { + inst.narrow = narrow; + if (narrow && !inst.userChoice && !hasChoice(inst.config.slot)) { + collapseInstance(inst, false); + } + } + applyWrapperBox(inst); + } + scheduleRelayout(); + } function init() { var self = selfScript(); if (!self) return; var embedOrigin = originOf(self); if (!embedOrigin) return; - var narrow = false; - try { - narrow = !!w.matchMedia && w.matchMedia('(max-width: 479px)').matches; - } catch { - narrow = false; - } + var narrow = isNarrow(); // 扫描全部 loader 标签:单标签单槽为主路径,多标签(多槽/集中引入)一并覆盖; // 同 data-slot 重复出现时后者静默忽略。 var tags = document.querySelectorAll('script[src*="/embed/v1/loader.js"]'); @@ -230,6 +408,9 @@ import { seenSlots[config.slot] = true; createInstance(embedOrigin, config); } + w.addEventListener('message', onMessage); + w.addEventListener('resize', onViewportChange); + w.addEventListener('orientationchange', onViewportChange); } if (document.body) { diff --git a/packages/embed-loader/src/params.js b/packages/embed-loader/src/params.js index 3e673b4..7fcec3b 100644 --- a/packages/embed-loader/src/params.js +++ b/packages/embed-loader/src/params.js @@ -2,10 +2,14 @@ // 纯函数参数模块:loader(esbuild 打包内联)与 vitest 单测共用的唯一参数源。 // 取值域与缺省严格对齐契约 EmbedSlotSettings(packages/contracts/openapi.yaml), // 非法/缺省一律静默回退——loader 对宿主零报错(A11「不破坏宿主」)。 +// UI-01:新增 imageFit/offsetX/offsetBottom/zIndex;配置优先级 +// data-* 显式 > 后台设置(iframe config 消息)> 契约默认。「data-* 缺省」不是 +// 显式覆盖——缺省字段允许后台设置生效(explicit 表只记真正提供的字段)。 export const POSITION_VALUES = ['left', 'right']; export const SIZE_VALUES = ['sm', 'md']; export const LANG_VALUES = ['auto', 'zh-CN', 'en']; +export const IMAGE_FIT_VALUES = ['contain', 'cover']; export const DEFAULTS = { position: 'right', @@ -13,10 +17,14 @@ export const DEFAULTS = { intervalMs: 5000, lang: 'auto', collapsedDefault: false, + imageFit: 'contain', + offsetX: 16, + offsetBottom: 24, + zIndex: 1000, }; -/** size → 宿主侧 wrapper 宽度(px)。sm 为窄屏降级档。 */ -export const SIZE_WIDTH = { sm: 280, md: 340 }; +/** size → 宿主侧 wrapper 宽度(px)。sm 为窄屏降级档;UI-01 §5.1 md 统一 320。 */ +export const SIZE_WIDTH = { sm: 280, md: 320 }; export const INTERVAL_MIN = 3000; export const INTERVAL_MAX = 30000; @@ -25,21 +33,44 @@ export const INTERVAL_MAX = 30000; export const HEIGHT_MIN = 60; export const HEIGHT_MAX = 640; +/** UI-01 新增偏移/层级取值域(对齐契约 EmbedSlotSettings)。 */ +export const OFFSET_X_MIN = 0; +export const OFFSET_X_MAX = 120; +export const OFFSET_BOTTOM_MIN = 0; +export const OFFSET_BOTTOM_MAX = 240; +export const Z_INDEX_MIN = 1; +export const Z_INDEX_MAX = 2147483000; + +/** loader ↔ iframe postMessage 载荷 type 白名单(双向严格校验的取值集)。 */ +export const MESSAGE_TYPES = ['resize', 'collapse', 'expand', 'config', 'destroy']; + function oneOf(allowed, value, fallback) { return allowed.indexOf(value) >= 0 ? value : fallback; } +/** 数值夹取:非有限数值/缺失 → fallback;否则取整并夹取到 [min, max]。 */ +function clampInt(value, min, max, fallback) { + const n = Number(value); + if (isNaN(n) || !isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.round(n))); +} + +function provided(ds, key) { + const value = ds[key]; + return value !== undefined && value !== null && value !== ''; +} + /** 轮播间隔(ms):解析失败取缺省,超界夹取到 [3000, 30000]。 */ export function normalizeInterval(value) { - const n = parseInt(value, 10); - if (isNaN(n)) return DEFAULTS.intervalMs; - return Math.min(INTERVAL_MAX, Math.max(INTERVAL_MIN, n)); + return clampInt(value, INTERVAL_MIN, INTERVAL_MAX, DEFAULTS.intervalMs); } /** - * 解析宿主 script 标签 data-* 配置(dataset 形如 {slot, lang, position, interval})。 - * opts.narrow=true(viewport <480px)时强制 sm 尺寸 + 默认收起(计划评审缺口:移动端可用)。 - * data-slot 缺失/空白 → slot 为 null,调用方须静默 no-op。 + * 解析宿主 script 标签 data-* 配置(dataset 形如 {slot, lang, position, + * interval, imageFit, offsetX, offsetBottom, zIndex, poll})。 + * opts.narrow=true(viewport <480px)时强制 sm 尺寸 + 默认收起(移动端可用)。 + * 返回的 explicit 表标记哪些字段被 data-* 真正提供(缺省 ≠ 覆盖,后台设置 + * 仍可生效,方案 §5.4)。data-slot 缺失/空白 → slot 为 null,调用方静默 no-op。 */ export function parseEmbedConfig(dataset, opts) { const ds = dataset || {}; @@ -47,27 +78,114 @@ export function parseEmbedConfig(dataset, opts) { const narrow = !!(opts && opts.narrow); return { slot: slot || null, - lang: oneOf(LANG_VALUES, ds.lang, DEFAULTS.lang), - position: oneOf(POSITION_VALUES, ds.position, DEFAULTS.position), - intervalMs: normalizeInterval(ds.interval), - size: narrow ? 'sm' : oneOf(SIZE_VALUES, ds.size, DEFAULTS.size), - collapsedDefault: narrow ? true : DEFAULTS.collapsedDefault, + lang: provided(ds, 'lang') ? oneOf(LANG_VALUES, ds.lang, DEFAULTS.lang) : DEFAULTS.lang, + position: provided(ds, 'position') + ? oneOf(POSITION_VALUES, ds.position, DEFAULTS.position) + : DEFAULTS.position, + intervalMs: provided(ds, 'interval') ? normalizeInterval(ds.interval) : DEFAULTS.intervalMs, + size: narrow ? 'sm' : provided(ds, 'size') ? oneOf(SIZE_VALUES, ds.size, DEFAULTS.size) : DEFAULTS.size, + collapsedDefault: DEFAULTS.collapsedDefault, + imageFit: provided(ds, 'imageFit') + ? oneOf(IMAGE_FIT_VALUES, ds.imageFit, DEFAULTS.imageFit) + : DEFAULTS.imageFit, + offsetX: provided(ds, 'offsetX') ? clampInt(ds.offsetX, OFFSET_X_MIN, OFFSET_X_MAX, DEFAULTS.offsetX) : DEFAULTS.offsetX, + offsetBottom: provided(ds, 'offsetBottom') + ? clampInt(ds.offsetBottom, OFFSET_BOTTOM_MIN, OFFSET_BOTTOM_MAX, DEFAULTS.offsetBottom) + : DEFAULTS.offsetBottom, + zIndex: provided(ds, 'zIndex') ? clampInt(ds.zIndex, Z_INDEX_MIN, Z_INDEX_MAX, DEFAULTS.zIndex) : DEFAULTS.zIndex, + // dev/e2e 轮询加速(viewer dev 构建读取;生产构建忽略),显式才透传。 + pollMs: provided(ds, 'poll') ? clampInt(ds.poll, 250, 60000, null) : null, + narrow: narrow, + explicit: { + lang: provided(ds, 'lang'), + position: provided(ds, 'position'), + interval: provided(ds, 'interval'), + size: !narrow && provided(ds, 'size'), + imageFit: provided(ds, 'imageFit'), + offsetX: provided(ds, 'offsetX'), + offsetBottom: provided(ds, 'offsetBottom'), + zIndex: provided(ds, 'zIndex'), + }, }; } -/** iframe src:{origin}/embed/{slot}?lang=&pos=&interval=(槽 token 编码防注入)。 */ +/** + * 后台展示设置(iframe config 消息 / 公开端点 settings)逐字段校验归一: + * 枚举白名单外回退缺省、数值越界夹取、布尔/形状异常回退缺省。整体非对象 → null + * (调用方忽略该消息)。 + */ +export function normalizeBackendSettings(value) { + if (!value || typeof value !== 'object') return null; + return { + position: oneOf(POSITION_VALUES, value.position, DEFAULTS.position), + size: oneOf(SIZE_VALUES, value.size, DEFAULTS.size), + intervalMs: clampInt(value.intervalMs, INTERVAL_MIN, INTERVAL_MAX, DEFAULTS.intervalMs), + lang: oneOf(LANG_VALUES, value.lang, DEFAULTS.lang), + collapsedDefault: typeof value.collapsedDefault === 'boolean' ? value.collapsedDefault : DEFAULTS.collapsedDefault, + imageFit: oneOf(IMAGE_FIT_VALUES, value.imageFit, DEFAULTS.imageFit), + offsetX: clampInt(value.offsetX, OFFSET_X_MIN, OFFSET_X_MAX, DEFAULTS.offsetX), + offsetBottom: clampInt(value.offsetBottom, OFFSET_BOTTOM_MIN, OFFSET_BOTTOM_MAX, DEFAULTS.offsetBottom), + zIndex: clampInt(value.zIndex, Z_INDEX_MIN, Z_INDEX_MAX, DEFAULTS.zIndex), + }; +} + +/** + * 后台设置合并进当前配置:data-* 显式覆盖的字段保持宿主值,其余字段采用 + * 后台值(loader 侧二次校验后的归一结果)。narrow 强制(sm/默认收起)优先于 + * 后台值。返回新配置对象(不改入参)。 + */ +export function mergeBackendConfig(config, settings) { + const s = normalizeBackendSettings(settings); + if (s === null) return config; + const e = config.explicit; + return { + slot: config.slot, + lang: e.lang ? config.lang : s.lang, + position: e.position ? config.position : s.position, + intervalMs: e.interval ? config.intervalMs : s.intervalMs, + size: config.narrow ? 'sm' : e.size ? config.size : s.size, + collapsedDefault: config.narrow ? true : s.collapsedDefault, + imageFit: e.imageFit ? config.imageFit : s.imageFit, + offsetX: e.offsetX ? config.offsetX : s.offsetX, + offsetBottom: e.offsetBottom ? config.offsetBottom : s.offsetBottom, + zIndex: e.zIndex ? config.zIndex : s.zIndex, + pollMs: config.pollMs, + narrow: config.narrow, + explicit: e, + }; +} + +/** + * iframe config 消息裁决:configVersion 必须为有限数值且高于当前版本(旧请求 + * 不得覆盖新配置),settings 归一校验。非法/过期 → null(忽略)。 + */ +export function parseConfigMessage(data, currentVersion) { + if (!data || typeof data !== 'object') return null; + const version = Number(data.configVersion); + if (isNaN(version) || !isFinite(version)) return null; + const local = currentVersion === null || currentVersion === undefined ? 0 : Number(currentVersion); + if (version <= local) return null; + const settings = normalizeBackendSettings(data.settings); + if (settings === null) return null; + return { configVersion: Math.round(version), settings }; +} + +/** iframe src:{origin}/embed/{slot}?lang=&pos=&interval=(槽 token 编码防注入)。 + * lang/interval/fit/poll 仅在 data-* 显式提供时拼接——缺省不覆盖后台设置 + * (iframe 侧以响应 settings 为准,方案 §5.4)。 */ export function buildEmbedUrl(origin, slot, config) { - return ( + let url = origin + '/embed/' + encodeURIComponent(slot) + '?lang=' + encodeURIComponent(config.lang) + '&pos=' + - encodeURIComponent(config.position) + - '&interval=' + - String(config.intervalMs) - ); + encodeURIComponent(config.position); + if (config.explicit.interval) url += '&interval=' + String(config.intervalMs); + if (config.explicit.imageFit) url += '&fit=' + encodeURIComponent(config.imageFit); + if (config.pollMs !== null && config.pollMs !== undefined) url += '&poll=' + String(config.pollMs); + return url; } /** resize 高度夹取:非有限数值/缺失 → null(忽略该消息)。 */ diff --git a/packages/embed-loader/src/params.test.js b/packages/embed-loader/src/params.test.js index 8195718..c66c620 100644 --- a/packages/embed-loader/src/params.test.js +++ b/packages/embed-loader/src/params.test.js @@ -1,16 +1,22 @@ // packages/embed-loader/src/params.test.js -// 纯函数单测:data-* 解析/缺省回退/夹取、窄屏强制、URL 拼接、高度夹取。 -// DOM/宿主交互行为由 Task 4 Playwright 宿主页覆盖(计划注明 node 模拟 DOM 太薄)。 +// 纯函数单测:data-* 解析/缺省回退/夹取、显式覆盖表(缺省 ≠ 覆盖)、窄屏强制、 +// URL 拼接、高度夹取、后台设置归一/合并优先级、config 消息裁决、消息类型白名单。 +// DOM/宿主交互行为由 Playwright 宿主页覆盖(计划注明 node 模拟 DOM 太薄)。 import { describe, expect, it } from 'vitest'; import { + MESSAGE_TYPES, buildEmbedUrl, clampHeight, + mergeBackendConfig, + normalizeBackendSettings, normalizeInterval, + parseConfigMessage, parseEmbedConfig, + SIZE_WIDTH, } from './params.js'; describe('parseEmbedConfig', () => { - it('空 dataset 全缺省(slot null → 调用方 no-op)', () => { + it('空 dataset 全缺省(slot null → 调用方 no-op)+ UI-01 新字段默认', () => { const c = parseEmbedConfig({}); expect(c).toEqual({ slot: null, @@ -19,6 +25,22 @@ describe('parseEmbedConfig', () => { intervalMs: 5000, size: 'md', collapsedDefault: false, + imageFit: 'contain', + offsetX: 16, + offsetBottom: 24, + zIndex: 1000, + pollMs: null, + narrow: false, + explicit: { + lang: false, + position: false, + interval: false, + size: false, + imageFit: false, + offsetX: false, + offsetBottom: false, + zIndex: false, + }, }); }); @@ -28,12 +50,15 @@ describe('parseEmbedConfig', () => { expect(parseEmbedConfig({ slot: ' ' }).slot).toBeNull(); }); - it('lang/position 白名单外静默回退缺省', () => { + it('lang/position/imageFit 白名单外静默回退缺省', () => { expect(parseEmbedConfig({ slot: 'a', lang: 'zh-CN' }).lang).toBe('zh-CN'); expect(parseEmbedConfig({ slot: 'a', lang: 'en' }).lang).toBe('en'); expect(parseEmbedConfig({ slot: 'a', lang: 'fr' }).lang).toBe('auto'); expect(parseEmbedConfig({ slot: 'a', position: 'left' }).position).toBe('left'); expect(parseEmbedConfig({ slot: 'a', position: 'top' }).position).toBe('right'); + expect(parseEmbedConfig({ slot: 'a', imageFit: 'cover' }).imageFit).toBe('cover'); + expect(parseEmbedConfig({ slot: 'a', imageFit: 'fill' }).imageFit).toBe('contain'); + expect(parseEmbedConfig({ slot: 'a', imageFit: 'cover' }).explicit.imageFit).toBe(true); }); it('interval 解析失败取缺省、越界夹取到 [3000, 30000]', () => { @@ -45,35 +70,222 @@ describe('parseEmbedConfig', () => { expect(parseEmbedConfig({ slot: 'a', interval: '2500' }).intervalMs).toBe(3000); }); + it('offsetX/offsetBottom/zIndex 数值夹取到契约范围,非法回退缺省', () => { + expect(parseEmbedConfig({ slot: 'a', offsetX: '200' }).offsetX).toBe(120); + expect(parseEmbedConfig({ slot: 'a', offsetX: '-5' }).offsetX).toBe(0); + expect(parseEmbedConfig({ slot: 'a', offsetX: '64' }).offsetX).toBe(64); + expect(parseEmbedConfig({ slot: 'a', offsetBottom: '999' }).offsetBottom).toBe(240); + expect(parseEmbedConfig({ slot: 'a', offsetBottom: 'abc' }).offsetBottom).toBe(24); + expect(parseEmbedConfig({ slot: 'a', zIndex: '99999999999' }).zIndex).toBe(2147483000); + expect(parseEmbedConfig({ slot: 'a', zIndex: '0' }).zIndex).toBe(1); + expect(parseEmbedConfig({ slot: 'a', zIndex: '3000' }).zIndex).toBe(3000); + }); + it('窄屏(<480px):强制 sm 尺寸 + 默认收起,覆盖 dataset 尺寸语义', () => { const c = parseEmbedConfig({ slot: 'a', size: 'md' }, { narrow: true }); expect(c.size).toBe('sm'); + expect(c.narrow).toBe(true); + expect(c.explicit.size).toBe(false); + }); + + it('md 尺寸宽 320(UI-01 §5.1 视觉变更),sm 280', () => { + expect(SIZE_WIDTH.md).toBe(320); + expect(SIZE_WIDTH.sm).toBe(280); + }); +}); + +describe('explicit 覆盖表(缺省 ≠ 显式覆盖)', () => { + it('仅真正提供的字段进 explicit(空串视为缺省)', () => { + const c = parseEmbedConfig({ slot: 'a', offsetX: '40', lang: '', imageFit: 'cover' }); + expect(c.explicit.offsetX).toBe(true); + expect(c.explicit.imageFit).toBe(true); + expect(c.explicit.lang).toBe(false); + expect(c.explicit.interval).toBe(false); + }); +}); + +describe('normalizeBackendSettings(iframe config 消息 / 公开端 settings)', () => { + it('合法输入原样通过', () => { + expect( + normalizeBackendSettings({ + position: 'left', + size: 'sm', + intervalMs: 8000, + lang: 'en', + collapsedDefault: true, + imageFit: 'cover', + offsetX: 40, + offsetBottom: 80, + zIndex: 2000, + }), + ).toEqual({ + position: 'left', + size: 'sm', + intervalMs: 8000, + lang: 'en', + collapsedDefault: true, + imageFit: 'cover', + offsetX: 40, + offsetBottom: 80, + zIndex: 2000, + }); + }); + + it('非法值逐字段回退缺省(枚举/布尔/数值夹取)', () => { + expect( + normalizeBackendSettings({ + position: 'top', + size: 'xl', + intervalMs: 'x', + lang: 1, + collapsedDefault: 'yes', + imageFit: null, + offsetX: 999, + offsetBottom: -1, + zIndex: 0, + }), + ).toEqual({ + position: 'right', + size: 'md', + intervalMs: 5000, + lang: 'auto', + collapsedDefault: false, + imageFit: 'contain', + offsetX: 120, + offsetBottom: 0, + zIndex: 1, + }); + }); + + it('非对象输入返回 null(整体忽略)', () => { + expect(normalizeBackendSettings(null)).toBeNull(); + expect(normalizeBackendSettings('x')).toBeNull(); + expect(normalizeBackendSettings(undefined)).toBeNull(); + }); +}); + +describe('mergeBackendConfig(优先级:data-* 显式 > 后台 > 默认)', () => { + const backend = { + position: 'left', + size: 'sm', + intervalMs: 9000, + lang: 'en', + collapsedDefault: true, + imageFit: 'cover', + offsetX: 48, + offsetBottom: 96, + zIndex: 2500, + }; + + it('未显式覆盖的字段采用后台值', () => { + const c = mergeBackendConfig(parseEmbedConfig({ slot: 'a' }), backend); + expect(c.position).toBe('left'); + expect(c.intervalMs).toBe(9000); + expect(c.imageFit).toBe('cover'); + expect(c.offsetX).toBe(48); + expect(c.offsetBottom).toBe(96); + expect(c.zIndex).toBe(2500); expect(c.collapsedDefault).toBe(true); }); - it('非窄屏时 collapsedDefault 恒 false(收起默认由 iframe 侧按服务端设置下发)', () => { - expect(parseEmbedConfig({ slot: 'a' }, { narrow: false }).collapsedDefault).toBe(false); + it('data-* 显式覆盖的字段不被后台刷新冲掉', () => { + const c = mergeBackendConfig( + parseEmbedConfig({ slot: 'a', position: 'right', offsetX: '10', imageFit: 'contain' }), + backend, + ); + expect(c.position).toBe('right'); + expect(c.offsetX).toBe(10); + expect(c.imageFit).toBe('contain'); + expect(c.intervalMs).toBe(9000); // 未显式 → 后台 + }); + + it('后台缺字段/非法 → 契约默认(旧投影行兼容)', () => { + const c = mergeBackendConfig(parseEmbedConfig({ slot: 'a' }), { position: 'left' }); + expect(c.imageFit).toBe('contain'); + expect(c.offsetX).toBe(16); + expect(c.zIndex).toBe(1000); + expect(c.collapsedDefault).toBe(false); + }); + + it('后台非法输入 → 返回原配置(忽略整条消息)', () => { + const base = parseEmbedConfig({ slot: 'a', offsetX: '10' }); + expect(mergeBackendConfig(base, null)).toBe(base); + }); + + it('窄屏强制优先于后台:size 恒 sm、collapsedDefault 恒 true', () => { + const c = mergeBackendConfig(parseEmbedConfig({ slot: 'a' }, { narrow: true }), { + ...backend, + size: 'md', + collapsedDefault: false, + }); + expect(c.size).toBe('sm'); + expect(c.collapsedDefault).toBe(true); + }); +}); + +describe('parseConfigMessage(版本闸 + 校验)', () => { + const settings = { + position: 'right', + size: 'md', + intervalMs: 5000, + lang: 'auto', + collapsedDefault: false, + imageFit: 'contain', + offsetX: 16, + offsetBottom: 24, + zIndex: 1000, + }; + + it('高版本 + 合法 settings → 归一结果', () => { + const r = parseConfigMessage({ photoliveEmbed: true, type: 'config', configVersion: 3, settings }, 1); + expect(r).toEqual({ configVersion: 3, settings }); + }); + + it('版本不高(旧请求/等值)→ null(旧配置不得覆盖新配置)', () => { + expect(parseConfigMessage({ configVersion: 1, settings }, 1)).toBeNull(); + expect(parseConfigMessage({ configVersion: 2, settings }, 5)).toBeNull(); + }); + + it('首条消息(当前版本 0/null)接受', () => { + expect(parseConfigMessage({ configVersion: 1, settings }, 0)).not.toBeNull(); + expect(parseConfigMessage({ configVersion: 1, settings }, null)).not.toBeNull(); + }); + + it('形状非法(缺版本/缺 settings/非对象)→ null', () => { + expect(parseConfigMessage({ settings }, 0)).toBeNull(); + expect(parseConfigMessage({ configVersion: 2 }, 0)).toBeNull(); + expect(parseConfigMessage(null, 0)).toBeNull(); + expect(parseConfigMessage('x', 0)).toBeNull(); }); }); describe('buildEmbedUrl', () => { - it('拼接 lang/pos/interval 查询参数', () => { - const url = buildEmbedUrl('https://ov.example.com', 'tok', { - lang: 'zh-CN', - position: 'left', - intervalMs: 7000, - }); - expect(url).toBe('https://ov.example.com/embed/tok?lang=zh-CN&pos=left&interval=7000'); + it('显式字段拼接 interval/fit;缺省不拼(后台设置经 iframe 响应生效)', () => { + const explicit = parseEmbedConfig({ slot: 'a', interval: '7000', imageFit: 'cover' }); + expect(buildEmbedUrl('https://ov.example.com', 'tok', explicit)).toBe( + 'https://ov.example.com/embed/tok?lang=auto&pos=right&interval=7000&fit=cover', + ); + const bare = parseEmbedConfig({ slot: 'a' }); + expect(buildEmbedUrl('https://ov.example.com', 'tok', bare)).toBe( + 'https://ov.example.com/embed/tok?lang=auto&pos=right', + ); + }); + + it('poll 显式时透传(dev/e2e 加速;生产构建忽略)', () => { + const c = parseEmbedConfig({ slot: 'a', poll: '2000' }); + expect(buildEmbedUrl('https://ov.example.com', 'tok', c)).toContain('&poll=2000'); }); it('slot token 编码(防路径注入)', () => { - const url = buildEmbedUrl('https://ov.example.com', 'a/b?c=1', { - lang: 'auto', - position: 'right', - intervalMs: 5000, - }); + const url = buildEmbedUrl('https://ov.example.com', 'a/b?c=1', parseEmbedConfig({ slot: 'a/b?c=1' })); expect(url.startsWith('https://ov.example.com/embed/a%2Fb%3Fc%3D1?')).toBe(true); - expect(url).toContain('lang=auto&pos=right&interval=5000'); + expect(url).toContain('lang=auto&pos=right'); + }); +}); + +describe('MESSAGE_TYPES(消息类型白名单)', () => { + it('resize/collapse/expand/config/destroy 全集', () => { + expect([...MESSAGE_TYPES].sort()).toEqual(['collapse', 'config', 'destroy', 'expand', 'resize'].sort()); }); });