feat(viewer): UI-02 visual foundation, top bar and album info

V01+V02 (Task 2 of the UI-02 viewer redesign plan):

- viewer-tokens.css: UI-02 §3.1 design tokens scoped to .viewer-shell
  (never :root), so embed routes consume none of them
- App.vue: normal routes get a sticky white top bar (PhotoLive brand +
  compact locale switcher + lightweight "..." menu holding the
  reduce-motion toggle); global 16px content padding removed so banner
  and photos run edge-to-edge; embed shell branch untouched
- LocaleSwitcher: compact "EN/中文 + caret" trigger with a proper menu
  (current language checked), 44px touch target, Escape/outside close
- AlbumHeader (new): banner (2.4:1 mobile, 320px desktop cap,
  object-fit contain, collapses on missing/failed), 2-line expandable
  title + share button (clipboard copy with selectable-text fallback),
  3-line expandable pre-line description, meta row (Intl-formatted
  view count with em-dash fallback for unknown, album-timezone dates
  with full range on title, venue), Latest/Popular 44px tabs and
  category pills from the API with horizontal scroll + overflow fade
- ActivityPage: delegates the info area to AlbumHeader; sequence
  reset/sort-lock/category-by-id semantics unchanged; desktop content
  capped at 1120px
- i18n: viewer.* strings (brand/share/views/readMore/latest/popular/
  allCategories/noPhotos/newPhotos/reduceMotion/settings) in zh-CN+en
- tests: viewer-shell (token source + normal/embed isolation) and
  album-header (8 cases) specs; activity/i18n specs updated for the
  menu switcher and the em-dash unknown view count; e2e selectors
  refreshed

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 21:22:36 +08:00
parent 2cbd167cc0
commit 9c3915b387
13 changed files with 1333 additions and 258 deletions
+21 -177
View File
@@ -13,6 +13,7 @@ import {
} from '../api/client';
import { errorMessageKey } from '../api/errors';
import { useDownload } from '../download/useDownload';
import AlbumHeader from '../components/album/AlbumHeader.vue';
import LikeBubbles from '../components/LikeBubbles.vue';
import PhotoFeed from '../components/PhotoFeed.vue';
import PhotoLightbox from '../components/PhotoLightbox.vue';
@@ -53,8 +54,6 @@ const routePhotoId = computed(() => (route.params.photoId === undefined ? null :
const album = ref<PublicAlbum | null>(null);
const loadErrorKey = ref<string | null>(null);
const loading = ref(true);
// banner 签名 URL 加载失败(onerror)后隐藏,布局自适应(无残破图占位)。
const bannerFailed = ref(false);
/** 相册浏览数(Task 10):album 加载初值 + 上报确认/ stats.updated 校准。 */
const viewCount = ref<number | null>(null);
@@ -64,7 +63,6 @@ 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;
@@ -95,43 +93,6 @@ 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(' · ');
});
// 分类筛选(Task 7):id 级持有(语言切换只换名称,选中态跨语言保留——
// 同一 category 值不重置,locale 变化才整表重取)。
const selectedCategory = ref<string | null>(null);
@@ -356,32 +317,17 @@ onBeforeUnmount(() => {
</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>
<div v-if="viewCount !== null" class="meta-row">
<dt>{{ t('album.views') }}</dt>
<dd class="view-count">{{ viewCount }}</dd>
</div>
</dl>
</header>
<!-- 活动信息区 + 排序/分类工具栏(UI-02 V02:展示提取到 AlbumHeader,
筛选决策/序列重置仍在本页——selectSort/selectCategory 原逻辑不变)。 -->
<AlbumHeader
:album="album"
:view-count="viewCount"
:sort-mode="sortMode"
:selected-category="selectedCategory"
:sort-locked="lightboxPhotoId !== null"
@select-sort="selectSort"
@select-category="selectCategory"
/>
<!-- 深链目标不可见(404 防枚举同码):页内本地化提示 + 返回相册。 -->
<div v-if="deepLinkNotFound || deepLinkErrorKey !== null" class="deep-link-notice" role="alert">
@@ -394,54 +340,6 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- 分类筛选 chips:全部 + 各分类;选中态高亮(aria-pressed)。 -->
<nav v-if="album.categories.length > 0" class="categories" :aria-label="t('album.categories')">
<button
type="button"
class="chip"
:class="{ active: selectedCategory === null }"
:aria-pressed="selectedCategory === null"
@click="selectCategory(null)"
>
{{ t('album.all') }}
</button>
<button
v-for="category in album.categories"
:key="category.id"
type="button"
class="chip"
:class="{ active: selectedCategory === category.id }"
:aria-pressed="selectedCategory === category.id"
@click="selectCategory(category.id)"
>
{{ category.name }}
</button>
</nav>
<!-- 排序切换(Task 10):latest/hot 快照重取;大图打开期间锁定(序列不变序)。 -->
<nav class="sort" :aria-label="t('album.sortLabel')">
<button
type="button"
class="chip"
:class="{ active: sortMode === 'latest' }"
:aria-pressed="sortMode === 'latest'"
:disabled="lightboxPhotoId !== null"
@click="selectSort('latest')"
>
{{ t('album.sortLatest') }}
</button>
<button
type="button"
class="chip"
:class="{ active: sortMode === 'hot' }"
:aria-pressed="sortMode === 'hot'"
:disabled="lightboxPhotoId !== null"
@click="selectSort('hot')"
>
{{ t('album.sortHot') }}
</button>
</nav>
<!-- 瀑布流(Task 7):序列数据在 PhotoSequence(Task 8 共享给大图)。 -->
<PhotoFeed :sequence="sequence" :likes="likes" @open="openFromFeed" />
</template>
@@ -466,10 +364,16 @@ onBeforeUnmount(() => {
</template>
<style scoped>
/* 极简基线样式:视觉打磨留待 P4a。 */
/* UI-02 V01/V02:页面无边到边(无全局 16px 包围);≥960px 主内容列限宽居中
* (§3.3,瀑布流列数细化属 Task 3)。信息区/工具栏样式在 AlbumHeader。 */
.activity {
max-width: 720px;
margin: 0 auto;
width: 100%;
}
@media (min-width: 960px) {
.activity {
max-width: 1120px;
margin: 0 auto;
}
}
.status {
color: #6b7280;
@@ -485,66 +389,6 @@ onBeforeUnmount(() => {
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;
cursor: pointer;
}
.chip.active {
border-color: #1d4ed8;
background: #eff6ff;
color: #1d4ed8;
}
.sort {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.sort .chip:disabled {
opacity: 0.55;
cursor: default;
}
.deep-link-notice {
border: 1px solid #fde68a;
border-radius: 8px;