feat(viewer): photo viewer with zoom, navigation and deep link

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 11:04:05 +08:00
parent d0b79dd0c9
commit 90fe64f637
15 changed files with 2170 additions and 345 deletions
+264
View File
@@ -0,0 +1,264 @@
import { onBeforeUnmount, onMounted, reactive, ref, watch, type Ref } from 'vue';
import {
ApiError,
NETWORK_ERROR_CODE,
getPublicPhoto,
getPublicPhotos,
type PublicPhoto,
type PublicPhotoList,
} from '../api/client';
import { errorMessageKey } from '../api/errors';
import { containsNewPhotoId, mergeUniqueByPhotoId } from './layout';
// apps/viewer/src/feed/sequence.ts —— 照片序列状态(Task 7 数据层 / Task 8 提升为
// feed + lightbox 共享 composable):游标分页追加、去重合并、轮询新照片提示、
// 深链 solo 条目与单图签名刷新。
//
// 提升决策(计划 Task 8):大图查看器需要「当前已加载列表序列」做左右切换、越过
// 末尾时取下一页扩展、深链照片就位/移除——这些都必须与瀑布流共享同一份数组与
// 游标状态。故把原先 PhotoFeed 内的数据生命周期整体上提至本 composable,由
// ActivityPage 创建一次,作为参数传给 PhotoFeed(渲染/懒加载/哨兵)与
// PhotoLightbox(导航/扩展),避免 props 逐层下钻或事件回传面条。
//
// solo 机制(深链兜底,文档化折中):目标照片不在已加载序列时以 solo 条目追加在
// 序列尾部(indexOf 可见、可导航);后续任何一页(含整表刷新)带回该 id 时,
// solo 副本被自然位置副本取代(integratePage),序列顺序自动纠正。ActivityPage
// 侧最多再拉 DEEP_LINK_PAGE_CAP 页寻找自然位置(见 ActivityPage)。
export interface PhotoSequenceInputs {
token: Ref<string>;
locale: Ref<string>;
category: Ref<string | null>;
}
/** 单图刷新结果:换绑新签名 URL / 照片已不可公开见(404,已从序列移除)/ 其他失败。 */
export type PhotoRefreshResult = 'replaced' | 'removed' | 'failed';
export function usePhotoSequence(inputs: PhotoSequenceInputs) {
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);
/** 深链解析的页数上限依据:整表重置成功置 1,每次成功追加 +1。 */
const pagesLoaded = ref(0);
// solo 条目标记(photoId 集合):见顶部「solo 机制」注。
const soloIds = reactive(new Set<string>());
let requestSequence = 0;
function fetchPage(cursor?: string): Promise<PublicPhotoList> {
return getPublicPhotos(inputs.token.value, {
locale: inputs.locale.value,
category: inputs.category.value ?? undefined,
cursor,
});
}
/** 页到达后的序列整合:natural(页序)在前、存活 solo 追加在后。
* 本页包含某 solo id 时先摘除其标记——该条目由本页副本在自然位置接管。 */
function integratePage(pageItems: PublicPhoto[], mode: 'reset' | 'append'): void {
// 快照须在摘标记前取:刚摘掉标记的旧 solo 条目不能被当作 natural 参与合并。
const previousNatural = photos.value.filter((photo) => !soloIds.has(photo.photoId));
for (const item of pageItems) soloIds.delete(item.photoId);
const natural =
mode === 'reset' ? [...pageItems] : mergeUniqueByPhotoId(previousNatural, pageItems);
const survivingSolos = photos.value.filter((photo) => soloIds.has(photo.photoId));
photos.value = [...natural, ...survivingSolos];
}
/** 整表重置加载(首载/分类切换/语言切换/token 切换/新照片提示刷新共用)。 */
async function resetAndLoad(): Promise<void> {
const sequence = ++requestSequence;
initialLoading.value = true;
initialErrorKey.value = null;
moreFailed.value = false;
loadingMore.value = false;
hasNew.value = false;
nextCursor.value = null;
hasMore.value = false;
try {
const page = await fetchPage();
if (sequence !== requestSequence) return;
integratePage(page.items, 'reset');
nextCursor.value = page.nextCursor;
hasMore.value = page.hasMore && page.nextCursor !== null;
pagesLoaded.value = 1;
} catch (error) {
if (sequence !== requestSequence) return;
const code = error instanceof ApiError ? error.code : NETWORK_ERROR_CODE;
initialErrorKey.value = errorMessageKey(code);
} finally {
if (sequence === requestSequence) initialLoading.value = false;
}
}
/** 取下一页追加(哨兵无限滚动 / 大图导航越过末尾 / 深链解析共用);去重防御性
* 合并。并发调用(哨兵 + 大图预取/导航同时触发)共用同一在途请求,不重复取数。 */
let inflightMore: Promise<void> | null = null;
function loadMore(): Promise<void> {
if (initialLoading.value || initialErrorKey.value !== null) return Promise.resolve();
if (!hasMore.value || nextCursor.value === null) return Promise.resolve();
if (inflightMore !== null) return inflightMore;
const sequence = requestSequence;
const cursor = nextCursor.value;
if (cursor === null) return Promise.resolve();
loadingMore.value = true;
moreFailed.value = false;
inflightMore = (async () => {
try {
const page = await fetchPage(cursor);
if (sequence !== requestSequence) return;
integratePage(page.items, 'append');
nextCursor.value = page.nextCursor;
hasMore.value = page.hasMore && page.nextCursor !== null;
pagesLoaded.value += 1;
} catch {
if (sequence === requestSequence) moreFailed.value = true;
} finally {
if (sequence === requestSequence) loadingMore.value = false;
inflightMore = null;
}
})();
return inflightMore;
}
/** 新照片提示条点击:整表刷新回第 1 页并回顶(提示消失)。 */
async function refreshFromHint(): Promise<void> {
if (initialLoading.value) return;
await resetAndLoad();
window.scrollTo(0, 0);
}
function indexOf(photoId: string): number {
return photos.value.findIndex((photo) => photo.photoId === photoId);
}
/** 深链兜底:目标照片不在序列时以 solo 条目追加尾部;已存在则返回现有下标。 */
function ensureInSequence(photo: PublicPhoto): number {
const existing = indexOf(photo.photoId);
if (existing >= 0) return existing;
soloIds.add(photo.photoId);
photos.value = [...photos.value, photo];
return photos.value.length - 1;
}
function isSolo(photoId: string): boolean {
return soloIds.has(photoId);
}
/** 从序列移除(照片 404 下线后)。 */
function removePhoto(photoId: string): void {
soloIds.delete(photoId);
photos.value = photos.value.filter((photo) => photo.photoId !== photoId);
}
/** 单图刷新(Task 7 缩略图重试 / Task 8 大图签名过期共用):成功原位换绑新
* 签名 URL;PHOTO_NOT_PUBLIC → 从序列移除(removed);其他失败不动。 */
async function refreshPhotoUrls(photoId: string): Promise<PhotoRefreshResult> {
try {
const detail = await getPublicPhoto(inputs.token.value, photoId);
const photo = photos.value.find((item) => item.photoId === photoId);
if (photo !== undefined) {
photo.thumb = detail.thumb;
photo.browse = detail.browse;
}
return 'replaced';
} catch (error) {
if (error instanceof ApiError && error.code === 'PHOTO_NOT_PUBLIC') {
removePhoto(photoId);
return 'removed';
}
return 'failed';
}
}
// —— 新照片轮询(30s;e2e 可用 window.__viewerPollMs 覆盖;失败静默) ——
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 = requestSequence;
try {
const page = await fetchPage();
if (sequence !== requestSequence) 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);
});
// token/语言/分类变化:整表重置(语言切换保留当前分类——inputs 自带)。
watch([inputs.token, inputs.locale, inputs.category], () => void resetAndLoad());
// reactive 包装:模板/脚本访问 sequence.photos 等 ref 自动解包(普通对象不会)。
return reactive({
photos,
nextCursor,
hasMore,
initialLoading,
initialErrorKey,
loadingMore,
moreFailed,
hasNew,
pagesLoaded,
resetAndLoad,
loadMore,
refreshFromHint,
indexOf,
ensureInSequence,
isSolo,
removePhoto,
refreshPhotoUrls,
});
}
export type PhotoSequence = ReturnType<typeof usePhotoSequence>;