feat(embed,viewer): UI-01 complete carousel, loader config and accessibility
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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<SeededFeed> {
|
||||
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) => `<script src="${VIEWER_ORIGIN}/embed/v1/loader.js" data-slot="${slot}" defer></script>`)
|
||||
.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 `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>cross-origin host fixture</title>
|
||||
${hostile ? `<style>\n ${hostile}\n </style>` : ''}
|
||||
</head>
|
||||
<body style="min-height: 2000px">
|
||||
<h1>Host page (cross-origin, port ${HOST_PORT})</h1>
|
||||
${tags}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
currentHtml = html;
|
||||
await page.goto(HOST_ORIGIN);
|
||||
}
|
||||
|
||||
/** wrapper(iframe 父 div)inline 样式属性值。 */
|
||||
function wrapperStyle(page: Page, prop: string): Promise<string> {
|
||||
return page.locator('iframe').evaluate(
|
||||
(frame, p) => ((frame.parentElement as HTMLElement).style as unknown as Record<string, string>)[p],
|
||||
prop,
|
||||
);
|
||||
}
|
||||
|
||||
test.describe.serial('embed loader on a cross-origin host (UI-01 Task 6)', () => {
|
||||
test.beforeAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(HOST_PORT, '127.0.0.1', () => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await new Promise<void>((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');
|
||||
});
|
||||
});
|
||||
+177
-41
@@ -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<SeededFeed> {
|
||||
return { slotToken: slot.token, slotId: slot.id, albumToken: album.publicToken, photoIds };
|
||||
}
|
||||
|
||||
/** loader wrapper(iframe 父 div)inline height(px 字串;''=未置)。 */
|
||||
function wrapperHeight(page: Page): Promise<string> {
|
||||
return page.locator('iframe').evaluate((frame) => (frame.parentElement as HTMLElement).style.height);
|
||||
/** loader wrapper(iframe 父 div)inline 样式属性值(''=未置)。 */
|
||||
function wrapperStyle(page: Page, prop: string): Promise<string> {
|
||||
return page.locator('iframe').evaluate(
|
||||
(frame, p) => ((frame.parentElement as HTMLElement).style as unknown as Record<string, string>)[p],
|
||||
prop,
|
||||
);
|
||||
}
|
||||
|
||||
test.describe.serial('embed carousel widget (P4b Task 4)', () => {
|
||||
async function activeSrc(page: Page): Promise<string | null> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,348 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import type { PublicEmbedPhoto } from '../api/client';
|
||||
import { preloadEmbedImage } from '../embed/images';
|
||||
import { createViewerI18n } from '../i18n';
|
||||
import EmbedCarousel from '../pages/embed/EmbedCarousel.vue';
|
||||
import EmbedStage from '../pages/embed/EmbedStage.vue';
|
||||
import EmbedFooterBar from '../pages/embed/EmbedFooterBar.vue';
|
||||
|
||||
// 嵌入轮播组件(UI-01 Task 6):切换编排(intervalMs 计时独立于取数轮询、
|
||||
// 暂停条件全集、reducedMotion 默认暂停 + 手动切换)、Stage(contain/cover、
|
||||
// decode 失败保留上一张换下一张、连续失败暂停重试、过渡层不可聚焦)、
|
||||
// FooterBar(页码/入口/暂停)。图片预热模块替身控制成败(jsdom 无真实解码)。
|
||||
vi.mock('../embed/images', () => ({
|
||||
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<void> {
|
||||
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<InstanceType<typeof EmbedFooterBar>['$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/暂停
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<HTMLElement | null>;
|
||||
/** 测试注入;缺省真实 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<EmbedFeedStatus>('loading');
|
||||
const album = ref<PublicEmbedAlbum | null>(null);
|
||||
const photos = ref<PublicEmbedPhoto[]>([]);
|
||||
const index = ref(0);
|
||||
const resolvedLocale = ref<ViewerLocale | null>(null);
|
||||
/** 后台展示设置(UI-01;旧 ov 投影缺字段 → null,按默认渲染)。 */
|
||||
const settings = ref<EmbedSlotSettings | null>(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<typeof setTimeout> | null = null;
|
||||
let carouselTimer: ReturnType<typeof setInterval> | 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<void> {
|
||||
async function pollNow(forceRefresh = false): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '下载图片',
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// apps/viewer/src/pages/EmbedPage.vue —— 嵌入轮播 iframe 页(P4b Task 4,
|
||||
// 路由 /embed/:slotToken,独立精简布局:无顶栏/页脚,App.vue 按 meta.layout
|
||||
// 裸渲染)。宿主 loader 以 iframe 载入本页(280-340px 小视口设计):
|
||||
// · 数据/轮询/暂停/收起编排见 ../embed/useEmbedFeed.ts;
|
||||
// · 照片按 intervalMs 交叉淡入淡出(仅 thumb 小图省流量,需求 4.8);
|
||||
// · 点击照片 → window.open viewer 单图深链(noopener 隔离 opener,用户手势);
|
||||
// · 空相册 → 「照片即将上线」小卡;槽 404 → 无 UI(feed.status gone 隐藏根);
|
||||
// · 语言独立于宿主与 localStorage:lang 查询(auto→相册 defaultLocale)。
|
||||
import { computed, ref, watch } from 'vue';
|
||||
// apps/viewer/src/pages/EmbedPage.vue —— 嵌入轮播 iframe 页薄壳(路由 /embed/:slotToken
|
||||
// + 数据/lifecycle 编排;P4b Task 4 创建,UI-01 Task 6 拆分)。独立精简布局:
|
||||
// 无顶栏/页脚,App.vue 按 meta.layout 裸渲染。宿主 loader 以 iframe 载入本页
|
||||
// (280-320px 小视口设计):
|
||||
// · 数据/轮询/暂停/收起/destroy 编排见 ../embed/useEmbedFeed.ts;
|
||||
// · 切换逻辑/舞台/底栏见 ./embed/(EmbedCarousel → EmbedStage + EmbedFooterBar);
|
||||
// · 头部:通用「活动照片」标题 + 收起 ×(全状态可用,含空态——§5.3 断网
|
||||
// 无缓存可收起;有效点击区 ≥44×44);
|
||||
// · 空相册 → 「照片即将上线」小卡;槽 404 → destroy 后无 UI(feed.status gone);
|
||||
// · 语言独立于宿主与 localStorage:lang 查询(auto→相册 defaultLocale);
|
||||
// · interval/fit 查询为宿主 data-* 显式覆盖(loader 仅显式透传);后台设置
|
||||
// 经响应 settings 生效(显式 > 后台 > 默认,§5.4)。
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useEmbedFeed } from '../embed/useEmbedFeed';
|
||||
import { clampEmbedIntervalMs, EMBED_CAROUSEL_DEFAULT_MS } from '../embed/feed';
|
||||
import EmbedCarousel from './embed/EmbedCarousel.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t, locale } = useI18n();
|
||||
@@ -24,12 +30,19 @@ const slotToken = computed(() => String(route.params.slotToken ?? ''));
|
||||
const langParam = queryText(route.query.lang) ?? 'auto';
|
||||
const intervalText = queryText(route.query.interval);
|
||||
const intervalParamMs = intervalText === null ? null : Number.parseInt(intervalText, 10);
|
||||
const fitParam = queryText(route.query.fit);
|
||||
// dev/e2e 轮询加速(跨源宿主可用;生产构建忽略——loader 侧同样仅显式透传)。
|
||||
const pollText = queryText(route.query.poll);
|
||||
const pollParamMs =
|
||||
import.meta.env.DEV && pollText !== null ? Number.parseInt(pollText, 10) : null;
|
||||
|
||||
const rootEl = ref<HTMLElement | null>(null);
|
||||
const feed = useEmbedFeed({
|
||||
slotToken: slotToken.value,
|
||||
langParam,
|
||||
intervalParamMs: Number.isFinite(intervalParamMs as number) ? intervalParamMs : null,
|
||||
pollParamMs: Number.isFinite(pollParamMs as number) && (pollParamMs as number) > 0
|
||||
? (pollParamMs as number)
|
||||
: null,
|
||||
rootEl,
|
||||
});
|
||||
|
||||
@@ -42,6 +55,44 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// reducedMotion:默认暂停 + 手动切换 + 取消过渡(§5.2);随系统设置动态联动。
|
||||
const reducedMotion = ref(false);
|
||||
let motionMedia: MediaQueryList | null = null;
|
||||
function onMotionChange(): void {
|
||||
reducedMotion.value = motionMedia?.matches ?? false;
|
||||
}
|
||||
onMounted(() => {
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
motionMedia = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
onMotionChange();
|
||||
motionMedia.addEventListener('change', onMotionChange);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
motionMedia?.removeEventListener('change', onMotionChange);
|
||||
});
|
||||
|
||||
// 轮播间隔:显式查询覆盖(夹取)→ 后台设置 → 缺省 5000。
|
||||
const carouselIntervalMs = computed(
|
||||
() => clampEmbedIntervalMs(Number.isFinite(intervalParamMs as number) ? intervalParamMs : null) ??
|
||||
feed.settings?.intervalMs ??
|
||||
EMBED_CAROUSEL_DEFAULT_MS,
|
||||
);
|
||||
|
||||
// 图片适配:显式查询覆盖 → 后台设置 → 缺省 contain。
|
||||
const imageFit = computed<'contain' | 'cover'>(() => {
|
||||
if (fitParam === 'contain' || fitParam === 'cover') return fitParam;
|
||||
return feed.settings?.imageFit ?? 'contain';
|
||||
});
|
||||
|
||||
const albumUrl = computed(() => {
|
||||
const albumToken = feed.album?.publicToken;
|
||||
if (albumToken === undefined) return null;
|
||||
const url = new URL(`/a/${encodeURIComponent(albumToken)}`, window.location.origin);
|
||||
url.searchParams.set('lang', feed.resolvedLocale ?? 'zh-CN');
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
function openPhoto(photoId: string): void {
|
||||
const albumToken = feed.album?.publicToken;
|
||||
if (albumToken === undefined) return;
|
||||
@@ -54,42 +105,35 @@ function openPhoto(photoId: string): void {
|
||||
|
||||
<template>
|
||||
<div v-if="feed.status !== 'gone'" ref="rootEl" class="embed-root" :class="{ 'is-empty': feed.status !== 'ready' }">
|
||||
<div v-if="feed.album !== null" class="embed-head">
|
||||
<span class="embed-title" :title="feed.album.title">{{ feed.album.title }}</span>
|
||||
<div class="embed-head">
|
||||
<span class="embed-heading">{{ t('embed.widgetTitle') }}</span>
|
||||
<button type="button" class="embed-close" :aria-label="t('embed.close')" @click="feed.requestCollapse()">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M18.3 5.71 12 12.01l-6.3-6.3-1.4 1.41 6.29 6.3-6.3 6.3 1.42 1.4 6.29-6.29 6.3 6.3 1.4-1.42-6.3-6.29 6.3-6.3z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="embed-stage">
|
||||
<button
|
||||
v-for="photo in feed.photos"
|
||||
:key="photo.photoId"
|
||||
type="button"
|
||||
class="embed-photo"
|
||||
:class="{ active: photo.photoId === feed.photos[feed.index]?.photoId }"
|
||||
:aria-label="t('embed.openPhoto')"
|
||||
@click="openPhoto(photo.photoId)"
|
||||
>
|
||||
<img :src="photo.thumb.url" :width="photo.width" :height="photo.height" alt="" decoding="async">
|
||||
</button>
|
||||
<EmbedCarousel
|
||||
v-if="feed.status === 'ready'"
|
||||
:photos="feed.photos"
|
||||
:interval-ms="carouselIntervalMs"
|
||||
:image-fit="imageFit"
|
||||
:active="feed.active"
|
||||
:reduced-motion="reducedMotion"
|
||||
:album-url="albumUrl"
|
||||
:album-title="feed.album?.title ?? ''"
|
||||
@open-photo="openPhoto"
|
||||
/>
|
||||
<!-- 加载骨架/空态:等比例舞台占位(§5.3 初次加载静态骨架 + 收起入口可用)。 -->
|
||||
<div v-else class="embed-stage embed-stage--placeholder">
|
||||
<p v-if="feed.status === 'empty'" class="embed-empty" role="note">{{ t('embed.comingSoon') }}</p>
|
||||
</div>
|
||||
<div v-if="feed.photos.length > 1" class="embed-dots" aria-hidden="true">
|
||||
<span
|
||||
v-for="photo in feed.photos"
|
||||
:key="photo.photoId"
|
||||
class="embed-dot"
|
||||
:class="{ on: photo.photoId === feed.photos[feed.index]?.photoId }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* iframe 内容适配宿主卡片:html/body 无背景(App.vue 基线未设背景,天然透亮,
|
||||
* 圆角/阴影由宿主 loader wrapper 提供),根元素白底小卡 280-340px 设计。 */
|
||||
* 圆角/阴影由宿主 loader wrapper 提供),根元素白底小卡 280-320px 设计。 */
|
||||
.embed-root {
|
||||
background: #fff;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
|
||||
@@ -101,22 +145,23 @@ function openPhoto(photoId: string): void {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 6px;
|
||||
padding: 6px 6px 6px 10px;
|
||||
}
|
||||
.embed-title {
|
||||
font-size: 0.8125rem;
|
||||
.embed-heading {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #5e6878;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.embed-close {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
/* 视觉小(22px)但有效点击区 ≥44×44(§5.3)。 */
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: -12px -4px -12px 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
@@ -128,34 +173,16 @@ function openPhoto(photoId: string): void {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
.embed-stage {
|
||||
.embed-close:focus-visible {
|
||||
outline: 2px solid #1a73e8;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.embed-stage--placeholder {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.embed-photo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: #f3f4f6;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
/* 叠层轮播:仅当前帧可命中(透明帧不拦截指针,避免遮挡后续帧的点击)。 */
|
||||
pointer-events: none;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
.embed-photo.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.embed-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.embed-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -166,24 +193,4 @@ function openPhoto(photoId: string): void {
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.embed-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 6px 0 8px;
|
||||
}
|
||||
.embed-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #d1d5db;
|
||||
}
|
||||
.embed-dot.on {
|
||||
background: #1f2937;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.embed-photo {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<!-- apps/viewer/src/pages/embed/EmbedCarousel.vue —— 嵌入轮播切换编排
|
||||
(UI-01 §5.2)。职责:
|
||||
· 纯本地切换:intervalMs 计时推进索引(与取数轮询彻底解耦——轮询基准恒
|
||||
30s,见 useEmbedFeed;队列增删经 photos prop 差分到达);
|
||||
· 暂停条件全集:用户手动暂停 / 指针悬停 / 键盘焦点在组件内 / 页面隐藏 /
|
||||
组件收起(active=false)/ decode 连续失败保护;恢复仅限非用户手动暂停
|
||||
(自动暂停离开即恢复,手动暂停不自动恢复);
|
||||
· reducedMotion:默认暂停 + 手动上一张/下一张(Stage 取消过渡);
|
||||
· decode 失败:换下一张重试;连续 3 次失败暂停一个间隔后自动重试循环。
|
||||
region + aria-roledescription=carousel(§5.3)。 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { PublicEmbedPhoto } from '../../api/client';
|
||||
import EmbedStage from './EmbedStage.vue';
|
||||
import EmbedFooterBar from './EmbedFooterBar.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
photos: PublicEmbedPhoto[];
|
||||
intervalMs: number;
|
||||
imageFit: 'contain' | 'cover';
|
||||
active: boolean;
|
||||
reducedMotion: boolean;
|
||||
albumUrl: string | null;
|
||||
albumTitle: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'open-photo', photoId: string): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const index = ref(0);
|
||||
/** 用户手动暂停(不自动恢复)。reducedMotion 默认暂停(§5.2)。 */
|
||||
const userPaused = ref(props.reducedMotion);
|
||||
const hoverPaused = ref(false);
|
||||
const focusPaused = ref(false);
|
||||
const hiddenPaused = ref(false);
|
||||
const failurePaused = ref(false);
|
||||
let consecutiveFailures = 0;
|
||||
let failureTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let carouselTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const paused = computed(
|
||||
() =>
|
||||
userPaused.value ||
|
||||
hoverPaused.value ||
|
||||
focusPaused.value ||
|
||||
hiddenPaused.value ||
|
||||
failurePaused.value ||
|
||||
!props.active,
|
||||
);
|
||||
const running = computed(() => !paused.value && props.photos.length > 1);
|
||||
|
||||
function stopTimer(): void {
|
||||
if (carouselTimer !== null) {
|
||||
clearInterval(carouselTimer);
|
||||
carouselTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function restartTimer(): void {
|
||||
stopTimer();
|
||||
if (!running.value) return;
|
||||
carouselTimer = setInterval(() => advance(1), props.intervalMs);
|
||||
}
|
||||
|
||||
function advance(dir: 1 | -1): void {
|
||||
const len = props.photos.length;
|
||||
if (len === 0) return;
|
||||
index.value = (index.value + dir + len) % len;
|
||||
}
|
||||
|
||||
/** Stage decode 失败:保留当前帧,尝试下一张;连续 3 次暂停一个间隔再重试。
|
||||
* 触发暂停的失败不再推进——推进会立刻触发下一次 display(再失败再推进),
|
||||
* 全队列不可用时形成无界微任务循环冻结页面;暂停后经 failureTimer → interval
|
||||
* 有界重试(恢复后首个周期可能耗在已展示帧的幂等切换上,可接受)。 */
|
||||
function onDecodeFailed(): void {
|
||||
consecutiveFailures += 1;
|
||||
if (consecutiveFailures >= 3) {
|
||||
failurePaused.value = true;
|
||||
if (failureTimer !== null) clearTimeout(failureTimer);
|
||||
failureTimer = setTimeout(() => {
|
||||
failureTimer = null;
|
||||
consecutiveFailures = 0;
|
||||
failurePaused.value = false;
|
||||
}, props.intervalMs);
|
||||
return;
|
||||
}
|
||||
advance(1);
|
||||
}
|
||||
|
||||
function onDecoded(): void {
|
||||
consecutiveFailures = 0;
|
||||
}
|
||||
|
||||
function togglePause(): void {
|
||||
userPaused.value = !userPaused.value;
|
||||
}
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
hiddenPaused.value = document.hidden;
|
||||
}
|
||||
|
||||
watch(running, restartTimer, { immediate: true });
|
||||
watch(
|
||||
() => props.intervalMs,
|
||||
() => restartTimer(),
|
||||
);
|
||||
watch(
|
||||
() => props.photos.length,
|
||||
(len) => {
|
||||
if (index.value >= len) index.value = 0;
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
hiddenPaused.value = document.hidden;
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopTimer();
|
||||
if (failureTimer !== null) clearTimeout(failureTimer);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="embed-carousel"
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
:aria-label="t('embed.carouselLabel')"
|
||||
@pointerenter="hoverPaused = true"
|
||||
@pointerleave="hoverPaused = false"
|
||||
@focusin="focusPaused = true"
|
||||
@focusout="focusPaused = false"
|
||||
>
|
||||
<EmbedStage
|
||||
:photos="photos"
|
||||
:index="index"
|
||||
:image-fit="imageFit"
|
||||
:reduced-motion="reducedMotion"
|
||||
@decode-failed="onDecodeFailed"
|
||||
@decoded="onDecoded"
|
||||
@open-photo="(id) => emit('open-photo', id)"
|
||||
/>
|
||||
<EmbedFooterBar
|
||||
:title="albumTitle"
|
||||
:index="index"
|
||||
:count="photos.length"
|
||||
:paused="userPaused"
|
||||
:reduced-motion="reducedMotion"
|
||||
:album-url="albumUrl"
|
||||
@toggle-pause="togglePause"
|
||||
@prev="advance(-1)"
|
||||
@next="advance(1)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,150 @@
|
||||
<!-- apps/viewer/src/pages/embed/EmbedFooterBar.vue —— 嵌入轮播底部信息条
|
||||
(UI-01 §5.1)。职责:
|
||||
· 相册标题(最多两行,超出省略——完整标题在相册页可见);
|
||||
· 页码「当前序号 / 当前轮播队列数量」(padStart 两位;队列数 ≠ 相册照片
|
||||
总数,§5.2 不冒充);
|
||||
· 暂停/播放切换;reducedMotion 时的手动上一张/下一张;
|
||||
· 「查看相册」入口:<a target=_blank rel="noopener,noreferrer">(用户手势
|
||||
触发新页、noopener 隔离 opener,lang 跟随 resolvedLocale)。
|
||||
所有控件 button/a 语义 + 中英文 aria-label + :focus-visible 可见焦点。 -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
index: number;
|
||||
count: number;
|
||||
paused: boolean;
|
||||
reducedMotion: boolean;
|
||||
albumUrl: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-pause'): void;
|
||||
(e: 'prev'): void;
|
||||
(e: 'next'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
const indicator = computed(() => `${pad(props.index + 1)} / ${pad(props.count)}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="embed-foot">
|
||||
<p class="embed-title" :title="title">{{ title }}</p>
|
||||
<div class="embed-foot-row">
|
||||
<span class="embed-indicator" aria-live="polite">{{ indicator }}</span>
|
||||
<div class="embed-foot-actions">
|
||||
<button
|
||||
v-if="reducedMotion"
|
||||
type="button"
|
||||
class="embed-foot-btn"
|
||||
:aria-label="t('embed.prev')"
|
||||
@click="emit('prev')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="reducedMotion"
|
||||
type="button"
|
||||
class="embed-foot-btn"
|
||||
:aria-label="t('embed.next')"
|
||||
@click="emit('next')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="embed-foot-btn"
|
||||
:aria-label="paused ? t('embed.play') : t('embed.pause')"
|
||||
@click="emit('toggle-pause')"
|
||||
>
|
||||
<svg v-if="paused" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M6 5h4v14H6zm8 0h4v14h-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
v-if="albumUrl !== null"
|
||||
class="embed-foot-link"
|
||||
:href="albumUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ t('embed.viewAlbum') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.embed-foot {
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
.embed-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
/* 最多两行,超出省略(§5.2 标题允许两行)。 */
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
.embed-foot-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 26px;
|
||||
}
|
||||
.embed-indicator {
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #5e6878;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.embed-foot-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.embed-foot-btn,
|
||||
.embed-foot-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #1f2937;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.embed-foot-btn:hover,
|
||||
.embed-foot-link:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.embed-foot-btn:focus-visible,
|
||||
.embed-foot-link:focus-visible {
|
||||
outline: 2px solid #1a73e8;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<!-- apps/viewer/src/pages/embed/EmbedStage.vue —— 嵌入轮播 4:3 图片舞台
|
||||
(UI-01 §5.1/§5.2)。职责:
|
||||
· 固定 4:3 比例、中性深色底(完整显示合影/海报,高度不随图片横竖跳变);
|
||||
· object-fit 按配置(contain 缺省 / cover),经 inline style 应用;
|
||||
· 仅当前图 + 下一张预加载(preloadEmbedImage 不入 DOM);DOM 最多两层
|
||||
(当前 + 过渡层),不请求整队列;
|
||||
· 新图 decode 成功才切换(250-350ms 交叉淡入;失败保留上一张并上报
|
||||
Carousel 尝试后续);reducedMotion 取消过渡直接切换;
|
||||
· 可访问性:当前帧 role=group + aria-roledescription=slide + 序号标签
|
||||
(§5.3 不编造画面描述),唯一可交互开图按钮在 Tab 序;过渡层
|
||||
aria-hidden + tabindex=-1 + pointer-events:none。 -->
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { PublicEmbedPhoto } from '../../api/client';
|
||||
import { preloadEmbedImage } from '../../embed/images';
|
||||
|
||||
const props = defineProps<{
|
||||
photos: PublicEmbedPhoto[];
|
||||
index: number;
|
||||
imageFit: 'contain' | 'cover';
|
||||
reducedMotion: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'decode-failed'): void;
|
||||
(e: 'decoded'): void;
|
||||
(e: 'open-photo', photoId: string): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
/** 已展示帧(decode 成功后提交)。 */
|
||||
const current = ref<PublicEmbedPhoto | null>(null);
|
||||
/** 过渡层(淡入中;对辅助技术与指针隐藏)。 */
|
||||
const incoming = ref<PublicEmbedPhoto | null>(null);
|
||||
const incomingVisible = ref(false);
|
||||
let swapSeq = 0;
|
||||
let commitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let disposed = false;
|
||||
|
||||
function targetPhoto(): PublicEmbedPhoto | null {
|
||||
return props.photos[props.index] ?? null;
|
||||
}
|
||||
|
||||
/** 下一张预加载(纯预热,不入 DOM;失败静默——切换时会再校验)。 */
|
||||
function preloadNext(): void {
|
||||
const len = props.photos.length;
|
||||
const cur = current.value;
|
||||
if (len < 2 || cur === null) return;
|
||||
const at = props.photos.findIndex((photo) => photo.photoId === cur.photoId);
|
||||
const next = at >= 0 ? props.photos[(at + 1) % len] : undefined;
|
||||
if (next !== undefined && next.photoId !== cur.photoId) {
|
||||
void preloadEmbedImage(next.thumb.url).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function commitIncoming(): void {
|
||||
if (incoming.value === null) return;
|
||||
current.value = incoming.value;
|
||||
incoming.value = null;
|
||||
incomingVisible.value = false;
|
||||
preloadNext();
|
||||
}
|
||||
|
||||
async function display(next: PublicEmbedPhoto | null): Promise<void> {
|
||||
const seq = ++swapSeq;
|
||||
if (next === null) {
|
||||
current.value = null;
|
||||
incoming.value = null;
|
||||
incomingVisible.value = false;
|
||||
return;
|
||||
}
|
||||
const cur = current.value;
|
||||
// 幂等:目标即当前(含签名刷新后同 id 同 URL)→ 不重切。
|
||||
if (cur !== null && cur.photoId === next.photoId && cur.thumb.url === next.thumb.url) return;
|
||||
try {
|
||||
await preloadEmbedImage(next.thumb.url);
|
||||
} catch {
|
||||
emit('decode-failed'); // 保留上一张;Carousel 换后续图片重试
|
||||
return;
|
||||
}
|
||||
if (disposed || seq !== swapSeq) return;
|
||||
emit('decoded');
|
||||
if (cur === null) {
|
||||
current.value = next; // 首张:直接展示(无过渡层)
|
||||
preloadNext();
|
||||
return;
|
||||
}
|
||||
incoming.value = next;
|
||||
incomingVisible.value = false;
|
||||
await nextTick();
|
||||
if (disposed || seq !== swapSeq) return;
|
||||
if (props.reducedMotion) {
|
||||
commitIncoming();
|
||||
return;
|
||||
}
|
||||
// 交叉淡入:下一帧置可见(CSS 300ms)→ 过渡结束提交;340ms 定时兜底。
|
||||
requestAnimationFrame(() => {
|
||||
incomingVisible.value = true;
|
||||
});
|
||||
if (commitTimer !== null) clearTimeout(commitTimer);
|
||||
commitTimer = setTimeout(() => {
|
||||
commitTimer = null;
|
||||
if (seq === swapSeq) commitIncoming();
|
||||
}, 340);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.index, props.photos] as const,
|
||||
() => {
|
||||
void display(targetPhoto());
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
if (commitTimer !== null) clearTimeout(commitTimer);
|
||||
});
|
||||
|
||||
function onTransitionEnd(): void {
|
||||
if (commitTimer !== null) clearTimeout(commitTimer);
|
||||
commitTimer = null;
|
||||
commitIncoming();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="embed-stage" :class="{ 'is-reduced': reducedMotion }">
|
||||
<div
|
||||
v-if="current !== null"
|
||||
class="embed-photo active"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
:aria-label="t('embed.slideOf', { n: index + 1, m: photos.length })"
|
||||
>
|
||||
<img
|
||||
:src="current.thumb.url"
|
||||
:width="current.width"
|
||||
:height="current.height"
|
||||
:alt="t('embed.photoAlt', { n: index + 1 })"
|
||||
:style="{ objectFit: imageFit }"
|
||||
decoding="async"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="embed-photo-open"
|
||||
:aria-label="t('embed.openPhoto')"
|
||||
@click="emit('open-photo', current.photoId)"
|
||||
/>
|
||||
</div>
|
||||
<!-- 过渡层:decode 成功后淡入;不进 Tab 序/读屏,不拦截指针。 -->
|
||||
<div
|
||||
v-if="incoming !== null"
|
||||
class="embed-photo embed-photo--next"
|
||||
:class="{ 'is-visible': incomingVisible }"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
@transitionend="onTransitionEnd"
|
||||
>
|
||||
<img
|
||||
:src="incoming.thumb.url"
|
||||
:width="incoming.width"
|
||||
:height="incoming.height"
|
||||
alt=""
|
||||
:style="{ objectFit: imageFit }"
|
||||
decoding="async"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.embed-stage {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
/* 中性深色舞台:contain 完整显示时衬底(§5.2)。 */
|
||||
background: #1a1a2e;
|
||||
}
|
||||
.embed-photo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.embed-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.embed-photo-open {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.embed-photo--next {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
.embed-photo--next.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.is-reduced .embed-photo--next {
|
||||
transition: none;
|
||||
}
|
||||
</style>
|
||||
@@ -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 端口跨源静态页)。
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -3,11 +3,18 @@
|
||||
// 由 viewer 静态服务,宿主以 <script src="{EMBED_ORIGIN}/embed/v1/loader.js" data-slot> 引入)。
|
||||
// 约束:零依赖、零全局(仅版本标记属性)、无 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 =
|
||||
'<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false">' +
|
||||
'<path fill="%23ffffff" d="M9 2 7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"/></svg>';
|
||||
'<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">' +
|
||||
'<path fill="#ffffff" d="M9 2 7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"/></svg>' +
|
||||
'<span>' + handleText(inst.config.lang) + '</span>';
|
||||
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) {
|
||||
|
||||
@@ -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(忽略该消息)。 */
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user