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
+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>