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; locale: Ref; category: Ref; } /** 单图刷新结果:换绑新签名 URL / 照片已不可公开见(404,已从序列移除)/ 其他失败。 */ export type PhotoRefreshResult = 'replaced' | 'removed' | 'failed'; export function usePhotoSequence(inputs: PhotoSequenceInputs) { const photos = ref([]); const nextCursor = ref(null); const hasMore = ref(false); const initialLoading = ref(true); const initialErrorKey = ref(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()); let requestSequence = 0; function fetchPage(cursor?: string): Promise { 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 { 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 | null = null; function loadMore(): Promise { 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 { 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 { 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 | 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 { 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;