feat(viewer): photo viewer with zoom, navigation and deep link
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,120 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
ApiError,
|
||||
NETWORK_ERROR_CODE,
|
||||
getPublicPhoto,
|
||||
getPublicPhotos,
|
||||
type PublicPhoto,
|
||||
type PublicPhotoList,
|
||||
} from '../api/client';
|
||||
import { errorMessageKey } from '../api/errors';
|
||||
import { containsNewPhotoId, layoutWaterfall, mergeUniqueByPhotoId } from '../feed/layout';
|
||||
import type { PublicPhoto } from '../api/client';
|
||||
import type { PhotoSequence } from '../feed/sequence';
|
||||
import { layoutWaterfall } from '../feed/layout';
|
||||
|
||||
// 观众瀑布流(Task 7):双列绝对定位(短列优先,条目高按 browse 宽高比例预留)
|
||||
// + IntersectionObserver 懒加载 + 游标无限滚动 + 分类筛选 + 新照片轮询提示。
|
||||
// 观众瀑布流(Task 7;Task 8 起数据层上提):双列绝对定位(短列优先,条目高按
|
||||
// browse 宽高比例预留)+ IntersectionObserver 懒加载 + 哨兵无限滚动 + 新照片提示条。
|
||||
// 数据生命周期(分页/去重/轮询/深链 solo)在 ActivityPage 创建的 PhotoSequence
|
||||
// composable(feed/sequence.ts)——feed 与大图查看器共享同一序列,切换大图时
|
||||
// 保持浏览顺序、越过末尾自动扩展。
|
||||
//
|
||||
// 关键决策:
|
||||
// · 懒加载 = 仅当单元格接近视口才绑定 <img src>(等价 data-src → src 换绑);
|
||||
// shownIds 集合跨刷新保留——刷新只换签名 URL,同 id 单元格不必重新判定;
|
||||
// IO 不可用(旧环境/jsdom)时退化为立即加载,功能不缺失。
|
||||
// · 图片 onerror 重试一次:调单图端点换新签名 URL 再换绑;再失败则收起该图
|
||||
// (占位底色仍在,布局因高度预留不跳动)。
|
||||
// · 轮询(30s,e2e 可用 window.__viewerPollMs 覆盖)只取第 1 页对比 id 集合,
|
||||
// 命中新照片只亮提示条——不自动插入(避免滚动位置跳动,需求 1.2);
|
||||
// 点击提示条 = 整表刷新回第 1 页并回顶。标签页隐藏暂停(R26 同精神)。
|
||||
// · 竞态防护:feedSequence 令牌——分类/语言/token 切换或刷新会使在途的
|
||||
// 分页/轮询结果作废;轮询失败静默(网络抖动不应打扰观众)。
|
||||
// 关键决策(沿用 Task 7):
|
||||
// · 懒加载 = 仅当单元格接近视口才绑定 <img src>;shownIds 集合跨刷新保留——
|
||||
// 刷新只换签名 URL,同 id 单元格不必重新判定;IO 不可用(旧环境/jsdom)时
|
||||
// 退化为立即加载,功能不缺失。
|
||||
// · 图片 onerror 重试一次:经 sequence.refreshPhotoUrls 换新签名 URL 再换绑;
|
||||
// 再失败(或照片已下线)则收起该图(占位底色仍在,布局因高度预留不跳动)。
|
||||
// · 单元格点击 emit('open', photoId) → ActivityPage 打开大图(Task 8)。
|
||||
|
||||
const props = defineProps<{
|
||||
token: string;
|
||||
locale: string;
|
||||
/** 当前分类筛选(null = 全部)。由活动页 chips 持有,切换即重置列表。 */
|
||||
category: string | null;
|
||||
sequence: PhotoSequence;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [photoId: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const photos = ref<PublicPhoto[]>([]);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMore = ref(false);
|
||||
const initialLoading = ref(true);
|
||||
const initialErrorKey = ref<string | null>(null);
|
||||
const loadingMore = ref(false);
|
||||
const moreFailed = ref(false);
|
||||
const hasNew = ref(false);
|
||||
const photos = computed(() => props.sequence.photos);
|
||||
const initialLoading = computed(() => props.sequence.initialLoading);
|
||||
const initialErrorKey = computed(() => props.sequence.initialErrorKey);
|
||||
const loadingMore = computed(() => props.sequence.loadingMore);
|
||||
const moreFailed = computed(() => props.sequence.moreFailed);
|
||||
const hasMore = computed(() => props.sequence.hasMore);
|
||||
const hasNew = computed(() => props.sequence.hasNew);
|
||||
|
||||
// 懒加载/重试视图状态(按 photoId,跨刷新保留,见顶部决策注)。
|
||||
const shownIds = reactive(new Set<string>());
|
||||
const retriedIds = reactive(new Set<string>());
|
||||
const failedIds = reactive(new Set<string>());
|
||||
|
||||
let feedSequence = 0;
|
||||
|
||||
function fetchPage(cursor?: string): Promise<PublicPhotoList> {
|
||||
return getPublicPhotos(props.token, {
|
||||
locale: props.locale,
|
||||
category: props.category ?? undefined,
|
||||
cursor,
|
||||
});
|
||||
}
|
||||
|
||||
/** 整表重置加载(首载/分类切换/语言切换/token 切换/新照片刷新共用)。 */
|
||||
async function resetAndLoad(): Promise<void> {
|
||||
const sequence = ++feedSequence;
|
||||
initialLoading.value = true;
|
||||
initialErrorKey.value = null;
|
||||
moreFailed.value = false;
|
||||
loadingMore.value = false;
|
||||
hasNew.value = false;
|
||||
photos.value = [];
|
||||
nextCursor.value = null;
|
||||
hasMore.value = false;
|
||||
pruneCellElements();
|
||||
try {
|
||||
const page = await fetchPage();
|
||||
if (sequence !== feedSequence) return;
|
||||
photos.value = [...page.items];
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMore.value = page.hasMore && page.nextCursor !== null;
|
||||
} catch (error) {
|
||||
if (sequence !== feedSequence) return;
|
||||
const code = error instanceof ApiError ? error.code : NETWORK_ERROR_CODE;
|
||||
initialErrorKey.value = errorMessageKey(code);
|
||||
} finally {
|
||||
if (sequence === feedSequence) initialLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 无限滚动:哨兵进入视口(600px 预载)时取下一页;去重防御性合并。 */
|
||||
async function loadMore(): Promise<void> {
|
||||
if (initialLoading.value || initialErrorKey.value !== null) return;
|
||||
if (loadingMore.value || !hasMore.value || nextCursor.value === null) return;
|
||||
const sequence = feedSequence;
|
||||
loadingMore.value = true;
|
||||
moreFailed.value = false;
|
||||
try {
|
||||
const page = await fetchPage(nextCursor.value);
|
||||
if (sequence !== feedSequence) return;
|
||||
photos.value = mergeUniqueByPhotoId(photos.value, page.items);
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMore.value = page.hasMore && page.nextCursor !== null;
|
||||
pruneCellElements();
|
||||
} catch {
|
||||
if (sequence === feedSequence) moreFailed.value = true;
|
||||
} finally {
|
||||
if (sequence === feedSequence) loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 新照片提示条点击:整表刷新回第 1 页并回顶(提示消失)。 */
|
||||
async function refreshFromHint(): Promise<void> {
|
||||
if (initialLoading.value) return;
|
||||
await resetAndLoad();
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
// —— 布局:容器宽 → 纯函数双列放置(ResizeObserver 跟踪容器宽度变化) ——
|
||||
|
||||
const trackEl = ref<HTMLElement | null>(null);
|
||||
@@ -191,15 +118,22 @@ function pruneCellElements(): void {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
photos,
|
||||
() => pruneCellElements(),
|
||||
);
|
||||
|
||||
watch(
|
||||
sentinelEl,
|
||||
(el) => {
|
||||
sentinelObserver?.disconnect();
|
||||
sentinelObserver = null;
|
||||
if (el === null) return;
|
||||
// IO 不可用(jsdom/旧环境)时不观察:无限滚动退化为已加载内容(不崩溃)。
|
||||
if (typeof IntersectionObserver === 'undefined') return;
|
||||
sentinelObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) void loadMore();
|
||||
if (entries.some((entry) => entry.isIntersecting)) void props.sequence.loadMore();
|
||||
},
|
||||
{ rootMargin: '600px 0px' },
|
||||
);
|
||||
@@ -208,99 +142,28 @@ watch(
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
/** 缩略图加载失败:重试一次(单图端点换新签名 URL);再失败收起图片。 */
|
||||
/** 缩略图加载失败:重试一次(单图端点换新签名 URL);再失败/已下线则收起图片。 */
|
||||
async function onImgError(photo: PublicPhoto): Promise<void> {
|
||||
const { photoId } = photo;
|
||||
if (failedIds.has(photoId)) return;
|
||||
if (retriedIds.has(photoId)) {
|
||||
failedIds.add(photoId);
|
||||
if (failedIds.has(photo.photoId)) return;
|
||||
if (retriedIds.has(photo.photoId)) {
|
||||
failedIds.add(photo.photoId);
|
||||
return;
|
||||
}
|
||||
retriedIds.add(photoId);
|
||||
try {
|
||||
const detail = await getPublicPhoto(props.token, photoId);
|
||||
photo.thumb = detail.thumb;
|
||||
photo.browse = detail.browse;
|
||||
} catch {
|
||||
failedIds.add(photoId);
|
||||
}
|
||||
retriedIds.add(photo.photoId);
|
||||
const result = await props.sequence.refreshPhotoUrls(photo.photoId);
|
||||
if (result !== 'replaced') failedIds.add(photo.photoId);
|
||||
}
|
||||
|
||||
// —— 新照片轮询 ——
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
/** e2e/开发注入点:window.__viewerPollMs 覆盖轮询间隔(仅测试加速用)。 */
|
||||
function pollIntervalMs(): number {
|
||||
const override = (globalThis as { __viewerPollMs?: unknown }).__viewerPollMs;
|
||||
return typeof override === 'number' && override > 0 ? override : DEFAULT_POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer !== undefined) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
stopPolling();
|
||||
pollTimer = setInterval(() => void pollOnce(), pollIntervalMs());
|
||||
}
|
||||
|
||||
/** 取当前筛选的第 1 页对比 id 集合;发现新照片只亮提示条(不自动插入)。失败静默。 */
|
||||
async function pollOnce(): Promise<void> {
|
||||
if (
|
||||
document.hidden ||
|
||||
initialLoading.value ||
|
||||
initialErrorKey.value !== null ||
|
||||
hasNew.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const sequence = feedSequence;
|
||||
try {
|
||||
const page = await fetchPage();
|
||||
if (sequence !== feedSequence) return;
|
||||
if (containsNewPhotoId(photos.value.map((photo) => photo.photoId), page.items)) {
|
||||
hasNew.value = true;
|
||||
}
|
||||
} catch {
|
||||
// 轮询失败静默:网络抖动不打扰观众(下一次间隔重试)。
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
if (document.hidden) stopPolling();
|
||||
else startPolling();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void resetAndLoad();
|
||||
startPolling();
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling();
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
lazyObserver?.disconnect();
|
||||
sentinelObserver?.disconnect();
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
|
||||
// 分类/语言/token 变化:整表重置(语言切换保留当前分类——由 props 自带)。
|
||||
watch(
|
||||
[() => props.token, () => props.locale, () => props.category],
|
||||
() => void resetAndLoad(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="photo-feed">
|
||||
<button v-if="hasNew" type="button" class="new-photo-bar" @click="refreshFromHint">
|
||||
<button v-if="hasNew" type="button" class="new-photo-bar" @click="props.sequence.refreshFromHint()">
|
||||
{{ t('album.newPhotos') }}
|
||||
</button>
|
||||
|
||||
@@ -308,7 +171,7 @@ watch(
|
||||
|
||||
<div v-else-if="initialErrorKey !== null" class="error" role="alert">
|
||||
<p>{{ t(initialErrorKey) }}</p>
|
||||
<button type="button" @click="resetAndLoad">{{ t('album.retry') }}</button>
|
||||
<button type="button" @click="props.sequence.resetAndLoad">{{ t('album.retry') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="photos.length === 0" class="feed">
|
||||
@@ -328,6 +191,7 @@ watch(
|
||||
width: `${layout.columnWidth}px`,
|
||||
height: `${placement.height}px`,
|
||||
}"
|
||||
@click="emit('open', photos[placement.index].photoId)"
|
||||
>
|
||||
<img
|
||||
v-if="shownIds.has(photos[placement.index].photoId) && !failedIds.has(photos[placement.index].photoId)"
|
||||
@@ -343,7 +207,7 @@ watch(
|
||||
|
||||
<p v-if="loadingMore" class="status more-status">{{ t('album.loadingMore') }}</p>
|
||||
<div v-else-if="moreFailed" class="more-failed">
|
||||
<button type="button" @click="loadMore">{{ t('album.retry') }}</button>
|
||||
<button type="button" @click="props.sequence.loadMore">{{ t('album.retry') }}</button>
|
||||
</div>
|
||||
<p v-else-if="!hasMore" class="end-note">{{ t('album.end') }}</p>
|
||||
|
||||
@@ -412,6 +276,7 @@ watch(
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(160deg, #f3f4f6, #e5e7eb);
|
||||
cursor: pointer;
|
||||
}
|
||||
.cell-img {
|
||||
display: block;
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { PhotoSequence } from '../feed/sequence';
|
||||
import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom';
|
||||
|
||||
// 大图查看器(Task 8):browse URL 全屏展示 + 左右切换(保持列表浏览序列,越过
|
||||
// 末尾且 hasMore 时取下一页扩展)+ 滚轮/双指缩放与平移(受控 CSS transform,数学
|
||||
// 见 viewer/zoom.ts)+ 签名过期静默重取(复用 sequence.refreshPhotoUrls)。
|
||||
//
|
||||
// 状态归属:序列数据/分页在 ActivityPage 创建的 PhotoSequence(共享 composable),
|
||||
// 本组件只持视图态(缩放/平移、加载失败标记、扩展中标记);当前照片以 photoId
|
||||
// (v-model)为准而非下标——序列追加/solo 归位/条目移除都会移动下标,photoId 键
|
||||
// 控让重排自动保持指向同一张照片。
|
||||
//
|
||||
// 手势(Pointer Events,无第三方依赖):
|
||||
// · 双指:捏合缩放(transform 不动点 = 手势中心)+ 中点漂移平移;
|
||||
// · 单指且 scale>1:拖拽平移;scale=1:横向轻扫切换上一张/下一张;
|
||||
// · 双击(鼠标 dblclick / 触控双 tap):复位缩放;
|
||||
// · 滚轮(无需 ctrl):以光标为不动点缩放;Esc/关闭按钮/背景点击关闭。
|
||||
//
|
||||
// 关闭回位(滚动锚点)与 URL 同步由 ActivityPage 负责(见 ActivityPage 注释)。
|
||||
|
||||
const props = defineProps<{
|
||||
sequence: PhotoSequence;
|
||||
photoId: string;
|
||||
}>();
|
||||
|
||||
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);
|
||||
|
||||
// —— 视图状态(按 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);
|
||||
|
||||
const imgStyle = computed(() => ({
|
||||
transform: `translate(${zoom.tx}px, ${zoom.ty}px) scale(${zoom.scale})`,
|
||||
}));
|
||||
|
||||
/** 手势中心(相对图片未变换时中心的坐标):图片经 flex 居中,未变换中心 = 容器中心。 */
|
||||
function center(): { x: number; y: number } {
|
||||
const rect = rootEl.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 {
|
||||
const { x, y } = center();
|
||||
Object.assign(zoom, zoomAt({ ...zoom }, wheelFactor(event.deltaY), event.clientX - x, event.clientY - y));
|
||||
}
|
||||
|
||||
function onDblClick(): void {
|
||||
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;
|
||||
|
||||
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 {
|
||||
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)。
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function onBackdropClick(): void {
|
||||
if (dragged) return; // 手势收尾的 click 不关闭
|
||||
emit('close');
|
||||
}
|
||||
|
||||
// —— 键盘 ——
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
emit('close');
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
void goTo(index.value - 1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
void goTo(index.value + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// —— 导航(保持列表浏览序列;越过末尾且 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);
|
||||
}
|
||||
|
||||
// 相邻预取(±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;
|
||||
}
|
||||
|
||||
// —— 签名过期/下线处理 ——
|
||||
|
||||
/** 大图加载失败:静默重取单图接口换新签名 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> 重挂载,重新触发加载/错误链
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="photo !== null"
|
||||
ref="rootEl"
|
||||
class="lightbox"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="t('viewer.title')"
|
||||
@wheel.prevent="onWheel"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerEnd"
|
||||
@pointercancel="onPointerEnd"
|
||||
@pointerleave="onPointerEnd"
|
||||
@dblclick="onDblClick"
|
||||
>
|
||||
<div class="backdrop" @click="onBackdropClick" />
|
||||
|
||||
<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>
|
||||
|
||||
<header class="bar">
|
||||
<span class="counter">{{ t('viewer.counter', { current: index + 1, total }) }}</span>
|
||||
<span class="likes">{{ t('viewer.likeCount', { count: photo.likeCount }) }}</span>
|
||||
<!-- P4a 预留:下载 / 分享操作位(P1b 不实现)。 -->
|
||||
<button type="button" class="control close" :aria-label="t('viewer.close')" @click="emit('close')">
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="control nav prev"
|
||||
:disabled="!hasPrev"
|
||||
:aria-label="t('viewer.previous')"
|
||||
@click="goTo(index - 1)"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="control nav next"
|
||||
:disabled="!hasNext"
|
||||
:aria-label="t('viewer.next')"
|
||||
@click="goTo(index + 1)"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
|
||||
<p v-if="extending" class="loading">{{ t('album.loading') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 极简基线样式:视觉打磨留待 P4a。 */
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(17, 24, 39, 0.92);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
color: #f9fafb;
|
||||
background: linear-gradient(rgba(0, 0, 0, 0.45), transparent);
|
||||
}
|
||||
.counter {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.likes {
|
||||
color: #d1d5db;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.control {
|
||||
border: none;
|
||||
background: rgba(17, 24, 39, 0.55);
|
||||
color: #f9fafb;
|
||||
cursor: pointer;
|
||||
}
|
||||
.control:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.control.close {
|
||||
margin-left: auto;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.control.nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.control.nav.prev {
|
||||
left: 12px;
|
||||
}
|
||||
.control.nav.next {
|
||||
right: 12px;
|
||||
}
|
||||
.loading {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
color: #d1d5db;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user