Files
photo-live/apps/viewer/src/feed/sequence.ts
T
2026-09-18 11:04:05 +08:00

265 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;