feat(embed,viewer): UI-01 complete carousel, loader config and accessibility
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user