feat(viewer): embed carousel iframe with visibility pause

P4b Task 4:宿主 loader 以 iframe 载入的轮播页(/embed/:slotToken,裸布局)。

- src/embed/feed.ts:纯函数集——A22 旧响应裁决(dispatch seq + configVersion
  双闸:过期/回看丢弃、等值差分合并、升值全量重置)、照片队列差分(新照片
  追加队尾、缺席淘汰、thumb 签名刷新)、interval/高度夹取、轮询退避;
- src/embed/useEmbedFeed.ts:取数编排(If-None-Match → 304 零处理)、轮询
  (查询 interval 覆盖 → EMBED_POLL_INTERVAL_MS,连续 5 失败 ×5 退避,成功
  归零;404/410 静默 collapse 隐藏自身)、A23 不可见暂停(document 隐藏 +
  IntersectionObserver 滚出视口 + 本地收起;恢复立即校准)、轮播 intervalMs
  切换;__embedPollMs e2e 注入点;
- src/embed/postMessage.ts:上行 resize/collapse(referrer origin 收敛,缺失
  退 '*'——载荷无敏感数据,宿主侧 loader source+origin 双校验)、下行 expand
  (event.source === window.parent 白名单);
- EmbedPage.vue:交叉淡入淡出(仅 thumb 小图省流量)、点击 window.open 单图
  深链(noopener)、计数圆点、「照片即将上线」空态卡、×收起;lang 查询
  (auto→相册 defaultLocale)驱动嵌入词条子集;
- api client:fetchPublicEmbedSlot(304/ETag 分支,不走 apiRequest);
- e2e:宿主夹具页 public/__test__/embed-host.html(仅测试资产)+ embed.spec 7
  用例(轮换/新标签深链/收起记忆/窄屏 sm/轮询增量/换绑重置/空态/幽灵槽);
  helpers 增展示位 admin API 种子 + flushOutbox;
- vitest 18 用例(裁决矩阵/队列差分/夹取/退避/304/可见性状态机)。

