feat(embed,viewer): UI-01 complete carousel, loader config and accessibility

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 18:55:15 +08:00
parent 1f67b7c818
commit ddf26a177f
22 changed files with 2274 additions and 326 deletions
@@ -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/暂停
});
});
+120 -37
View File
@@ -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);
});
});
+1
View File
@@ -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';
+23 -3
View File
@@ -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;
});
}
/**
+36
View File
@@ -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);
}
});
}
+16 -5
View File
@@ -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 {
+72 -61
View File
@@ -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,
+9
View File
@@ -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',
+11 -1
View File
@@ -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: '下载图片',
+91 -84
View File
@@ -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>
+210
View File
@@ -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>