Files
photo-live/apps/viewer/src/components/PhotoLightbox.vue
T

882 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { DownloadVariant } from '../api/client';
import type { PhotoSequence } from '../feed/sequence';
import type { DownloadController } from '../download/useDownload';
import type { LikesController } from '../likes/useLikes';
import type { LikeBubble } from '../realtime/useAlbumRealtime';
import PhotoActionBar from './viewer/PhotoActionBar.vue';
import HeartParticles from './viewer/HeartParticles.vue';
import InteractionNotice from './viewer/InteractionNotice.vue';
import ThumbnailStrip, { type ThumbnailItem } from './viewer/ThumbnailStrip.vue';
import ShareSheet from './viewer/ShareSheet.vue';
import { positionDisplay } from '../viewer/position';
import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom';
// 大图查看器(Task 8 业务编排 + UI-02 V04 深色沉浸布局):
// · 布局层级(§5.1):顶栏(关闭 × / 位置计数 / ⋯ 菜单)→ 图片舞台(contain
// 大图 + 边缘 ‹ › 切换 + 粒子/互动占位层)→ 缩略图条 → 底部操作栏;
// · 业务逻辑不变:browse URL 展示、保持列表序列的左右切换(越过末尾且
// hasMore 时翻页扩展)、zoom.ts 缩放/平移、签名过期静默重取(复用
// sequence.refreshPhotoUrls)、下载两级开关与微信引导(useDownload)、
// 点赞乐观计数(useLikes);
// · 位置计数(§5.2 真实回退):见 viewer/position.ts——深链 solo 显示
// 「照片预览」,无 total 未到末页显示「已加载 N 张」,全部加载完成才
// 显示筛选序列 i/n;当前 API 无 total,不伪造总数;
// · 模态可用性:打开锁 body 滚动、焦点进入对话框并困于其内(Tab 循环)、
// 关闭/卸载时焦点回到触发元素;Esc 先收菜单再关查看器;左右键在菜单
// 焦点内不抢占(§5.3);
// · 关闭回位(滚动锚点 savedScrollY)与 URL push/replace/back 语义由
// ActivityPage 负责(见 ActivityPage 注释)。
//
// 手势(Pointer Events,无第三方依赖;与 V04 布局的协作):
// · 手势绑定在根元素,但顶栏/缩略图条/底部操作栏(data-lightbox-chrome)
// 内的指针不参与手势——条带横滚、按钮点击不会误触轻扫切图;
// · 双指:捏合缩放(不动点 = 手势中心)+ 中点漂移平移;
// · 单指且 scale>1:拖拽平移;scale=1:横向轻扫切换上一张/下一张;
// · 双击(dblclick / 触控双 tap):复位缩放;滚轮:以光标为不动点缩放
// (缩略图条 data-native-scroll 区域内的滚轮留给条带横滚);
// · 舞台空白区(非图片)点击关闭;手势位移后的 click 不关闭。
const props = defineProps<{
sequence: PhotoSequence;
photoId: string;
/** 点赞控制器(Task 10):缺省时 Like 位退化为静态计数(组件单测/降级组装)。 */
likes?: LikesController;
/** 下载控制器(P4a Task 4):缺省时不渲染下载位(组件单测/降级组装)。 */
download?: DownloadController;
/** 相册标题(§5.4 navigator.share 的分享标题)。 */
albumTitle?: string;
/** 当前照片分享深链(含 lang 参数,ActivityPage 组装;缺省回退当前地址)。 */
shareUrl?: string;
/** 他人点赞气泡(WS likes.burst,realtime router 已滤自身回声/隐藏/减少动画):
* 当前照片 → 心形粒子,其他照片 → 跨照片互动提示(§6.2 分层)。 */
likeBubbles?: LikeBubble[];
}>();
const emit = defineEmits<{
'update:photoId': [photoId: string];
/** 当前照片经刷新确认已下线(404):neighborIndex 为移除前下标,供回退邻居。 */
removed: [photoId: string, neighborIndex: number];
close: [];
}>();
const { t } = useI18n();
const photos = computed(() => props.sequence.photos);
const index = computed(() => photos.value.findIndex((photo) => photo.photoId === props.photoId));
const photo = computed(() => photos.value[index.value] ?? null);
const total = computed(() => photos.value.length);
const hasPrev = computed(() => index.value > 0);
// 末张且仍有下一页 → 允许继续向右(导航触发序列扩展)。
const hasNext = computed(() => index.value < total.value - 1 || props.sequence.hasMore);
// —— 位置计数(§5.2):形态判定在 viewer/position.ts,此处只拼接文案 ——
const positionText = computed(() => {
const display = positionDisplay({
index: index.value,
loaded: photos.value.length,
// 当前公开端点为游标分页(CursorPageMeta 无 total)——不伪造总数;
// 接口将来提供准确 total 时改传实值即得「12 / 248」形态。
total: null,
atEnd: !props.sequence.hasMore,
solo: props.sequence.isSolo(props.photoId),
});
switch (display.kind) {
case 'preview':
return t('viewer.photoPreview');
case 'loaded':
return t('viewer.loadedCount', { count: display.count });
default:
return `${display.current} / ${display.total}`;
}
});
// —— 视图状态(按 photoId;切换照片即复位) ——
/** 图片单次重试的缓存击穿键:重试按钮点击后 +1 强制 <img> 重挂载重触发加载。 */
const retryCount = ref(0);
const failedIds = reactive(new Set<string>());
const retriedIds = reactive(new Set<string>());
const extending = ref(false);
watch(
() => props.photoId,
() => {
failedIds.clear();
retriedIds.clear();
retryCount.value = 0;
Object.assign(zoom, resetZoom());
},
);
// —— 缩放/平移 ——
const zoom = reactive<ZoomState>({ ...resetZoom() });
const rootEl = ref<HTMLElement | null>(null);
/** 舞台(图片容器):缩放不动点坐标系的原点(图片经 flex 居中于其中)。 */
const stageEl = ref<HTMLElement | null>(null);
const imgStyle = computed(() => ({
transform: `translate(${zoom.tx}px, ${zoom.ty}px) scale(${zoom.scale})`,
}));
/** 手势中心(相对图片未变换时中心的坐标):图片经 flex 居中,未变换中心 = 舞台中心。 */
function center(): { x: number; y: number } {
const rect = stageEl.value?.getBoundingClientRect();
if (rect === undefined) return { x: 0, y: 0 };
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
function onWheel(event: WheelEvent): void {
// 缩略图条等原生滚动区(data-native-scroll)内的滚轮留给自身横滚。
if (event.target instanceof HTMLElement && event.target.closest('[data-native-scroll]') !== null) return;
event.preventDefault();
const { x, y } = center();
Object.assign(zoom, zoomAt({ ...zoom }, wheelFactor(event.deltaY), event.clientX - x, event.clientY - y));
}
function onDblClick(event: MouseEvent): void {
if (inChrome(event.target)) return;
Object.assign(zoom, resetZoom());
}
// —— 指针手势(双指捏合 / 单指平移 / 单指轻扫 / 双 tap 复位) ——
const pointers = new Map<number, { x: number; y: number }>();
let pinchDistance = 0;
let pinchMid = { x: 0, y: 0 };
let swipe: { x: number; y: number; dx: number; dy: number } | null = null;
let lastTap = { time: 0, x: 0, y: 0 };
/** 手势期间发生过位移:抑制手势收尾时的舞台 click 误关闭。 */
let dragged = false;
const SWIPE_MIN_DISTANCE = 50;
/** 顶栏/缩略图条/底部操作栏(data-lightbox-chrome):不参与手势与双击复位。 */
function inChrome(target: EventTarget | null): boolean {
return target instanceof HTMLElement && target.closest('[data-lightbox-chrome]') !== null;
}
/** 菜单(role=menu)焦点内左右键不抢占切图(§5.3)。 */
function inMenu(target: EventTarget | null): boolean {
return target instanceof HTMLElement && target.closest('[role="menu"]') !== null;
}
function pointerPair(): { a: { x: number; y: number }; b: { x: number; y: number } } | null {
if (pointers.size < 2) return null;
const [first, second] = pointers.values();
if (first === undefined || second === undefined) return null;
return { a: first, b: second };
}
function distance(a: { x: number; y: number }, b: { x: number; y: number }): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function onPointerDown(event: PointerEvent): void {
if (inChrome(event.target)) return;
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
dragged = false;
// 触控双 tap:400ms 内同点两落 → 复位缩放。
if (event.pointerType === 'touch' && pointers.size === 1) {
const now = performance.now();
if (now - lastTap.time < 400 && Math.hypot(event.clientX - lastTap.x, event.clientY - lastTap.y) < 40) {
Object.assign(zoom, resetZoom());
lastTap = { time: 0, x: 0, y: 0 };
} else {
lastTap = { time: now, x: event.clientX, y: event.clientY };
}
}
const pair = pointerPair();
if (pair !== null) {
pinchDistance = distance(pair.a, pair.b);
pinchMid = { x: (pair.a.x + pair.b.x) / 2, y: (pair.a.y + pair.b.y) / 2 };
swipe = null;
} else if (zoom.scale <= MIN_SCALE) {
swipe = { x: event.clientX, y: event.clientY, dx: 0, dy: 0 };
}
}
function onPointerMove(event: PointerEvent): void {
const previous = pointers.get(event.pointerId);
if (previous === undefined) return;
const current = { x: event.clientX, y: event.clientY };
pointers.set(event.pointerId, current);
const pair = pointerPair();
if (pair !== null) {
// 双指:距离比 → 缩放因子(不动点 = 当前中点);中点位移 → 平移。
const nextDistance = distance(pair.a, pair.b);
const mid = { x: (pair.a.x + pair.b.x) / 2, y: (pair.a.y + pair.b.y) / 2 };
const { x, y } = center();
const factor = pinchDistance > 0 ? nextDistance / pinchDistance : 1;
Object.assign(zoom, zoomAt({ ...zoom }, factor, mid.x - x, mid.y - y));
Object.assign(zoom, panBy({ ...zoom }, mid.x - pinchMid.x, mid.y - pinchMid.y));
pinchDistance = nextDistance;
pinchMid = mid;
dragged = true;
return;
}
if (zoom.scale > MIN_SCALE) {
// 单指平移(scale>1):平移期间不触发轻扫切图(§5.3)。
Object.assign(zoom, panBy({ ...zoom }, current.x - previous.x, current.y - previous.y));
dragged = true;
} else if (swipe !== null) {
swipe.dx = current.x - swipe.x;
swipe.dy = current.y - swipe.y;
if (Math.abs(swipe.dx) > 10 || Math.abs(swipe.dy) > 10) dragged = true;
}
}
/** 手势结束(抬起/取消/离开全屏层)。不使用 setPointerCapture:捕获会把后续
* click 重定向到捕获元素,按钮(关闭/切换)将收不到点击。 */
function onPointerEnd(event: PointerEvent): void {
pointers.delete(event.pointerId);
if (pointers.size < 2) pinchDistance = 0;
if (pointers.size === 1) {
// 双指 → 单指:剩余指针重新作为平移/轻扫基线。
const [remaining] = pointers.values();
if (remaining !== undefined) {
swipe = zoom.scale <= MIN_SCALE ? { x: remaining.x, y: remaining.y, dx: 0, dy: 0 } : null;
}
return;
}
if (pointers.size > 0) return;
const gesture = swipe;
swipe = null;
if (gesture === null) return;
// 以抬起坐标结算总位移(最后一帧 move 可能早于抬起点)。
gesture.dx = event.clientX - gesture.x;
gesture.dy = event.clientY - gesture.y;
if (Math.abs(gesture.dx) > SWIPE_MIN_DISTANCE && Math.abs(gesture.dx) > Math.abs(gesture.dy) * 1.2) {
if (gesture.dx < 0) void goTo(index.value + 1);
else void goTo(index.value - 1);
}
}
/** 舞台空白区(目标=容器自身,非图片/占位)点击关闭;手势位移后的 click 不关闭。 */
function onStageClick(event: MouseEvent): void {
if (dragged) return;
if (event.target !== event.currentTarget) return;
emit('close');
}
// —— 键盘:Esc 关闭(菜单开时先收菜单)、←→ 切换、Tab 焦点困于对话框 ——
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
emit('close');
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
if (inMenu(event.target)) return;
event.preventDefault();
void goTo(event.key === 'ArrowLeft' ? index.value - 1 : index.value + 1);
}
}
/** Tab 循环限制在对话框内(模态焦点不穿透,§5.3)。 */
function onTrapTab(event: KeyboardEvent): void {
const root = rootEl.value;
if (root === null) return;
const focusables = Array.from(
root.querySelectorAll<HTMLElement>('button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),
);
if (focusables.length === 0) {
event.preventDefault();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (event.shiftKey) {
if (active === first || !root.contains(active)) {
event.preventDefault();
last.focus();
}
} else if (active === last || !root.contains(active)) {
event.preventDefault();
first.focus();
}
}
// —— 导航(保持列表浏览序列;越过末尾且 hasMore → 扩展) ——
async function goTo(targetIndex: number): Promise<void> {
if (targetIndex < 0 || targetIndex === index.value) return;
if (targetIndex >= total.value) {
if (!props.sequence.hasMore) return;
extending.value = true;
try {
await props.sequence.loadMore();
} finally {
extending.value = false;
}
if (targetIndex >= photos.value.length) return; // 翻页失败/无新增:留在原处
}
const target = photos.value[targetIndex];
if (target === undefined || target.photoId === props.photoId) return;
emit('update:photoId', target.photoId);
}
/** 缩略图条选择:与手势/键盘共用 goTo 入口(update:photoId → 父级 replace 路由,
* 不新增浏览器历史)。 */
function selectThumbnail(photoId: string): void {
if (photoId === props.photoId) return;
const targetIndex = photos.value.findIndex((item) => item.photoId === photoId);
if (targetIndex >= 0) void goTo(targetIndex);
}
// 相邻预取(±1 browse URL)+ 临近末尾预拉下一页,让右切更顺滑。
watch(
() => [props.photoId, photos.value] as const,
() => {
if (index.value === -1) return;
for (const neighbor of [index.value - 1, index.value + 1]) {
const target = photos.value[neighbor];
if (target !== undefined) void preload(target.browse.url);
}
if (index.value >= photos.value.length - 2 && props.sequence.hasMore && !props.sequence.loadingMore) {
void props.sequence.loadMore();
}
},
{ immediate: true },
);
function preload(url: string): void {
const img = new Image();
img.src = url;
}
// —— 点赞(Task 10):乐观 +1 与心跳动画由 LikesController 承担 ——
const displayLikeCount = computed(() => {
if (photo.value === null) return 0;
return props.likes !== undefined ? props.likes.displayFor(photo.value) : photo.value.likeCount;
});
function likeCurrent(): void {
if (photo.value === null || props.likes === undefined) return;
props.likes.like(photo.value.photoId);
}
// —— 互动编排(Task 5 / V05 §6.2):当前照片心形粒子 + 其他照片互动提示分层 ——
const heartParticles = ref<{ burst: (count: number) => void } | null>(null);
const interactionNotice = ref<{ notify: (entry: { photoId: string; delta: number; thumbUrl: string | null }) => void } | null>(null);
/** 本地点赞每次有效点击的粒子数(1-3 取中,§6.1 立即轻量反馈)。 */
const LOCAL_LIKE_PARTICLES = 2;
// 本地点击(useLikes 动效事件):当前照片 → 舞台右区心形粒子。
let stopLocalLikeListener: (() => void) | undefined;
if (props.likes !== undefined) {
stopLocalLikeListener = props.likes.onLocalLike((photoId) => {
if (photoId === props.photoId) heartParticles.value?.burst(LOCAL_LIKE_PARTICLES);
});
}
// 他人点赞气泡(§6.2 两类反馈分开):当前照片 → 粒子(1-2 枚按 delta 采样,
// 数值仍按真实 delta 校准——视觉采样不改变统计,§6.3);其他照片 → 互动提示。
// 仅处理挂载后新到的气泡:打开大图瞬间已在屏的不回放(§6.3 不回放历史动画)。
let bubblesInitialized = false;
let maxSeenBubbleKey = Number.NEGATIVE_INFINITY;
watch(
() => props.likeBubbles,
(bubbles) => {
if (bubbles === undefined) return;
if (!bubblesInitialized) {
bubblesInitialized = true;
for (const bubble of bubbles) maxSeenBubbleKey = Math.max(maxSeenBubbleKey, bubble.key);
return;
}
for (const bubble of bubbles) {
if (bubble.key <= maxSeenBubbleKey) continue;
maxSeenBubbleKey = bubble.key;
if (bubble.photoId === props.photoId) {
heartParticles.value?.burst(Math.min(2, Math.max(1, bubble.delta)));
} else {
interactionNotice.value?.notify({
photoId: bubble.photoId,
delta: bubble.delta,
thumbUrl: bubble.thumbUrl,
});
}
}
},
{ immediate: true },
);
// —— 下载(P4a Task 4):入口与二级菜单在 PhotoActionBar,控制器编排在此 ——
const canDownload = computed(() => props.download !== undefined && props.download.canDownload);
const canDownloadOriginal = computed(() => canDownload.value && props.download !== undefined && props.download.canDownloadOriginal);
function onDownloadVariant(variant: DownloadVariant): void {
void props.download?.download(variant);
}
// —— 分享(V05 §5.4):navigator.share 优先(成功/取消无额外反馈——原生面板
// 已收尾;AbortError 静默),不可用或其他失败回退剪贴板;复制失败保留
// 可选中文本。分享目标为照片深链(含 lang 参数,ActivityPage 组装),
// 不分享 OSS 签名 URL ——
const shareSheetOpen = ref(false);
const shareUrlText = ref('');
function shareCurrent(): void {
shareUrlText.value = props.shareUrl ?? window.location.href;
shareSheetOpen.value = true;
}
// —— 签名过期/下线处理 ——
/** 大图加载失败:静默重取单图接口换新签名 URL;404 → 通知父级移除;其他失败可重试。 */
async function onImgError(): Promise<void> {
const current = photo.value;
if (current === null) return;
const { photoId } = current;
if (retriedIds.has(photoId)) {
failedIds.add(photoId);
return;
}
retriedIds.add(photoId);
const neighborIndex = index.value;
const result = await props.sequence.refreshPhotoUrls(photoId);
if (result === 'replaced') return; // 新签名 URL 换绑后 <img> 自动重载
if (result === 'removed') {
if (props.photoId === photoId) emit('removed', photoId, neighborIndex);
return;
}
failedIds.add(photoId); // 网络等其他失败:收起图片,展示可重试占位
}
function retryFailed(): void {
const current = photo.value;
if (current === null) return;
failedIds.delete(current.photoId);
retriedIds.delete(current.photoId);
retryCount.value += 1; // 强制 <img> 重挂载,重新触发加载/错误链
}
// —— 模态可用性:body 滚动锁 + 焦点进入/困住/归还(§5.1/§5.3) ——
let savedBodyOverflow = '';
let savedFocus: HTMLElement | null = null;
let focusClaimed = false;
// 根节点渲染时(深链下序列就位前组件先挂载、photo 后到):焦点进入对话框。
watch(rootEl, (el) => {
if (el === null || focusClaimed) return;
focusClaimed = true;
savedFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
el.focus();
});
const thumbItems = computed<ThumbnailItem[]>(() =>
photos.value.map((item) => ({ photoId: item.photoId, thumbUrl: item.thumb.url })),
);
onMounted(() => {
window.addEventListener('keydown', onKeydown);
savedBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden'; // 锁背景滚动(§5.1)
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown);
document.body.style.overflow = savedBodyOverflow;
stopLocalLikeListener?.(); // 粒子订阅随大图卸载解除(useLikes 侧不残留)
// 焦点归还触发元素(瀑布流单元格;深链直达时为 body——无可归还则不动)。
if (savedFocus !== null && savedFocus.isConnected) savedFocus.focus();
});
</script>
<template>
<div
v-if="photo !== null"
ref="rootEl"
class="photo-viewer"
role="dialog"
aria-modal="true"
:aria-label="t('viewer.photoViewer')"
tabindex="-1"
@wheel="onWheel"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerEnd"
@pointercancel="onPointerEnd"
@pointerleave="onPointerEnd"
@dblclick="onDblClick"
@keydown.tab="onTrapTab"
>
<!-- 顶栏(§5.1:52-56px 深色半透明):关闭 / 位置计数(⋯ 菜单已按用户要求移除) -->
<header class="viewer-topbar" data-lightbox-chrome>
<button type="button" class="viewer-close" :aria-label="t('viewer.close')" @click="emit('close')">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M6 6l12 12" />
<path d="M18 6 6 18" />
</svg>
</button>
<p class="position-indicator">{{ positionText }}</p>
</header>
<!-- 图片舞台(flex:1):contain 大图 + 边缘切换 + 粒子/互动占位层 -->
<main class="viewer-stage">
<div ref="stageEl" class="image-container" @click="onStageClick">
<img
v-if="!failedIds.has(photo.photoId)"
:key="`${photo.photoId}:${retryCount}`"
class="photo"
:src="photo.browse.url"
:alt="t('route.photoLabel')"
:style="imgStyle"
draggable="false"
@error="onImgError"
>
<div v-else class="img-failed" role="alert">
<p>{{ t('viewer.loadFailed') }}</p>
<button type="button" @click="retryFailed">{{ t('album.retry') }}</button>
</div>
</div>
<!-- 参考稿:42px 细体(200)字形箭头 + 半透明圆底 rgba(0,0,0,.13) -->
<button
type="button"
class="nav prev"
:disabled="!hasPrev"
:aria-label="t('viewer.previous')"
@click="goTo(index - 1)"
>
<span class="nav-glyph" aria-hidden="true">‹</span>
</button>
<button
type="button"
class="nav next"
:disabled="!hasNext"
:aria-label="t('viewer.next')"
@click="goTo(index + 1)"
>
<span class="nav-glyph" aria-hidden="true">›</span>
</button>
<!-- 心形粒子层(Task 5 / V05 §6.2):装饰性、不可交互;触发见上方互动编排 -->
<HeartParticles ref="heartParticles" />
<!-- 跨照片互动提示层(Task 5 / V05 §6.2):底部操作栏上方安全区;
点击定位 = 缩略图条共用入口(序列未含该照片时忽略——不伪造定位) -->
<InteractionNotice ref="interactionNotice" @select="selectThumbnail" />
<!-- 分享弹层(2026-09-23 用户需求:二维码 + 链接 + 复制,页面级提示退役) -->
<ShareSheet
v-model="shareSheetOpen"
:heading="t('viewer.sharePhoto')"
:title="albumTitle ?? ''"
:url="shareUrlText"
/>
<div class="stage-notices">
<p v-if="extending" class="loading">{{ t('album.loading') }}</p>
<!-- 下载失败轻提示(端点失败/网络;DOWNLOAD_DISABLED 专属文案):
仅归属到触发下载的那张照片(§5.4 异步错误归属)。 -->
<p
v-if="download !== undefined && download.failed && download.failedFor === photo.photoId"
class="download-failed"
role="alert"
>
{{ t(download.failed) }}
</p>
</div>
</main>
<!-- 缩略图条(§5.3):当前 ±3 窗口;点击选择(不新增历史) -->
<ThumbnailStrip :photos="thumbItems" :current-photo-id="photoId" @select="selectThumbnail" />
<!-- 底部操作栏(§5.1:88-104px + 安全区):Like / Share / Download -->
<footer class="viewer-actionbar" data-lightbox-chrome>
<PhotoActionBar
:like-count="displayLikeCount"
:like-pop="likes !== undefined && photo !== null ? likes.popNonce(photo.photoId) : 0"
:can-download="canDownload"
:can-download-original="canDownloadOriginal"
@like="likeCurrent"
@share="shareCurrent"
@download="onDownloadVariant"
/>
</footer>
<!-- 微信内置浏览器引导(P4a Task 4):替代直接下载——长按保存 + 复制深链。 -->
<div
v-if="download !== undefined && download.wechatGuide"
class="wechat-guide"
role="dialog"
aria-modal="true"
:aria-label="t('download.wechatTitle')"
>
<div class="wechat-card">
<h2>{{ t('download.wechatTitle') }}</h2>
<p>{{ t('download.wechatHint') }}</p>
<!-- clipboard 不可用降级:展示可选中文本供手动长按复制。 -->
<p v-if="download.copyNotice === 'failed'" class="copy-fallback">{{ download.shareUrl() }}</p>
<div class="guide-actions">
<button type="button" class="guide-copy" @click="download.copyShareLink()">
{{ t('download.copyLink') }}
</button>
<span v-if="download.copyNotice === 'copied'" class="copy-status" role="status">
{{ t('download.copied') }}
</span>
<span v-else-if="download.copyNotice === 'failed'" class="copy-status" role="status">
{{ t('download.copyFailed') }}
</span>
<button type="button" class="guide-dismiss" @click="download.dismissWechatGuide()">
{{ t('download.dismiss') }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* —— UI-02 V04 深色沉浸布局(§5.1):dvh + 安全区;token 经祖先 .viewer-shell
* 提供,独立挂载(无 shell)时 var() 回退同值 —— */
.photo-viewer {
position: fixed;
inset: 0;
/* 200:盖住 sticky 顶栏(z=120)与偏好菜单(z=130)——全屏模态下顶栏不可
* 点也不可见;下线轻提示(z=210)仍在其上。 */
z-index: 200;
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
background: var(--viewer-dark-bg, #0e1218);
color: #ffffff;
user-select: none;
}
.photo-viewer:focus {
outline: none;
}
/* 顶栏:grid 三列(44px | 1fr 居中计数 | 44px),半透明深色。 */
.viewer-topbar {
flex: none;
display: grid;
grid-template-columns: var(--viewer-touch-target, 44px) 1fr var(--viewer-touch-target, 44px);
align-items: center;
min-height: 52px;
padding: 0 8px;
padding-top: env(safe-area-inset-top, 0px);
background: rgba(14, 18, 24, 0.72);
}
.viewer-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--viewer-touch-target, 44px);
height: var(--viewer-touch-target, 44px);
border: none;
border-radius: 50%;
background: transparent;
color: #ffffff;
cursor: pointer;
}
.viewer-close:hover {
background: rgba(255, 255, 255, 0.1);
}
.viewer-close:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
}
.viewer-close svg {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
/* 舞台:占剩余空间;contain 完整大图(不裁切);手势区 touch-action none。 */
.viewer-stage {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
touch-action: none;
}
.image-container {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.photo {
position: relative;
max-width: 100%;
max-height: 100%;
object-fit: contain;
transform-origin: center;
will-change: transform;
}
.img-failed {
position: relative;
color: #f3f4f6;
text-align: center;
}
.img-failed button {
margin-top: 8px;
border: 1px solid #4b5563;
border-radius: 6px;
background: transparent;
color: #e5e7eb;
padding: 4px 12px;
cursor: pointer;
}
/* 参考稿切换箭头:44px 宽 48px 高圆钮、半透明黑底 rgba(0,0,0,.13)、
* 42px/200 细体字形、白字 + 深色 text-shadow;top calc(50% - 24px)。 */
.nav {
position: absolute;
top: calc(50% - 24px);
display: flex;
align-items: center;
justify-content: center;
width: var(--viewer-touch-target, 44px);
height: 48px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.13);
color: #ffffff;
padding: 0;
cursor: pointer;
}
.nav:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.3);
}
.nav:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
}
.nav:disabled {
opacity: 0.3;
cursor: default;
}
.nav-glyph {
font-size: 2.625rem;
font-weight: 200;
line-height: 1;
text-shadow: 0 1px 8px #000;
/* 字形基线在圆内视觉居中 */
transform: translateY(-2px);
user-select: none;
}
.nav.prev {
left: 4px;
}
.nav.next {
right: 4px;
}
/* 粒子层/互动提示层的定位与样式由 HeartParticles / InteractionNotice 自带
* (Task 5:舞台右侧上浮 / 操作栏上方安全区)。 */
.stage-notices {
position: absolute;
bottom: 12px;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
pointer-events: none;
z-index: 4;
}
.loading {
margin: 0;
color: #d1d5db;
}
.download-failed {
margin: 0;
border-radius: var(--viewer-radius-pill, 20px);
background: rgba(185, 28, 28, 0.92);
color: #fef2f2;
padding: 6px 16px;
font-size: 0.8125rem;
}
/* 底部操作栏容器:安全区 padding;按钮编排(Like/Share/Download)在
* PhotoActionBar(§5.4:下载关闭移除整项并均分剩余栏宽)。 */
.viewer-actionbar {
flex: none;
/* 参考稿:5px 16px(+ 底部安全区) */
padding: 5px 16px;
padding-bottom: calc(5px + env(safe-area-inset-bottom, 0px));
background: rgba(14, 18, 24, 0.72);
}
/* —— 微信引导浮层 —— */
.wechat-guide {
position: absolute;
inset: 0;
z-index: 8;
display: flex;
align-items: center;
justify-content: center;
background: rgba(17, 24, 39, 0.72);
padding: 24px;
}
.wechat-card {
max-width: 420px;
border-radius: 12px;
background: #f9fafb;
color: #111827;
padding: 20px 24px;
}
.wechat-card h2 {
margin: 0 0 8px;
font-size: 1.05rem;
}
.wechat-card p {
margin: 0 0 12px;
font-size: 0.9rem;
line-height: 1.6;
white-space: pre-line;
}
.copy-fallback {
user-select: all;
word-break: break-all;
background: #e5e7eb;
border-radius: 6px;
padding: 6px 8px;
}
.guide-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.guide-actions button {
border: none;
border-radius: 999px;
padding: 6px 16px;
font-size: 0.9rem;
cursor: pointer;
}
.guide-copy {
background: #1d4ed8;
color: #fff;
}
.guide-dismiss {
background: #e5e7eb;
color: #374151;
}
.copy-status {
font-size: 0.8rem;
color: #6b7280;
}
</style>