配套修正:api CORS exposedHeaders ETag(跨源浏览器可读,304 语义成立);
contracts 新增浏览器安全子路径 ./constants(根出口 re-export internal-sync
的 node:crypto 会打进浏览器包);eslint ignore embed-loader 产物拷贝
(Task 3 起 viewer lint 红,先修)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 23:02:07 +08:00
parent ddb4c8175f
commit 8f2c21d25a
16 changed files with 1433 additions and 5 deletions
+226
View File
@@ -0,0 +1,226 @@
import { expect, test, type Page } from '@playwright/test';
import {
cleanupSeededAlbums,
createEmbedSlot,
flushOutbox,
flushSync,
makePublicToken,
seedAlbum,
seedPhoto,
updateEmbedSlot,
} from './helpers';
// P4b Task 4 嵌入轮播组件 Playwright(真实后端:cn 8080 + ov 8081 + viewer dev
// 5175 由 playwright.config webServer 拉起或复用)。宿主夹具页
// /__test__/embed-host.html(viewer publicDir 静态资产,仅测试用)引入真实
// loader.js(/embed/v1/loader.js)→ iframe 载 /embed/{slot} → 走 ov 公开端点。
// 展示位种子经 cn admin API 真实路径(seedAdmin + POST/PATCH),slot.updated
// 事件以 flushOutbox 排空(不随相册 bootstrap 走,见 helpers 注)。
// 断言锚点:loader 侧 wrapper(iframe 父 div,inline style)/ 把手(div[role=button]);
// iframe 内 viewer 侧 CSS 类名。CSP:夹具页不设(宿主真实 CSP 要求见
// packages/embed-loader/README-embed.md,此仅文档化)。
const MINUTE = 60_000;
interface SeededFeed {
slotToken: string;
slotId: string;
albumToken: string;
photoIds: string[];
}
/** 相册(zh 默认 + en 双语)+ 3 张错峰发布照片 + 展示位绑定 + ov 投影。 */
async function seedFeed(alias: string): Promise<SeededFeed> {
const album = await seedAlbum({
alias,
defaultLocale: 'zh-CN',
translations: [
{ locale: 'zh-CN', title: `${alias} 中文相册`, description: '嵌入轮播' },
{ locale: 'en', title: `${alias} EN Album`, description: 'embed feed' },
],
});
const base = Date.now() - 10 * MINUTE;
const photoIds = [
await seedPhoto(album.id, `${alias}-p1`, { publishedAt: new Date(base - 2 * MINUTE) }),
await seedPhoto(album.id, `${alias}-p2`, { publishedAt: new Date(base - MINUTE) }),
await seedPhoto(album.id, `${alias}-p3`, { publishedAt: new Date(base) }),
];
await flushSync(album.id);
const slot = await createEmbedSlot(`viewer-e2e-${alias}`);
await updateEmbedSlot(slot.id, { albumId: album.id });
await flushOutbox();
return { slotToken: slot.token, slotId: slot.id, albumToken: album.publicToken, photoIds };
}
/** loader wrapper(iframe 父 div)inline height(px 字串;''=未置)。 */
function wrapperHeight(page: Page): Promise<string> {
return page.locator('iframe').evaluate((frame) => (frame.parentElement as HTMLElement).style.height);
}
test.describe.serial('embed carousel widget (P4b Task 4)', () => {
test.afterAll(async () => {
await cleanupSeededAlbums();
});
test('host page shows carousel, rotates photos and opens the viewer photo in a new tab', async ({ page }) => {
const feed = await seedFeed('main');
await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`);
const frame = page.frameLocator('iframe');
// lang=auto → 相册 defaultLocale(zh-CN)标题。
await expect(frame.locator('.embed-title')).toHaveText('main 中文相册');
// 轮播照片就位(3 张,thumb 为真实签名 URL)。
await expect(frame.locator('.embed-photo img').first()).toBeVisible();
await expect(frame.locator('.embed-photo')).toHaveCount(3);
await expect(frame.locator('.embed-dot')).toHaveCount(3);
// intervalMs 切换:当前展示照片变化(首个为 publishedAt 最新 p3)。
const firstSrc = await frame.locator('.embed-photo.active img').getAttribute('src');
await expect
.poll(async () => frame.locator('.embed-photo.active img').getAttribute('src'), { timeout: 10_000 })
.not.toBe(firstSrc);
// 点击照片 → 新标签打开 viewer 单图深链(noopener;lang 跟随 resolvedLocale)。
const popupPromise = page.context().waitForEvent('page');
await frame.locator('.embed-photo.active').click();
const popup = await popupPromise;
await expect(popup).toHaveURL(new RegExp(`/a/${feed.albumToken}/p/`));
await expect(popup).toHaveURL(/lang=zh-CN/);
await popup.close();
});
test('collapse button hides the widget, persists after reload, and the handle expands it', async ({ page }) => {
const feed = await seedFeed('collapse');
await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&interval=3000`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-title')).toHaveText('collapse 中文相册');
// iframe 内 × → postMessage collapse → loader wrapper 高度 0 + 把手出现 + 会话记忆。
await frame.locator('.embed-close').click();
await expect.poll(() => wrapperHeight(page)).toBe('0px');
await expect(page.locator('div[role="button"]')).toBeVisible();
await expect
.poll(() => page.evaluate((slot) => sessionStorage.getItem(`pl-embed-collapsed:${slot}`), feed.slotToken))
.toBe('1');
// 刷新仍收起(会话内不反复弹出,需求 9)。
await page.reload();
await expect.poll(() => wrapperHeight(page)).toBe('0px');
await expect(page.locator('div[role="button"]')).toBeVisible();
// 把手点击 → loader 展开回记忆高度,并向 iframe 发 expand(恢复轮询)。
await page.locator('div[role="button"]').click();
await expect
.poll(async () => (await wrapperHeight(page)) !== '0px')
.toBe(true);
await expect(frame.locator('.embed-title')).toHaveText('collapse 中文相册');
});
test('narrow viewport starts collapsed and downgrades to sm size', async ({ page }) => {
const feed = await seedFeed('narrow');
await page.setViewportSize({ width: 375, height: 667 });
await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}`);
// <480px:强制 collapsedDefault + sm(280px 宽,计划评审缺口:移动端可用)。
await expect(page.locator('div[role="button"]')).toBeVisible();
await expect.poll(() => wrapperHeight(page)).toBe('0px');
const width = await page
.locator('iframe')
.evaluate((frame) => (frame.parentElement as HTMLElement).style.width);
expect(width).toBe('280px');
// 把手展开可用(移动端不因默认收起而不可达)。
await page.locator('div[role="button"]').click();
await expect
.poll(async () => (await wrapperHeight(page)) !== '0px')
.toBe(true);
});
test('newly published photo appears within the poll window', async ({ page }) => {
const album = await seedAlbum({
alias: 'poll',
translations: [{ locale: 'zh-CN', title: '轮询增量相册' }],
});
await seedPhoto(album.id, 'poll-p1', { publishedAt: new Date(Date.now() - MINUTE) });
await flushSync(album.id);
const slot = await createEmbedSlot('viewer-e2e-poll');
await updateEmbedSlot(slot.id, { albumId: album.id });
await flushOutbox();
// ?poll=2000 → 夹具页向 iframe 注入 __embedPollMs(同源;轮询 2s)。
await page.goto(`/__test__/embed-host.html?slot=${slot.token}&poll=2000`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-photo')).toHaveCount(1);
// 新照片发布(真实管线同步 ov,ETag 必变 → 200 增量)→ 追加队尾(A11 数据路径)。
await seedPhoto(album.id, 'poll-p2', { publishedAt: new Date() });
await flushSync(album.id);
await expect
.poll(() => frame.locator('.embed-photo').count(), { timeout: 20_000 })
.toBe(2);
});
test('rebinding to another album resets the feed (configVersion bump, A22)', async ({ page }) => {
const albumA = await seedAlbum({
alias: 'rebind-a',
translations: [{ locale: 'zh-CN', title: '换绑前相册' }],
});
await seedPhoto(albumA.id, 'rebind-a-p1', { publishedAt: new Date(Date.now() - 2 * MINUTE) });
await seedPhoto(albumA.id, 'rebind-a-p2', { publishedAt: new Date(Date.now() - MINUTE) });
const albumB = await seedAlbum({
alias: 'rebind-b',
translations: [{ locale: 'zh-CN', title: '换绑后相册' }],
});
await seedPhoto(albumB.id, 'rebind-b-p1', { publishedAt: new Date(Date.now()) });
await flushSync(albumA.id);
await flushSync(albumB.id);
const slot = await createEmbedSlot('viewer-e2e-rebind');
await updateEmbedSlot(slot.id, { albumId: albumA.id });
await flushOutbox();
await page.goto(`/__test__/embed-host.html?slot=${slot.token}&poll=2000`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-title')).toHaveText('换绑前相册');
await expect(frame.locator('.embed-photo')).toHaveCount(2);
// 换绑:configVersion+1 → 轮询发现版本升 → 全量重置(新相册标题 + 新照片集,
// 旧相册照片不残留——A22「旧响应不串图」)。
await updateEmbedSlot(slot.id, { albumId: albumB.id });
await flushOutbox();
await expect
.poll(async () => frame.locator('.embed-title').textContent(), { timeout: 20_000 })
.toBe('换绑后相册');
await expect
.poll(() => frame.locator('.embed-photo').count(), { timeout: 20_000 })
.toBe(1);
});
test('album without photos shows the coming-soon card', async ({ page }) => {
const album = await seedAlbum({
alias: 'emptyslot',
translations: [{ locale: 'zh-CN', title: '空相册展示位' }],
});
await flushSync(album.id);
const slot = await createEmbedSlot('viewer-e2e-empty');
await updateEmbedSlot(slot.id, { albumId: album.id });
await flushOutbox();
await page.goto(`/__test__/embed-host.html?slot=${slot.token}`);
const frame = page.frameLocator('iframe');
await expect(frame.locator('.embed-title')).toHaveText('空相册展示位');
await expect(frame.locator('.embed-empty')).toHaveText('照片即将上线');
});
test('ghost slot collapses silently with no error UI inside the iframe', async ({ page }) => {
await page.goto(`/__test__/embed-host.html?slot=${makePublicToken()}`);
// 404(不存在/停用/未绑定同码防探测)→ postMessage collapse:wrapper 高度 0 +
// 把手出现;iframe 内无错误 UI(root 隐藏,不弹错误)。
await expect.poll(() => wrapperHeight(page), { timeout: 15_000 }).toBe('0px');
await expect(page.locator('div[role="button"]')).toBeVisible();
await expect(page.frameLocator('iframe').locator('.embed-root')).toHaveCount(0);
});
});
+83 -1
View File
@@ -19,6 +19,7 @@ import {
signSyncBody,
} from '@photo/contracts';
import { deliverPendingOnce } from '../../../services/sync/src/sender';
import { seedAdmin } from '../../../services/api/src/scripts/seed-admin';
// 仓库根 .env 唯一来源(同 services/api test/load-env.ts 约定)。必须在
// PrismaClient / createStorageFromEnv 构造前执行——两者均在首次查询/调用时读 env。
@@ -235,13 +236,84 @@ export async function flushSync(albumId: string): Promise<void> {
`flushSync bootstrap 失败(${bootstrap.status}):${(await bootstrap.text()).slice(0, 300)}`,
);
}
await flushOutbox();
}
/** 仅排空 outbox(P4b Task 4 embed e2e):slot.updated 事件不随相册 bootstrap
* 走(bootstrap 清单恒不含 slot-state),展示位创建/绑定的同步须单独排空。 */
export async function flushOutbox(): Promise<void> {
await deliverPendingOnce({
prisma,
ovApiUrl: process.env.OV_API_URL ?? 'http://127.0.0.1:8081',
internalKey,
internalKey: process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me',
});
}
// ── P4b Task 4 展示位种子(经 cn admin API 真实路径,非直插行)──
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
const seededSlotIds: string[] = [];
let adminTokenPromise: Promise<string> | null = null;
/** seeded admin 幂等 + 登录(journey.spec 同款;会话进程内复用)。 */
async function loginAdmin(): Promise<string> {
if (adminTokenPromise === null) {
adminTokenPromise = (async () => {
await seedAdmin(prisma, ADMIN_ACCOUNT, ADMIN_PASSWORD);
const res = await fetch(
`${(process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '')}/v1/auth/password`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ account: ADMIN_ACCOUNT, password: ADMIN_PASSWORD }),
},
);
if (!res.ok) {
throw new Error(`embed seed 登录失败(${res.status})`);
}
return ((await res.json()) as { sessionToken: string }).sessionToken;
})();
}
return adminTokenPromise;
}
/** 经 cn admin API 创建展示位(未绑定态;settings 缺省),登记待清理。 */
export async function createEmbedSlot(name: string): Promise<{ id: string; token: string }> {
const token = await loginAdmin();
const res = await fetch(
`${(process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '')}/v1/admin/embed-slots`,
{
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ name }),
},
);
if (!res.ok) {
throw new Error(`createEmbedSlot 失败(${res.status}):${(await res.text()).slice(0, 300)}`);
}
const body = (await res.json()) as { id: string; publicToken: string };
seededSlotIds.push(body.id);
return { id: body.id, token: body.publicToken };
}
/** 经 cn admin API 更新展示位(绑定/换绑相册、启停、设置——body 直传契约形状)。 */
export async function updateEmbedSlot(id: string, body: Record<string, unknown>): Promise<void> {
const token = await loginAdmin();
const res = await fetch(
`${(process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '')}/v1/admin/embed-slots/${id}`,
{
method: 'PATCH',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify(body),
},
);
if (!res.ok) {
throw new Error(`updateEmbedSlot 失败(${res.status}):${(await res.text()).slice(0, 300)}`);
}
}
/** 模拟已分享深链的照片下线(Task 8 e2e):软删(visibility=deleted +
* entityVersion 自增)——硬删会让 cn 行缺席、bootstrap 快照无从发墓碑,ov 副本
* 将永远留着旧发布行;软删 + flushSync 让 photo.deleted 事件走真实管线撤下。
@@ -260,6 +332,16 @@ export async function deleteSeededPhoto(photoId: string): Promise<void> {
* dev 桶内残余有界(同 api e2e 约定)。 */
export async function cleanupSeededAlbums(): Promise<void> {
try {
// 展示位种子(P4b):绑定态 slot.updated 行随相册圈定(albumId 在种子相册集)
// 已被下方 outbox 清理覆盖;未绑定创建行以 albumId='' 占位——仅清「未投递」
// 残留(已投递行 inert;未投递残留会污染后续 deliverPendingOnce 排空判定)。
if (seededSlotIds.length > 0) {
await prisma.outbox_events.deleteMany({
where: { type: 'slot.updated', albumId: '', deliveredAt: null },
});
await prisma.embed_slots.deleteMany({ where: { id: { in: seededSlotIds } } });
seededSlotIds.length = 0;
}
if (seededAlbumIds.length > 0) {
const photos = await prisma.photos.findMany({
where: { albumId: { in: seededAlbumIds } },
@@ -0,0 +1,49 @@
<!doctype html>
<!-- apps/viewer/public/__test__/embed-host.html —— P4b Task 4 e2e 专用宿主夹具页。
仅测试资产(计划约定:不入 viewer 生产路由),模拟第三方站点引入
{VIEWER_ORIGIN}/embed/v1/loader.js:查询参数 ?slot=&lang=&interval=&position=
透传为 loader data-*;?poll= 同源写入 iframe contentWindow.__embedPollMs 加速
轮询断言(__viewerPollMs 惰性读取同一约定)。本页不设 CSP——宿主真实 CSP
要求(script-src/frame-src {EMBED_ORIGIN})见 packages/embed-loader/README-embed.md。 -->
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>embed host fixture</title>
</head>
<body>
<h1>Host page (embed e2e fixture)</h1>
<p>宿主夹具页:loader 注入区。</p>
<script>
(function () {
var q = new URLSearchParams(location.search);
var slot = q.get('slot');
if (!slot) return;
var s = document.createElement('script');
s.src = '/embed/v1/loader.js';
s.defer = true;
s.dataset.slot = slot;
var map = { lang: 'lang', interval: 'interval', position: 'position' };
for (var key in map) {
var value = q.get(key);
if (value) s.dataset[map[key]] = value;
}
document.body.appendChild(s);
var poll = parseInt(q.get('poll') || '', 10);
if (poll > 0) {
var timer = setInterval(function () {
try {
var frame = document.querySelector('iframe');
if (frame && frame.contentWindow) frame.contentWindow.__embedPollMs = poll;
} catch (e) {
/* 跨源写入被拒(真实第三方场景)——夹具仅在同源生效 */
}
}, 250);
setTimeout(function () {
clearInterval(timer);
}, 60000);
}
})();
</script>
</body>
</html>
+15 -2
View File
@@ -1,11 +1,17 @@
<script setup lang="ts">
import { watchEffect } from 'vue';
import { computed, watchEffect } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import LocaleSwitcher from './components/LocaleSwitcher.vue';
import { reducedMotion, toggleReducedMotion } from './ui/prefs';
const route = useRoute();
const { t, locale } = useI18n();
// 嵌入 iframe 页(P4b Task 4):裸布局——无顶栏/页脚/内边距(宿主 loader 的
// 小视口卡片,280-340px 设计);标题仍随 i18n(iframe 内无感知,保持简单)。
const isEmbedLayout = computed(() => route.meta.layout === 'embed');
// 所有可见文案一律经 i18n(P1b Task 5 起约定);标题与 html lang 随语言联动。
watchEffect(() => {
document.title = t('app.title');
@@ -14,7 +20,10 @@ watchEffect(() => {
</script>
<template>
<div class="app">
<div v-if="isEmbedLayout" class="embed-shell">
<RouterView />
</div>
<div v-else class="app">
<header class="topbar">
<span class="brand">{{ t('app.title') }}</span>
<div class="topbar-actions">
@@ -84,4 +93,8 @@ body {
flex: 1;
padding: 16px;
}
/* 嵌入页外壳:无内边距/最小高度(iframe 内容高度由 resize 上报驱动)。 */
.embed-shell {
display: block;
}
</style>
@@ -0,0 +1,339 @@
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 { postEmbedMessage } from '../embed/postMessage';
import {
clampEmbedHeight,
clampEmbedIntervalMs,
classifyEmbedResponse,
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/
// ResizeObserver 在 jsdom 缺席,composable 已按特性探测跳过(高度上报走
// clampEmbedHeight 纯函数覆盖)。
const expandHandlers = vi.hoisted(() => new Set<() => void>());
vi.mock('../embed/postMessage', () => ({
postEmbedMessage: vi.fn(),
embedParentOrigin: vi.fn(() => null),
onEmbedExpand: vi.fn((handler: () => void) => {
expandHandlers.add(handler);
return () => expandHandlers.delete(handler);
}),
}));
const mockedPost = vi.mocked(postEmbedMessage);
function makePhoto(id: string, url?: string): PublicEmbedPhoto {
return {
photoId: id,
thumb: { url: url ?? `https://img.test/${id}.jpg`, expiresAt: '2026-10-01T00:00:00.000Z' },
width: 240,
height: 150,
};
}
function makeSlot(overrides: Partial<PublicEmbedSlot> = {}): PublicEmbedSlot {
return {
slotToken: 'slottest0000000000000000aa',
configVersion: 1,
album: {
publicToken: 'tokfixture0000000000aaaa',
title: '嵌入测试相册',
defaultLocale: 'zh-CN',
},
photos: [makePhoto('p1'), makePhoto('p2')],
...overrides,
};
}
// —— 纯函数矩阵 ——
describe('embed feed pure helpers', () => {
it('clamps the interval override to 3000-30000 and passes through null', () => {
expect(clampEmbedIntervalMs(null)).toBeNull();
expect(clampEmbedIntervalMs(Number.NaN)).toBeNull();
expect(clampEmbedIntervalMs(100)).toBe(3000);
expect(clampEmbedIntervalMs(5000)).toBe(5000);
expect(clampEmbedIntervalMs(120000)).toBe(30000);
});
it('clamps reported heights to 60-640 and rejects non-finite values', () => {
expect(clampEmbedHeight(null)).toBeNull();
expect(clampEmbedHeight(Number.NaN)).toBeNull();
expect(clampEmbedHeight(10)).toBe(60);
expect(clampEmbedHeight(321.6)).toBe(322);
expect(clampEmbedHeight(2000)).toBe(640);
});
it('resolves the UI locale from lang param with album defaultLocale fallback', () => {
expect(resolveEmbedLocale('auto', 'en')).toBe('en');
expect(resolveEmbedLocale('zh-CN', 'en')).toBe('zh-CN');
expect(resolveEmbedLocale('bogus', 'en')).toBe('en');
expect(resolveEmbedLocale('auto', 'fr')).toBe('zh-CN'); // 不支持回退
expect(resolveEmbedLocale('auto', undefined)).toBe('zh-CN');
});
it('discards a response whose dispatch seq is stale (newer request in flight)', () => {
expect(
classifyEmbedResponse({ dispatchSeq: 1, currentSeq: 2, dispatchVersion: 1, currentVersion: 1, responseVersion: 1 }),
).toBe('discard');
});
it('discards a response whose configVersion is below the locally accepted one (A22 旧响应)', () => {
expect(
classifyEmbedResponse({ dispatchSeq: 3, currentSeq: 3, dispatchVersion: 1, currentVersion: 3, responseVersion: 2 }),
).toBe('discard');
});
it('merges on equal configVersion and resets on higher (or first load)', () => {
expect(
classifyEmbedResponse({ dispatchSeq: 3, currentSeq: 3, dispatchVersion: 2, currentVersion: 2, responseVersion: 2 }),
).toBe('merge');
expect(
classifyEmbedResponse({ dispatchSeq: 3, currentSeq: 3, dispatchVersion: 2, currentVersion: 2, responseVersion: 5 }),
).toBe('reset');
expect(
classifyEmbedResponse({ dispatchSeq: 1, currentSeq: 1, dispatchVersion: null, currentVersion: null, responseVersion: 1 }),
).toBe('reset');
});
it('merges the photo queue: new photos append at the tail, missing ones drop', () => {
const current = [makePhoto('a'), makePhoto('b'), makePhoto('c')];
const incoming = [makePhoto('c'), makePhoto('d'), makePhoto('b')];
const merged = mergePhotoQueue(current, incoming);
// 幸存保持现有顺序(展示不被打断),新照片按服务端顺序追加队尾,
// 服务端缺席(a)淘汰。
expect(merged.photos.map((p) => p.photoId)).toEqual(['b', 'c', 'd']);
expect(merged.added).toEqual(['d']);
expect(merged.removed).toEqual(['a']);
});
it('refreshes thumb URLs of survivors and reports removals', () => {
const current = [makePhoto('a', 'https://img.test/old-a.jpg'), makePhoto('gone')];
const incoming = [makePhoto('a', 'https://img.test/new-a.jpg')];
const merged = mergePhotoQueue(current, incoming);
expect(merged.photos.map((p) => p.photoId)).toEqual(['a']);
expect(merged.photos[0]!.thumb.url).toBe('https://img.test/new-a.jpg');
expect(merged.removed).toEqual(['gone']);
});
it('backs off the poll interval only once failures reach the cap', () => {
expect(pollIntervalAfterFailures(0, 30000)).toBe(30000);
expect(pollIntervalAfterFailures(4, 30000)).toBe(30000);
expect(pollIntervalAfterFailures(5, 30000)).toBe(150000);
expect(pollIntervalAfterFailures(9, 30000)).toBe(150000);
});
});
// —— useEmbedFeed 编排 ——
const mounted: VueWrapper[] = [];
function mountFeed(options: { fetcher: Mock; langParam?: string; intervalParamMs?: number | null }): EmbedFeed {
let feed!: EmbedFeed;
const Host = defineComponent({
setup() {
feed = useEmbedFeed({
slotToken: 'slote2etest0000000000aa',
langParam: options.langParam ?? 'auto',
intervalParamMs: options.intervalParamMs ?? null,
rootEl: ref(null),
fetcher: options.fetcher as UseEmbedFeedOptions['fetcher'],
});
return () => null;
},
});
mounted.push(mount(Host));
return feed;
}
/** jsdom document.hidden 只读:defineProperty 覆写(用例后还原)。 */
function setHidden(hidden: boolean): void {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });
}
describe('useEmbedFeed orchestration', () => {
beforeEach(() => {
vi.useFakeTimers();
mockedPost.mockClear();
});
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount();
vi.useRealTimers();
setHidden(false);
});
it('loads the slot, passes etag on subsequent polls and treats 304 as no change', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
const feed = mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
expect(fetcher).toHaveBeenCalledWith('slote2etest0000000000aa', { lang: 'auto', etag: null });
expect(feed.status).toBe('ready');
expect(feed.photos.map((p) => p.photoId)).toEqual(['p1', 'p2']);
expect(feed.album?.title).toBe('嵌入测试相册');
expect(feed.resolvedLocale).toBe('zh-CN'); // auto → defaultLocale
// 下一轮带上 If-None-Match;304(data null)→ 队列零变化、不重置版本。
fetcher.mockResolvedValue({ data: null, etag: '"e1"' });
await vi.advanceTimersByTimeAsync(30_000);
expect(fetcher).toHaveBeenLastCalledWith('slote2etest0000000000aa', { lang: 'auto', etag: '"e1"' });
expect(feed.photos.map((p) => p.photoId)).toEqual(['p1', 'p2']);
expect(feed.status).toBe('ready');
});
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 });
await vi.advanceTimersByTimeAsync(0);
fetcher.mockResolvedValue({
data: makeSlot({
photos: [makePhoto('p3', 'https://img.test/p3-new.jpg'), makePhoto('p1', 'https://img.test/p1-new.jpg'), makePhoto('p2', 'https://img.test/p2-new.jpg')],
}),
etag: '"e2"',
});
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 () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
const feed = mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
feed.postResize(); // no-op(rootEl null)——仅确认不抛
fetcher.mockResolvedValue({
data: makeSlot({
configVersion: 2,
album: { publicToken: 'tokother00000000000000bbbb', title: '换绑相册', defaultLocale: 'en' },
photos: [makePhoto('q1')],
}),
etag: '"e2"',
});
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 变化生效
});
it('discards a stale in-flight response (seq guard)', async () => {
let resolveFirst!: (value: { data: PublicEmbedSlot; etag: string }) => void;
let resolveSecond!: (value: { data: PublicEmbedSlot; etag: string }) => void;
const fetcher = vi
.fn()
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve; }));
const feed = mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0); // 首次 dispatch(in flight)
// 可见性恢复触发第二次 dispatch(首次仍在途)→ seq 前进。
setHidden(false);
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(0);
resolveSecond({ data: makeSlot({ configVersion: 2, photos: [makePhoto('new1'), makePhoto('new2')] }), etag: '"e2"' });
await vi.advanceTimersByTimeAsync(0);
resolveFirst({ data: makeSlot({ photos: [makePhoto('stale1')] }), etag: '"e1"' });
await vi.advanceTimersByTimeAsync(0);
// 旧响应(v1、发起更早)迟到 → 丢弃,不串入新集合。
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 () => {
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' });
// gone 后彻底停止轮询。
await vi.advanceTimersByTimeAsync(300_000);
expect(fetcher).toHaveBeenCalledTimes(1);
});
it('backs off after five consecutive failures and recovers on success', async () => {
const failure = new ApiError('INTERNAL_ERROR', 'boom', 500, null);
const fetcher = vi.fn().mockRejectedValue(failure);
mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0); // 第 1 次(failures=1)
for (let i = 0; i < 4; i++) await vi.advanceTimersByTimeAsync(30_000); // 第 2-5 次
expect(fetcher).toHaveBeenCalledTimes(5);
await vi.advanceTimersByTimeAsync(30_000); // 退避中(150s 间隔)
expect(fetcher).toHaveBeenCalledTimes(5);
await vi.advanceTimersByTimeAsync(120_000); // 累计 150s → 第 6 次(仍失败)
expect(fetcher).toHaveBeenCalledTimes(6);
fetcher.mockResolvedValue({ data: makeSlot(), etag: '"ok"' });
await vi.advanceTimersByTimeAsync(150_000); // 第 7 次(成功,failures 归零)
await vi.advanceTimersByTimeAsync(30_000); // 恢复正常间隔 → 第 8 次
expect(fetcher).toHaveBeenCalledTimes(8);
});
it('pauses polling while hidden and refreshes immediately on return (A23)', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
mountFeed({ fetcher });
await vi.advanceTimersByTimeAsync(0);
const callsBeforeHide = fetcher.mock.calls.length;
setHidden(true);
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(120_000);
expect(fetcher).toHaveBeenCalledTimes(callsBeforeHide); // 隐藏期间零轮询
setHidden(false);
document.dispatchEvent(new Event('visibilitychange'));
await vi.advanceTimersByTimeAsync(0);
expect(fetcher).toHaveBeenCalledTimes(callsBeforeHide + 1); // 恢复立即校准一次
});
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 });
await vi.advanceTimersByTimeAsync(0);
expect(feed.index).toBe(0);
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'));
await vi.advanceTimersByTimeAsync(30_000);
expect(feed.index).toBe(0); // 暂停期间不切换
});
it('collapse stops timers and expand from the parent resumes with a resize repost', async () => {
const fetcher = vi.fn().mockResolvedValue({ data: makeSlot(), etag: '"e1"' });
const feed = mountFeed({ fetcher, intervalParamMs: 3000 });
await vi.advanceTimersByTimeAsync(0);
const callsAfterLoad = fetcher.mock.calls.length;
feed.requestCollapse();
expect(mockedPost).toHaveBeenCalledWith({ photoliveEmbed: true, type: 'collapse' });
await vi.advanceTimersByTimeAsync(60_000);
expect(fetcher).toHaveBeenCalledTimes(callsAfterLoad); // 收起期间零轮询
// 宿主把手 expand(onEmbedExpand 捕获的 handler):恢复(立即校准 + 重发高度,
// rootEl null → resize 跳过不抛)。
expandHandlers.forEach((handler) => handler());
await vi.advanceTimersByTimeAsync(0);
expect(fetcher).toHaveBeenCalledTimes(callsAfterLoad + 1);
});
});
+60
View File
@@ -16,6 +16,11 @@ export type VisitReportResponse = components['schemas']['VisitReportResponse'];
export type PublicPhotoDownload = components['schemas']['PublicPhotoDownload'];
export type DownloadVariant = components['schemas']['DownloadVariant'];
// P4b Task 4 展示位公开数据类型(ov 域;嵌入轮播 iframe 数据源)。
export type PublicEmbedSlot = components['schemas']['PublicEmbedSlot'];
export type PublicEmbedAlbum = components['schemas']['PublicEmbedAlbum'];
export type PublicEmbedPhoto = components['schemas']['PublicEmbedPhoto'];
/** 公开照片排序模式(sort=hot 仅 ov 实现:likeTotal 倒序短期快照,§7)。 */
export type SortMode = 'latest' | 'hot';
@@ -163,3 +168,58 @@ export function getPhotoDownload(
}),
);
}
/** 展示位公开数据取回结果(P4b Task 4):304(内容未变)时 data 为 null。 */
export interface EmbedSlotFetchResult {
data: PublicEmbedSlot | null;
etag: string | null;
}
/**
* 公开展示位数据(P4b Task 4):不走 apiRequest——需要 If-None-Match 条件请求与
* 304 分支(apiRequest 对非 2xx 一律抛错)。同源/跨源 fetch 对 304 均以
* response.status===304 无 body 达成;ETag 头依赖 API 侧 CORS exposedHeaders
* (configure-app),跨源读取否则恒 null。错误信封解析与 apiRequest 同规。
*/
export async function fetchPublicEmbedSlot(
slotToken: string,
options: { lang?: string; etag?: string | null } = {},
): Promise<EmbedSlotFetchResult> {
let response: Response;
const path = withQuery(`/public/embed-slots/${encodeURIComponent(slotToken)}`, {
lang: options.lang,
});
try {
response = await fetch(`${apiBase()}/v1${path}`, {
headers: {
accept: 'application/json',
...(options.etag ? { 'if-none-match': options.etag } : {}),
},
});
} catch {
throw new ApiError(NETWORK_ERROR_CODE, 'network request failed', 0, null);
}
const etag = response.headers.get('etag');
if (response.status === 304) {
return { data: null, etag };
}
const text = await response.text();
let body: unknown = null;
if (text.length > 0) {
try {
body = JSON.parse(text);
} catch {
body = null;
}
}
if (!response.ok) {
const env = parseErrorEnvelope(body);
throw new ApiError(
env.code ?? 'INTERNAL_ERROR',
env.message ?? `request failed with status ${response.status}`,
response.status,
env.requestId,
);
}
return { data: body as PublicEmbedSlot, etag };
}
+110
View File
@@ -0,0 +1,110 @@
// apps/viewer/src/embed/feed.ts —— 嵌入轮播纯函数集(P4b Task 4)。
// 数据流不变量(A22 旧响应丢弃 / 队列差分合并 / 轮询参数)全部收敛为纯函数,
// vitest 直接覆盖;useEmbedFeed.ts 组合它们完成编排。
// 浏览器安全子路径:根出口 re-export internal-sync(node:crypto),会把
// createHmac 拖进浏览器包使 iframe 页崩溃——constants 子路径零 Node 依赖。
import {
EMBED_POLL_BACKOFF_MULTIPLIER,
EMBED_POLL_INTERVAL_MS,
EMBED_POLL_MAX_FAILURES,
} from '@photo/contracts/constants';
import type { PublicEmbedPhoto } from '../api/client';
import { FALLBACK_LOCALE, SUPPORTED_LOCALES, type ViewerLocale } from '../i18n';
/** 轮播切换/查询 interval 覆盖的夹取界(同契约 EmbedSlotSettings.intervalMs)。 */
export const EMBED_INTERVAL_MIN_MS = 3000;
export const EMBED_INTERVAL_MAX_MS = 30000;
/** 查询未提供 interval 时轮播切换缺省(同 loader DEFAULTS.intervalMs)。 */
export const EMBED_CAROUSEL_DEFAULT_MS = 5000;
/** resize 上报高度夹取界(与 embed-loader HEIGHT_MIN/HEIGHT_MAX 一致,改须同步)。 */
export const EMBED_HEIGHT_MIN = 60;
export const EMBED_HEIGHT_MAX = 640;
/** interval 覆盖夹取(轮播与轮询共用同值域);null/非法 → null(表示「无覆盖」)。 */
export function clampEmbedIntervalMs(value: number | null): number | null {
if (value === null || !Number.isFinite(value)) return null;
return Math.min(EMBED_INTERVAL_MAX_MS, Math.max(EMBED_INTERVAL_MIN_MS, Math.round(value)));
}
/** resize 上报高度夹取:非有限数值 → null(跳过本次上报)。 */
export function clampEmbedHeight(value: number | null): number | null {
if (value === null || !Number.isFinite(value)) return null;
return Math.min(EMBED_HEIGHT_MAX, Math.max(EMBED_HEIGHT_MIN, Math.round(value)));
}
/**
* 嵌入页 UI 语言解析:lang=zh-CN/en → 指定值;auto/非法 → 相册 defaultLocale
* (不支持时回退 zh-CN)。i18n 词条子集由此驱动(照片即将上线/aria 标签)。
*/
export function resolveEmbedLocale(langParam: string, albumDefaultLocale: string | undefined): ViewerLocale {
if ((SUPPORTED_LOCALES as readonly string[]).includes(langParam)) {
return langParam as ViewerLocale;
}
if (albumDefaultLocale !== undefined && (SUPPORTED_LOCALES as readonly string[]).includes(albumDefaultLocale)) {
return albumDefaultLocale as ViewerLocale;
}
return FALLBACK_LOCALE;
}
/** 响应裁决:A22 核心竞态防护(seq 与 configVersion 双闸,见 classifyEmbedResponse)。 */
export type EmbedResponseDecision = 'discard' | 'merge' | 'reset';
export function classifyEmbedResponse(input: {
/** 请求发起时取得的 seq(每次 dispatch 自增)。 */
dispatchSeq: number;
/** resolve 时的当前 seq(有更新请求发出即前进)。 */
currentSeq: number;
/** 请求发起时的本地 configVersion(首次为 null)。 */
dispatchVersion: number | null;
/** resolve 时的本地 configVersion。 */
currentVersion: number | null;
responseVersion: number;
}): EmbedResponseDecision {
// ① seq 过期:发起后又有新请求(轮询/可见性恢复并发)→ 本响应作废。
if (input.dispatchSeq !== input.currentSeq) return 'discard';
// ② 版本回看:响应版本低于本地已接受版本(更早绑定的旧数据迟到)→ 丢弃,
// 不得串入新相册画面(A22「旧响应不串图」)。
const local = input.currentVersion ?? 0;
if (input.responseVersion < local) return 'discard';
if (input.responseVersion === local) return 'merge';
return 'reset';
}
export interface MergedPhotoQueue {
photos: PublicEmbedPhoto[];
/** 新增(追加到队尾)的 photoId 列表(按服务端顺序)。 */
added: string[];
/** 被移除(服务端已不可见)的 photoId 列表。 */
removed: string[];
}
/**
* 照片队列差分合并(equal configVersion 分支):幸存项保持现有顺序、thumb URL
* 刷新(签名短时效,200 即换新);新照片按服务端(publishedAt 倒序)顺序追加到
* 队尾——正在展示的照片不被打断;服务端缺席项淘汰。reset 分支不走本函数。
*/
export function mergePhotoQueue(current: PublicEmbedPhoto[], incoming: PublicEmbedPhoto[]): MergedPhotoQueue {
const incomingById = new Map(incoming.map((photo) => [photo.photoId, photo]));
const survivors = current.filter((photo) => incomingById.has(photo.photoId));
const survivorIds = new Set(survivors.map((photo) => photo.photoId));
const added = incoming.filter((photo) => !survivorIds.has(photo.photoId));
const photos = survivors.map((photo) => incomingById.get(photo.photoId) ?? photo).concat(added);
const removed = current
.filter((photo) => !incomingById.has(photo.photoId))
.map((photo) => photo.photoId);
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;
}
/**
* 轮询退避:连续失败达 EMBED_POLL_MAX_FAILURES(5)后间隔 ×EMBED_POLL_BACKOFF_
* MULTIPLIER(5);成功即归零(调用方维护 failures 计数)。参数均出契约 constants。
*/
export function pollIntervalAfterFailures(failures: number, baseIntervalMs: number): number {
return failures >= EMBED_POLL_MAX_FAILURES ? baseIntervalMs * EMBED_POLL_BACKOFF_MULTIPLIER : baseIntervalMs;
}
+50
View File
@@ -0,0 +1,50 @@
// apps/viewer/src/embed/postMessage.ts —— 嵌入 iframe ↔ 宿主 loader 消息通道
// (P4b Task 4)。协议与白名单模型见 packages/embed-loader(README-embed.md):
// 载荷恒为 {photoliveEmbed: true, type: 'resize'|'collapse'|..., ...}。
//
// 上行(iframe → 宿主):resize(高度)/ collapse(收起,宿主侧高度 0 + 把手)。
// 目标 origin 尽量收敛到 document.referrer 的源(loader 的 iframe 带
// no-referrer-when-downgrade,宿主正常配置下可得完整来源 URL);referrer 被宿主
// 隐去时退回 '*'——载荷仅含高度/收起意图,无敏感数据,且宿主侧 loader 以
// 「event.source === 已登记 iframe && event.origin === iframe 源」双重严格校验,
// 通配目标不构成越权面。
//
// 下行(宿主 → iframe):expand(把手点击恢复)——只认 event.source ===
// window.parent 且载荷形状匹配(source 校验即充分:宿主页内任意脚本无法伪造
// parent 窗口引用;loader 对上行另有 origin 全等白名单)。
export type EmbedUpMessage =
| { photoliveEmbed: true; type: 'resize'; height: number }
| { photoliveEmbed: true; type: 'collapse' };
/** 宿主源(referrer 推导);取不到(被隐去/直接打开)为 null。 */
export function embedParentOrigin(): string | null {
try {
if (document.referrer === '') return null;
return new URL(document.referrer).origin;
} catch {
return null;
}
}
/** 上行发送:无父窗(直接打开开发/测试)静默跳过,绝不抛错。 */
export function postEmbedMessage(message: EmbedUpMessage): void {
if (typeof window === 'undefined' || window.parent === window) return;
try {
window.parent.postMessage(message, embedParentOrigin() ?? '*');
} catch {
/* 静默:嵌入环境异常不得外显 */
}
}
/** 订阅宿主 expand;返回取消函数。 */
export function onEmbedExpand(handler: () => void): () => void {
const listener = (event: MessageEvent): void => {
if (window.parent === window || event.source !== window.parent) return;
const data = event.data as { photoliveEmbed?: unknown; type?: unknown } | null;
if (data === null || typeof data !== 'object') return;
if (data.photoliveEmbed !== true || data.type !== 'expand') return;
handler();
};
window.addEventListener('message', listener);
return () => window.removeEventListener('message', listener);
}
+284
View File
@@ -0,0 +1,284 @@
// apps/viewer/src/embed/useEmbedFeed.ts —— 嵌入轮播数据流编排(P4b Task 4)。
// 职责(纯函数在 feed.ts,消息通道在 postMessage.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 后 ×退避倍数,
// 成功归零;404/410(槽停用/解绑/不存在,防探测同码)→ 静默 gone +
// postMessage collapse,其余错误静默退避重试(不弹错误 UI,A11);
// · 不可见暂停(A23):document 隐藏 / IntersectionObserver 自身滚出视口 /
// 本地收起 → 停轮询停切换;恢复 → 立即校准一次再续常态;
// · 轮播:intervalMs 切换索引(仅 thumb 小图,UI 在 EmbedPage.vue)。
// e2e 注入点:window.__embedPollMs(轮询间隔覆盖,宿主页同源写入;沿用
// __viewerRealtimePollMs 惰性读取约定,运行中改动下一轮生效)。
import { onBeforeUnmount, onMounted, reactive, ref, type Ref } from 'vue';
import {
ApiError,
fetchPublicEmbedSlot,
type PublicEmbedAlbum,
type PublicEmbedPhoto,
type PublicEmbedSlot,
} from '../api/client';
import type { ViewerLocale } from '../i18n';
import {
basePollIntervalMs,
clampEmbedHeight,
clampEmbedIntervalMs,
classifyEmbedResponse,
EMBED_CAROUSEL_DEFAULT_MS,
mergePhotoQueue,
pollIntervalAfterFailures,
resolveEmbedLocale,
} from './feed';
import { onEmbedExpand, postEmbedMessage } from './postMessage';
export type EmbedFeedStatus =
| 'loading'
/** 相册绑定且有照片:轮播展示。 */
| 'ready'
/** 相册无照片(或契约前瞻 album null):「照片即将上线」小卡。 */
| 'empty'
/** 槽 404(停用/解绑/不存在):无 UI,postMessage collapse 后隐藏自身。 */
| 'gone';
export interface UseEmbedFeedOptions {
slotToken: string;
/** loader 传入 lang 查询(auto|zh-CN|en)。 */
langParam: string;
/** loader 传入 interval 查询(毫秒;null=未提供)。 */
intervalParamMs: number | null;
/** 页面根元素(高度上报 / 可见性观测)。 */
rootEl: Ref<HTMLElement | null>;
/** 测试注入;缺省真实 fetchPublicEmbedSlot。 */
fetcher?: typeof fetchPublicEmbedSlot;
}
function overrideNumber(key: string): number | null {
const value = (globalThis as unknown as Record<string, unknown>)[key];
return typeof value === 'number' && value > 0 ? value : null;
}
export function useEmbedFeed(options: UseEmbedFeedOptions) {
const { slotToken, langParam, intervalParamMs, 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);
let etag: string | null = null;
let configVersion: number | null = null;
/** dispatch 序号:每次发起取数自增,resolve 时落后即丢弃。 */
let seq = 0;
let failures = 0;
let alive = true;
let collapsed = false;
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;
function pollBaseMs(): number {
return overrideNumber('__embedPollMs') ?? basePollIntervalMs(intervalParamMs);
}
function carouselMs(): number {
return clampEmbedIntervalMs(intervalParamMs) ?? EMBED_CAROUSEL_DEFAULT_MS;
}
/** 活动闸门:未卸载、未收起、非 gone、页面可见且自身在视口内。 */
function running(): boolean {
return alive && !collapsed && status.value !== 'gone' && pageVisible && intersecting;
}
function stopTimers(): void {
if (pollTimer !== null) {
clearTimeout(pollTimer);
pollTimer = null;
}
if (carouselTimer !== null) {
clearInterval(carouselTimer);
carouselTimer = null;
}
}
function ensureCarousel(): void {
if (carouselTimer !== null) clearInterval(carouselTimer);
carouselTimer = null;
if (!running() || photos.value.length < 2) return;
carouselTimer = setInterval(
() => {
index.value = (index.value + 1) % photos.value.length;
},
carouselMs(),
);
}
function applyResponse(dispatchSeq: number, dispatchVersion: number | null, data: PublicEmbedSlot): void {
const decision = classifyEmbedResponse({
dispatchSeq,
currentSeq: seq,
dispatchVersion,
currentVersion: configVersion,
responseVersion: data.configVersion,
});
if (decision === 'discard') return;
if (decision === 'reset') {
configVersion = data.configVersion;
photos.value = data.photos;
index.value = 0;
} 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();
}
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> {
if (!running()) return;
const dispatchSeq = ++seq;
const dispatchVersion = configVersion;
try {
const result = await fetcher(slotToken, { lang: langParam, 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);
} catch (error) {
if (dispatchSeq !== seq || !running()) return;
if (error instanceof ApiError && (error.status === 404 || error.status === 410)) {
fatal();
return;
}
// 网络错/5xx/限速:静默退避(无错误 UI),成功归零。
failures += 1;
}
scheduleNextPoll();
}
function resumeIfActive(): void {
if (!running()) return;
void pollNow(); // 恢复即校准一次(pollNow 完成后续排下一轮)
ensureCarousel();
}
function requestCollapse(): void {
collapsed = true;
stopTimers();
postEmbedMessage({ photoliveEmbed: true, type: 'collapse' });
}
function expandFromParent(): void {
if (!alive || status.value === 'gone') return;
collapsed = false;
if (!running()) return;
void pollNow();
ensureCarousel();
postResize();
}
function postResize(): void {
if (!alive || status.value === 'gone') return;
const element = rootEl.value;
if (element === null) return;
const height = clampEmbedHeight(element.offsetHeight);
if (height === null) return;
postEmbedMessage({ photoliveEmbed: true, type: 'resize', height });
}
function onVisibilityChange(): void {
pageVisible = !document.hidden;
if (pageVisible) resumeIfActive();
else stopTimers();
}
onMounted(() => {
pageVisible = !document.hidden;
document.addEventListener('visibilitychange', onVisibilityChange);
offExpand = onEmbedExpand(expandFromParent);
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(() => {
if (typeof requestAnimationFrame === 'function') {
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null;
postResize();
});
} else {
postResize();
}
});
if (rootEl.value !== null) observer.observe(rootEl.value);
}
if (typeof IntersectionObserver !== 'undefined' && rootEl.value !== null) {
visibilityObserver = new IntersectionObserver(
(entries) => {
intersecting = entries.some((entry) => entry.isIntersecting);
if (intersecting) resumeIfActive();
else stopTimers();
},
{ threshold: 0 },
);
visibilityObserver.observe(rootEl.value);
}
void pollNow();
});
onBeforeUnmount(() => {
alive = false;
stopTimers();
document.removeEventListener('visibilitychange', onVisibilityChange);
offExpand?.();
observer?.disconnect();
visibilityObserver?.disconnect();
if (resizeFrame !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(resizeFrame);
}
});
// reactive 包装:模板/脚本访问 feed.photos 等自动解包(同 useAlbumRealtime 约定)。
return reactive({
status,
album,
photos,
index,
resolvedLocale,
requestCollapse,
/** 直接触发一次高度上报(expand 后重发 / 测试)。 */
postResize,
});
}
export type EmbedFeed = ReturnType<typeof useEmbedFeed>;
+6
View File
@@ -19,6 +19,12 @@ export default {
},
lang: { switch: 'Switch language' },
prefs: { reduceMotion: 'Reduce motion' },
// Embed carousel iframe page (P4b Task 4): subset driven by the lang query.
embed: {
comingSoon: 'Photos coming soon',
openPhoto: 'Open photo',
close: 'Collapse widget',
},
download: {
action: 'Download photo',
original: 'Download original',
+6
View File
@@ -19,6 +19,12 @@ export default {
},
lang: { switch: '切换语言' },
prefs: { reduceMotion: '减少动画' },
// 嵌入轮播 iframe 页(P4b Task 4):独立于宿主语言的词条子集(lang 查询驱动)。
embed: {
comingSoon: '照片即将上线',
openPhoto: '查看照片大图',
close: '收起组件',
},
download: {
action: '下载图片',
original: '下载原图',
+189
View File
@@ -0,0 +1,189 @@
<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';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useEmbedFeed } from '../embed/useEmbedFeed';
const route = useRoute();
const { t, locale } = useI18n();
function queryText(value: unknown): string | null {
if (typeof value === 'string' && value !== '') return value;
return null;
}
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 rootEl = ref<HTMLElement | null>(null);
const feed = useEmbedFeed({
slotToken: slotToken.value,
langParam,
intervalParamMs: Number.isFinite(intervalParamMs as number) ? intervalParamMs : null,
rootEl,
});
// 嵌入页词条语言随 resolvedLocale(首包后确定),不写 localStorage(宿主无关)。
watch(
() => feed.resolvedLocale,
(value) => {
if (value !== null && value !== locale.value) locale.value = value;
},
{ immediate: true },
);
function openPhoto(photoId: string): void {
const albumToken = feed.album?.publicToken;
if (albumToken === undefined) return;
const url = new URL(`/a/${encodeURIComponent(albumToken)}/p/${encodeURIComponent(photoId)}`, window.location.origin);
url.searchParams.set('lang', feed.resolvedLocale ?? 'zh-CN');
// noopener:新标签页不持有 opener(sandbox allow-popups-to-escape-sandbox 配合)。
window.open(url, '_blank', 'noopener,noreferrer');
}
</script>
<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>
<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>
<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 设计。 */
.embed-root {
background: #fff;
font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
color: #1f2328;
min-height: 60px;
}
.embed-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px 6px;
}
.embed-title {
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.embed-close {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: #6b7280;
cursor: pointer;
}
.embed-close:hover {
background: #f3f4f6;
color: #374151;
}
.embed-stage {
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;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
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>
+4
View File
@@ -1,13 +1,17 @@
import { createRouter, createWebHistory } from 'vue-router';
import ActivityPage from '../pages/ActivityPage.vue';
import EmbedPage from '../pages/EmbedPage.vue';
import HomePage from '../pages/HomePage.vue';
// 单图深链复用活动页组件(Task 8 起在此路由加载目标照片并进入大图模式)。
// /embed/:slotToken(P4b Task 4):宿主 loader iframe 载入的轮播页——meta.layout
// 'embed' 使 App.vue 裸渲染(无顶栏/页脚/内边距,iframe 小视口精简布局)。
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomePage },
{ path: '/a/:token', name: 'activity', component: ActivityPage },
{ path: '/a/:token/p/:photoId', name: 'activity-photo', component: ActivityPage },
{ path: '/embed/:slotToken', name: 'embed', component: EmbedPage, meta: { layout: 'embed' } },
],
});
+3
View File
@@ -18,6 +18,9 @@ module.exports = tseslint.config(
'**/.turbo/',
// 生成产物不参与 lint(契约生成类型已提交入库,改动随 `pnpm gen` 审查)
'**/generated/',
// embed-loader 发布产物拷贝(viewer publicDir 携带,源在 packages/embed-loader
// ——esbuild minify 输出不适用 lint;P4b Task 3 起 viewer lint 曾因此红)
'apps/viewer/public/embed/v1/',
// Playwright 运行产物
'**/playwright-report/',
'**/test-results/',
+5 -1
View File
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"type": "module",
"description": "API contract single source of truth (OpenAPI 3.1 + error codes); types generated via openapi-typescript and committed under src/generated/",
"description": "API contract single source of truth (OpenAPI 3.1 + error codes); types generated via openapi-typescript and committed under src/generated/. Subpath ./constants is the browser-safe entry (root index re-exports internal-sync with node:crypto — browser apps like viewer's embed page must import constants from there).",
"//": "lint runs `eslint .` against the root flat config (Task 7). test is ts-jest with full type-checking (Task 6 type tests).",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -12,6 +12,10 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./constants": {
"types": "./dist/constants.d.ts",
"default": "./dist/constants.js"
},
"./realtime": {
"types": "./dist/realtime.d.ts",
"default": "./dist/realtime.js"
+4 -1
View File
@@ -14,5 +14,8 @@ export function configureApp(app: NestExpressApplication): void {
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin !== '');
app.enableCors({ origin: corsOrigins });
// exposedHeaders:嵌入轮播 iframe(跨源 fetch 公开展示位端点)需读 ETag 响应头
// 维持 If-None-Match 条件请求(P4b Task 4)——CORS 缺省只暴露 simple headers,
// 不加此配置浏览器侧 response.headers.get('etag') 恒为 null(304 语义失效)。
app.enableCors({ origin: corsOrigins, exposedHeaders: ['ETag'] });
}