feat(viewer): photo viewer with zoom, navigation and deep link
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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>;
|
||||
Reference in New Issue
Block a user