feat(viewer): album page with locale fallback and timezone display
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, 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 { errorMessageKey } from '../api/errors';
|
||||
|
||||
// 脚手架占位页:Task 6 起替换为活动信息区 + 空态,Task 7/8 补瀑布流与大图。
|
||||
// 单图深链(/a/:token/p/:photoId)复用本组件,photoId 用于定位目标照片。
|
||||
// 观众活动页(P1b Task 6):信息区 + 空态 + 时区展示;瀑布流(Task 7)与大图
|
||||
// (Task 8,复用本组件于 /a/:token/p/:photoId 深链)随后补充。
|
||||
//
|
||||
// 语言模型(文档化决策):
|
||||
// · UI 语言 = 用户选择(i18n locale,LocaleSwitcher 切换,localStorage 持久);
|
||||
// · 内容语言 = 同一选择值作为 ?locale= 查询参数传给公开端点;
|
||||
// · resolvedLocale(响应)仅说明服务端实际返回的翻译语言(请求语言缺翻译时
|
||||
// 回退 defaultLocale)——不反向驱动 UI 语言,避免「切了英文却被拽回中文」。
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const token = computed(() => String(route.params.token ?? ''));
|
||||
const photoId = computed(() => (route.params.photoId !== undefined ? String(route.params.photoId) : null));
|
||||
|
||||
const album = ref<PublicAlbum | null>(null);
|
||||
const loadErrorKey = ref<string | null>(null);
|
||||
const loading = ref(true);
|
||||
// banner 签名 URL 加载失败(onerror)后隐藏,布局自适应(无残破图占位)。
|
||||
const bannerFailed = ref(false);
|
||||
|
||||
// 快速连续切换语言/路由时的竞态防护:仅最新请求的结果可落地。
|
||||
let loadSequence = 0;
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const sequence = ++loadSequence;
|
||||
loading.value = true;
|
||||
bannerFailed.value = false;
|
||||
try {
|
||||
const data = await getPublicAlbum(token.value, locale.value);
|
||||
if (sequence !== loadSequence) return;
|
||||
album.value = data;
|
||||
loadErrorKey.value = null;
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return;
|
||||
album.value = null;
|
||||
const code = error instanceof ApiError ? error.code : NETWORK_ERROR_CODE;
|
||||
loadErrorKey.value = errorMessageKey(code);
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
// token 变化(相册间跳转)与 UI 语言切换(内容随之换语言重取)都触发重载。
|
||||
watch([token, locale], load);
|
||||
|
||||
// 文档标题随相册标题;加载中/出错回退应用名。App.vue 的同名 watchEffect 先注册
|
||||
// 先执行,此处后写覆盖(locale 变化时两者都重跑,最终值以本页为准)。
|
||||
watchEffect(() => {
|
||||
document.title = album.value !== null ? album.value.translation.title : t('app.title');
|
||||
});
|
||||
|
||||
/** 活动时间:按相册 IANA 时区 + 当前展示语言格式化;无 endAt 时单点展示。
|
||||
* 起止两端各自完整格式化(不走 formatRange:CLDR 区间格式在 zh-CN 下会压缩为
|
||||
* 数字日期,丢失「10月1日」式展示且跨环境形态不稳)。 */
|
||||
const timeText = computed(() => {
|
||||
if (album.value === null) return '';
|
||||
const { timezone, startAt, endAt } = album.value;
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
};
|
||||
const start = new Date(startAt);
|
||||
const format = (value: Date, withZone: boolean): string => {
|
||||
const formatter = new Intl.DateTimeFormat(locale.value, withZone ? { ...options, timeZone: timezone } : options);
|
||||
return formatter.format(value);
|
||||
};
|
||||
try {
|
||||
const startText = format(start, true);
|
||||
return endAt === null ? startText : `${startText} – ${format(new Date(endAt), true)}`;
|
||||
} catch {
|
||||
// 非法时区名(RangeError):去掉 timeZone 按本地时区展示,优于整页失败。
|
||||
const startText = format(start, false);
|
||||
return endAt === null ? startText : `${startText} – ${format(new Date(endAt), false)}`;
|
||||
}
|
||||
});
|
||||
|
||||
/** 场地 + 地址拼接(任一缺失时只展示存在的部分)。 */
|
||||
const venueText = computed(() => {
|
||||
const translation = album.value?.translation;
|
||||
if (translation === undefined) return '';
|
||||
return [translation.venue, translation.address]
|
||||
.filter((part): part is string => part !== null)
|
||||
.join(' · ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="activity">
|
||||
<p class="meta">{{ t('route.tokenLabel') }}: {{ token }}</p>
|
||||
<p v-if="photoId !== null" class="meta">{{ t('route.photoLabel') }}: {{ photoId }}</p>
|
||||
<p class="empty-notice">{{ t('album.emptyNotice') }}</p>
|
||||
<p v-if="loading" class="status">{{ t('album.loading') }}</p>
|
||||
|
||||
<div v-else-if="loadErrorKey !== null" class="error" role="alert">
|
||||
<p>{{ t(loadErrorKey) }}</p>
|
||||
<button type="button" @click="load">{{ t('album.retry') }}</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="album !== null">
|
||||
<img
|
||||
v-if="album.banner !== null && !bannerFailed"
|
||||
class="banner"
|
||||
:src="album.banner.url"
|
||||
:alt="album.translation.title"
|
||||
@error="bannerFailed = true"
|
||||
/>
|
||||
|
||||
<header class="info">
|
||||
<h1 class="title">{{ album.translation.title }}</h1>
|
||||
<p v-if="album.translation.description !== null" class="description">{{ album.translation.description }}</p>
|
||||
<dl class="meta">
|
||||
<div v-if="timeText !== ''" class="meta-row">
|
||||
<dt>{{ t('album.time') }}</dt>
|
||||
<dd>{{ timeText }}</dd>
|
||||
</div>
|
||||
<div v-if="venueText !== ''" class="meta-row">
|
||||
<dt>{{ t('album.venue') }}</dt>
|
||||
<dd>{{ venueText }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<!-- Task 7 接线分类过滤;当前仅展示(禁用态占位)。 -->
|
||||
<nav v-if="album.categories.length > 0" class="categories" :aria-label="t('album.categories')">
|
||||
<button v-for="category in album.categories" :key="category.id" type="button" class="chip" disabled>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 瀑布流(Task 7)前的列表占位:空态文案由 i18n 提供。 -->
|
||||
<div class="feed">
|
||||
<p class="empty-notice">{{ t('album.emptyNotice') }}</p>
|
||||
</div>
|
||||
<!-- viewCount 契约占位 null:不渲染任何浏览计数节点。 -->
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 极简基线样式:视觉打磨留待 P4a。 */
|
||||
.activity {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.status {
|
||||
color: #6b7280;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.error button {
|
||||
margin-top: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.banner {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.description {
|
||||
margin: 0 0 12px;
|
||||
color: #374151;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.meta {
|
||||
margin: 0 0 12px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: 3em 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.meta-row dt {
|
||||
color: #6b7280;
|
||||
}
|
||||
.meta-row dd {
|
||||
margin: 0;
|
||||
}
|
||||
.categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.chip {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 999px;
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
padding: 2px 12px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.chip:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.feed {
|
||||
border: 1px dashed #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-notice {
|
||||
margin: 0;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user