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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user