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