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;
|
||||
|
||||
Reference in New Issue
Block a user