85146be7ef
Co-Authored-By: Claude Code <noreply@anthropic.com>
882 lines
31 KiB
Vue
882 lines
31 KiB
Vue
<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>
|