feat(viewer): photo viewer with zoom, navigation and deep link
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ApiError, NETWORK_ERROR_CODE, getPublicAlbum, type PublicAlbum } from '../api/client';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ApiError, NETWORK_ERROR_CODE, getPublicAlbum, getPublicPhoto, type PublicAlbum } from '../api/client';
|
||||
import { errorMessageKey } from '../api/errors';
|
||||
import PhotoFeed from '../components/PhotoFeed.vue';
|
||||
import PhotoLightbox from '../components/PhotoLightbox.vue';
|
||||
import { usePhotoSequence } from '../feed/sequence';
|
||||
|
||||
// 观众活动页(P1b Task 6):信息区 + 空态 + 时区展示;瀑布流(Task 7)与大图
|
||||
// (Task 8,复用本组件于 /a/:token/p/:photoId 深链)随后补充。
|
||||
// 观众活动页(P1b Task 6 信息区 + Task 7 瀑布流 + Task 8 大图查看器)。
|
||||
//
|
||||
// 语言模型(文档化决策):
|
||||
// · UI 语言 = 用户选择(i18n locale,LocaleSwitcher 切换,localStorage 持久);
|
||||
// · 内容语言 = 同一选择值作为 ?locale= 查询参数传给公开端点;
|
||||
// · resolvedLocale(响应)仅说明服务端实际返回的翻译语言(请求语言缺翻译时
|
||||
// 回退 defaultLocale)——不反向驱动 UI 语言,避免「切了英文却被拽回中文」。
|
||||
//
|
||||
// 大图查看器编排(Task 8):
|
||||
// · 序列状态(分页/游标/轮询)在本页创建一次(usePhotoSequence),PhotoFeed 与
|
||||
// PhotoLightbox 共享——大图左右切换保持列表浏览序列,越过末尾自动扩展下一页;
|
||||
// · 当前照片以 photoId(v-model)为键,序列重排(深链 solo 归位/条目移除)时
|
||||
// 指向自动保持;
|
||||
// · 深链 /a/:token/p/:photoId:先取单图立即开大图(序列在背后继续加载),
|
||||
// 目标不在已加载序列时以 solo 条目兜底入尾,最多再拉 DEEP_LINK_PAGE_CAP 页
|
||||
// 寻找自然位置(文档化折中:防深链老照片触发全量翻页;超限保留 solo);
|
||||
// · URL 同步:点击打开 = push 深链路由;大图内切换/回退邻居 = replace(不产生
|
||||
// 多余历史条目);关闭 = push 过则 history.back()(浏览器后退同理关闭),
|
||||
// 深链直达(非本会话 push)则 replace 回相册路由;
|
||||
// · 关闭回位:打开时记录 window.scrollY,关闭(含浏览器后退触发)恢复——
|
||||
// 瀑布流为绝对定位布局,列表未被触碰,恢复即精确回位。
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const token = computed(() => String(route.params.token ?? ''));
|
||||
const routePhotoId = computed(() => (route.params.photoId === undefined ? null : String(route.params.photoId)));
|
||||
|
||||
const album = ref<PublicAlbum | null>(null);
|
||||
const loadErrorKey = ref<string | null>(null);
|
||||
@@ -95,12 +113,170 @@ const venueText = computed(() => {
|
||||
});
|
||||
|
||||
// 分类筛选(Task 7):id 级持有(语言切换只换名称,选中态跨语言保留——
|
||||
// PhotoFeed 收到同一 category 值不重置,locale 变化才整表重取)。
|
||||
// 同一 category 值不重置,locale 变化才整表重取)。
|
||||
const selectedCategory = ref<string | null>(null);
|
||||
|
||||
function selectCategory(categoryId: string | null): void {
|
||||
selectedCategory.value = categoryId;
|
||||
}
|
||||
|
||||
// —— 照片序列(Task 8 提升:feed + lightbox 共享) ——
|
||||
|
||||
const sequence = usePhotoSequence({ token, locale, category: selectedCategory });
|
||||
|
||||
// —— 大图查看器(Task 8) ——
|
||||
|
||||
/** 深链解析的翻页上限(含首页):超过后目标照片保留 solo 条目(见文件头注)。 */
|
||||
const DEEP_LINK_PAGE_CAP = 3;
|
||||
|
||||
const lightboxPhotoId = ref<string | null>(null);
|
||||
/** 深链目标 404(PHOTO_NOT_PUBLIC):页内本地化提示 + 返回相册链接。 */
|
||||
const deepLinkNotFound = ref(false);
|
||||
/** 深链单图请求失败(网络等):本地化提示 + 重试。 */
|
||||
const deepLinkErrorKey = ref<string | null>(null);
|
||||
/** 照片经刷新确认下线:轻提示(role=status,数秒后自动消失)。 */
|
||||
const photoRemovedNotice = ref(false);
|
||||
let removedNoticeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
let savedScrollY = 0;
|
||||
/** 本会话内点击 push 过深链路由 → 关闭走 history.back();深链直达则 replace。 */
|
||||
let pushedByUs = false;
|
||||
/** 深链解析竞态令牌:仅最新一次解析可落地。 */
|
||||
let resolveSequence = 0;
|
||||
/** 最近一次大图就位(打开或深链解析)所属 token:识别「路由目标=自己 push 的」。 */
|
||||
let resolvedToken = '';
|
||||
|
||||
const currentPhoto = computed(() => {
|
||||
if (lightboxPhotoId.value === null) return null;
|
||||
return sequence.photos.find((photo) => photo.photoId === lightboxPhotoId.value) ?? null;
|
||||
});
|
||||
|
||||
function showRemovedNotice(): void {
|
||||
photoRemovedNotice.value = true;
|
||||
if (removedNoticeTimer !== undefined) clearTimeout(removedNoticeTimer);
|
||||
removedNoticeTimer = setTimeout(() => {
|
||||
photoRemovedNotice.value = false;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
/** 瀑布流单元格点击 → 打开大图:记录滚动锚点并 push 深链路由。 */
|
||||
async function openFromFeed(photoId: string): Promise<void> {
|
||||
savedScrollY = window.scrollY;
|
||||
pushedByUs = true;
|
||||
resolvedToken = token.value;
|
||||
// 先就位再 push:routePhotoId watcher 看到路由目标与当前照片一致即跳过,
|
||||
// 避免自触发深链重解析(会冲掉 pushedByUs 并造成开-关闪烁)。
|
||||
lightboxPhotoId.value = photoId;
|
||||
await router.push({ name: 'activity-photo', params: { token: token.value, photoId } });
|
||||
}
|
||||
|
||||
/** 关闭大图:恢复滚动锚点;push 过 → back()(历史出栈),深链直达 → replace 回相册。 */
|
||||
function closeLightbox(): void {
|
||||
if (lightboxPhotoId.value === null) return;
|
||||
lightboxPhotoId.value = null;
|
||||
deepLinkNotFound.value = false;
|
||||
deepLinkErrorKey.value = null;
|
||||
window.scrollTo(0, savedScrollY);
|
||||
if (pushedByUs) {
|
||||
pushedByUs = false;
|
||||
router.back();
|
||||
} else if (route.name === 'activity-photo') {
|
||||
void router.replace({ name: 'activity', params: { token: token.value } });
|
||||
}
|
||||
}
|
||||
|
||||
/** 深链/前进导航进入照片路由:先取单图立即开大图,再由相邻页 watcher 构成序列。 */
|
||||
async function openFromRoute(photoId: string): Promise<void> {
|
||||
const run = ++resolveSequence;
|
||||
resolvedToken = token.value;
|
||||
deepLinkNotFound.value = false;
|
||||
deepLinkErrorKey.value = null;
|
||||
savedScrollY = window.scrollY;
|
||||
pushedByUs = false;
|
||||
lightboxPhotoId.value = null;
|
||||
|
||||
let detail: Awaited<ReturnType<typeof getPublicPhoto>>;
|
||||
try {
|
||||
detail = await getPublicPhoto(token.value, photoId);
|
||||
} catch (error) {
|
||||
if (run !== resolveSequence) return;
|
||||
if (error instanceof ApiError && error.code === 'PHOTO_NOT_PUBLIC') {
|
||||
deepLinkNotFound.value = true;
|
||||
return;
|
||||
}
|
||||
deepLinkErrorKey.value = error instanceof ApiError ? errorMessageKey(error.code) : errorMessageKey(NETWORK_ERROR_CODE);
|
||||
return;
|
||||
}
|
||||
if (run !== resolveSequence) return;
|
||||
// 序列已含该照片(首页先到)→ 直接就位;否则 solo 兜底入尾(可立即查看/切换)。
|
||||
if (sequence.indexOf(photoId) === -1) sequence.ensureInSequence(detail);
|
||||
lightboxPhotoId.value = photoId;
|
||||
}
|
||||
|
||||
// 深链入口(immediate 覆盖首挂;token 变化时对同 photoId 重新解析)。
|
||||
// 自己 push/replace 造成的路由变化(目标与当前照片一致且同 token)不重解析。
|
||||
watch(
|
||||
[routePhotoId, token],
|
||||
() => {
|
||||
const photoId = routePhotoId.value;
|
||||
if (photoId === null) return;
|
||||
if (photoId === lightboxPhotoId.value && token.value === resolvedToken) return;
|
||||
void openFromRoute(photoId);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 深链相邻页:目标仍是 solo 且未达翻页上限 → 追拉下一页,直到自然位置出现
|
||||
//(integratePage 自动以页内副本取代 solo)或上限/游标耗尽(保留 solo,文档化)。
|
||||
watch(
|
||||
() => [sequence.photos, sequence.initialLoading, sequence.initialErrorKey, sequence.hasMore, lightboxPhotoId.value] as const,
|
||||
() => {
|
||||
const photoId = lightboxPhotoId.value;
|
||||
if (photoId === null) return;
|
||||
if (!sequence.isSolo(photoId)) return;
|
||||
if (sequence.initialLoading || sequence.initialErrorKey !== null) return;
|
||||
if (sequence.pagesLoaded >= DEEP_LINK_PAGE_CAP || !sequence.hasMore || sequence.loadingMore) return;
|
||||
void sequence.loadMore();
|
||||
},
|
||||
);
|
||||
|
||||
// 大图内切换/邻居回退 → replace 深链路由(不产生多余历史条目)。
|
||||
watch(lightboxPhotoId, (photoId) => {
|
||||
if (photoId === null) return;
|
||||
if (route.name !== 'activity-photo' || routePhotoId.value === photoId) return;
|
||||
void router.replace({ name: 'activity-photo', params: { token: token.value, photoId } });
|
||||
});
|
||||
|
||||
// 浏览器后退/前进离开照片路由:同步关闭大图(不再动历史),恢复滚动锚点。
|
||||
watch(
|
||||
() => route.name,
|
||||
(name) => {
|
||||
if (name !== 'activity') return;
|
||||
if (lightboxPhotoId.value !== null) {
|
||||
lightboxPhotoId.value = null;
|
||||
pushedByUs = false;
|
||||
window.scrollTo(0, savedScrollY);
|
||||
}
|
||||
deepLinkNotFound.value = false;
|
||||
deepLinkErrorKey.value = null;
|
||||
},
|
||||
);
|
||||
|
||||
/** 大图确认照片下线(404 刷新):从序列移除,回退到原位邻居(或关闭),轻提示。 */
|
||||
function onPhotoRemoved(photoId: string, neighborIndex: number): void {
|
||||
showRemovedNotice();
|
||||
if (lightboxPhotoId.value !== photoId) return;
|
||||
const fallback = sequence.photos[Math.min(neighborIndex, sequence.photos.length - 1)];
|
||||
if (fallback === undefined) {
|
||||
closeLightbox();
|
||||
} else {
|
||||
lightboxPhotoId.value = fallback.photoId;
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (removedNoticeTimer !== undefined) clearTimeout(removedNoticeTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -119,7 +295,7 @@ function selectCategory(categoryId: string | null): void {
|
||||
:src="album.banner.url"
|
||||
:alt="album.translation.title"
|
||||
@error="bannerFailed = true"
|
||||
/>
|
||||
>
|
||||
|
||||
<header class="info">
|
||||
<h1 class="title">{{ album.translation.title }}</h1>
|
||||
@@ -136,6 +312,17 @@ function selectCategory(categoryId: string | null): void {
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<!-- 深链目标不可见(404 防枚举同码):页内本地化提示 + 返回相册。 -->
|
||||
<div v-if="deepLinkNotFound || deepLinkErrorKey !== null" class="deep-link-notice" role="alert">
|
||||
<p>{{ deepLinkNotFound ? t('viewer.photoUnavailable') : t(deepLinkErrorKey ?? 'errors.unknown') }}</p>
|
||||
<div class="notice-actions">
|
||||
<RouterLink :to="{ name: 'activity', params: { token } }">{{ t('viewer.backToAlbum') }}</RouterLink>
|
||||
<button v-if="deepLinkErrorKey !== null" type="button" @click="routePhotoId !== null && openFromRoute(routePhotoId)">
|
||||
{{ t('album.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类筛选 chips:全部 + 各分类;选中态高亮(aria-pressed)。 -->
|
||||
<nav v-if="album.categories.length > 0" class="categories" :aria-label="t('album.categories')">
|
||||
<button
|
||||
@@ -160,10 +347,22 @@ function selectCategory(categoryId: string | null): void {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 瀑布流(Task 7):懒加载/无限滚动/轮询提示在 PhotoFeed 内实现。 -->
|
||||
<PhotoFeed :token="token" :locale="locale" :category="selectedCategory" />
|
||||
<!-- 瀑布流(Task 7):序列数据在 PhotoSequence(Task 8 共享给大图)。 -->
|
||||
<PhotoFeed :sequence="sequence" @open="openFromFeed" />
|
||||
<!-- viewCount 契约占位 null:不渲染任何浏览计数节点。 -->
|
||||
</template>
|
||||
|
||||
<!-- 大图查看器(Task 8):当前照片以 photoId 键控(v-model)。 -->
|
||||
<PhotoLightbox
|
||||
v-if="lightboxPhotoId !== null && currentPhoto !== null"
|
||||
v-model:photo-id="lightboxPhotoId"
|
||||
:sequence="sequence"
|
||||
@removed="onPhotoRemoved"
|
||||
@close="closeLightbox"
|
||||
/>
|
||||
|
||||
<!-- 照片下线轻提示(大图内 404 刷新确认移除时)。 -->
|
||||
<p v-if="photoRemovedNotice" class="removed-notice" role="status">{{ t('viewer.photoRemoved') }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -238,4 +437,42 @@ function selectCategory(categoryId: string | null): void {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.deep-link-notice {
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
color: #92400e;
|
||||
}
|
||||
.deep-link-notice p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.notice-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.notice-actions a {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.notice-actions button {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.removed-notice {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
z-index: 110;
|
||||
border-radius: 8px;
|
||||
background: rgba(17, 24, 39, 0.9);
|
||||
color: #f9fafb;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user