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:
@@ -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>
|
||||
Reference in New Issue
Block a user