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
+122 -17
View File
@@ -1,40 +1,145 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { LOCALE_STORAGE_KEY, type ViewerLocale } from '../i18n';
import { LOCALE_STORAGE_KEY, SUPPORTED_LOCALES, type ViewerLocale } from '../i18n';
// UI-02 V01 紧凑语言控件:顶栏右侧「当前语言代码 ▾」,点击弹出小菜单列出
// 全部语言(当前项勾选态)。44px 触控区(§3.1);Esc / 点击外部收起。
// 切换仍直接写当前 composer(与注入的 i18n 实例解耦,测试可挂独立实例),
// 持久化随切换写入,写失败不阻断。
const { locale, t } = useI18n();
const nextLocale = computed<ViewerLocale>(() => (locale.value === 'zh-CN' ? 'en' : 'zh-CN'));
// 按钮始终显示目标语言的自称,便于不识当前语言的用户发现
const nextLabel = computed(() => (nextLocale.value === 'zh-CN' ? '中文' : 'English'));
const open = ref(false);
const rootEl = ref<HTMLElement | null>(null);
// 直接写当前 composer(可写 locale):与注入的 i18n 实例解耦——组件测试可挂载
// 独立实例而不被运行时单例牵制。持久化随切换写入,写失败不阻断。
// 注意先取 next 再赋值:赋值后 nextLocale 已重算为目标的对侧。
function switchLocale(): void {
const next = nextLocale.value;
const currentLabel = computed(() => (locale.value === 'zh-CN' ? '中文' : 'EN'));
function switchLocale(next: ViewerLocale): void {
locale.value = next;
try {
localStorage.setItem(LOCALE_STORAGE_KEY, next);
} catch {
// 忽略持久化失败
}
open.value = false;
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') open.value = false;
}
function onOuterPointerDown(event: PointerEvent): void {
if (open.value && rootEl.value !== null && !rootEl.value.contains(event.target as Node)) {
open.value = false;
}
}
onMounted(() => {
document.addEventListener('pointerdown', onOuterPointerDown, true);
document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onOuterPointerDown, true);
document.removeEventListener('keydown', onKeydown);
});
</script>
<template>
<button type="button" class="locale-switcher" :aria-label="t('lang.switch')" @click="switchLocale">
{{ nextLabel }}
</button>
<div ref="rootEl" class="locale-switcher">
<button
type="button"
class="locale-trigger"
:aria-label="t('lang.switch')"
aria-haspopup="menu"
:aria-expanded="open"
@click="open = !open"
>
{{ currentLabel }}
<span class="caret" aria-hidden="true">▾</span>
</button>
<div v-if="open" class="locale-menu" role="menu" :aria-label="t('lang.switch')">
<button
v-for="lang in SUPPORTED_LOCALES"
:key="lang"
type="button"
class="locale-option"
role="menuitemradio"
:aria-checked="locale === lang"
:lang="lang"
@click="switchLocale(lang)"
>
{{ lang === 'zh-CN' ? '中文' : 'English' }}
</button>
</div>
</div>
</template>
<style scoped>
/* token 经祖先 .viewer-shell 提供;独立挂载(无 shell)时回退同值。 */
.locale-switcher {
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
padding: 4px 10px;
position: relative;
}
.locale-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: var(--viewer-touch-target, 44px);
min-width: var(--viewer-touch-target, 44px);
justify-content: center;
border: none;
border-radius: var(--viewer-radius-pill, 20px);
background: transparent;
color: var(--viewer-text, #172033);
padding: 0 10px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.locale-trigger:hover {
background: var(--viewer-page-bg, #f3f6fa);
}
.locale-trigger:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
outline-offset: -2px;
}
.caret {
font-size: 0.625rem;
color: var(--viewer-text-secondary, #647084);
}
.locale-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 130;
min-width: 128px;
border: 1px solid var(--viewer-divider, #e5eaf1);
border-radius: 12px;
background: var(--viewer-surface, #fff);
box-shadow: 0 8px 24px rgba(23, 32, 51, 0.12);
padding: 4px;
display: grid;
}
.locale-option {
border: none;
border-radius: 8px;
background: transparent;
color: var(--viewer-text, #172033);
min-height: var(--viewer-touch-target, 44px);
padding: 0 12px;
text-align: left;
font-size: 0.875rem;
cursor: pointer;
}
.locale-option[aria-checked='true'] {
color: var(--viewer-primary, #1a73e8);
font-weight: 600;
}
.locale-option:hover {
background: var(--viewer-page-bg, #f3f6fa);
}
.locale-option:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
outline-offset: -2px;
}
</style>
@@ -0,0 +1,553 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import type { PublicAlbum, SortMode } from '../../api/client';
// UI-02 V02 活动信息区(从 ActivityPage 提取):宣传图 + 标题/分享 + 说明 +
// 元信息行(浏览/日期/地点)+ 排序/分类工具栏。数据与筛选决策仍在父级
// (ActivityPage 持有序列):本组件只做展示与事件上抛(select-sort /
// select-category),语言切换由父级重取内容,选中分类以 id 跨语言保留。
//
// 数据诚实(方案 §4.1):
// · viewCount 未知(null)显示「—」而非 0;已知值经 Intl.NumberFormat 分组;
// · 日期按相册 IANA 时区格式化(紧凑日期;完整起止+时区放 title 提示),
// 不悄悄换成设备时区;非法时区名回退本地时区(沿用原策略);
// · 说明/场地/地址/宣传图缺失时自然省略节点,不渲染 null/undefined。
const props = defineProps<{
album: PublicAlbum;
/** 浏览数权威值(album 初值 + /visits 与 stats.updated 校准),null=未知。 */
viewCount: number | null;
sortMode: SortMode;
selectedCategory: string | null;
/** 大图打开期间锁定排序(父级序列不变序,§4.2)。 */
sortLocked?: boolean;
}>();
const emit = defineEmits<{
selectSort: [mode: SortMode];
selectCategory: [categoryId: string | null];
}>();
const { t, locale } = useI18n();
const router = useRouter();
// —— 宣传图:签名 URL 加载失败后收起(布局自适应,活动信息仍可用) ——
const bannerFailed = ref(false);
const bannerUrl = computed(() => (props.album.banner !== null && !bannerFailed.value ? props.album.banner.url : null));
// —— 标题/说明的行数钳制与展开(§4.1:默认 2/3 行,超出可展开收起) ——
const titleEl = ref<HTMLElement | null>(null);
const descEl = ref<HTMLElement | null>(null);
const titleClamped = ref(false);
const descClamped = ref(false);
const titleExpanded = ref(false);
const descExpanded = ref(false);
function isClamped(el: HTMLElement | null): boolean {
// -webkit-line-clamp 不改 scrollHeight:溢出时 scrollHeight > clientHeight。
return el !== null && el.scrollHeight - el.clientHeight > 1;
}
function measureOverflow(): void {
// 展开态保持按钮可见(供「收起」);收起态按实际溢出决定是否显示「展开」。
titleClamped.value = titleExpanded.value || isClamped(titleEl.value);
descClamped.value = descExpanded.value || isClamped(descEl.value);
}
// 相册/语言变化(文案长度随之变化)后:复位宣传图失败与展开态,重测溢出。
watch(
[() => props.album, locale],
() => {
bannerFailed.value = false;
titleExpanded.value = false;
descExpanded.value = false;
measureOverflow();
},
{ flush: 'post' },
);
// 元素挂载与展开/收起切换后重测(收起后按内容恢复钳制检测)。
watch([titleEl, descEl, titleExpanded, descExpanded], () => measureOverflow(), { flush: 'post' });
// —— 元信息行 ——
const viewsText = computed(() => {
if (props.viewCount === null) return t('viewer.viewsUnknown');
return t('viewer.views', { count: new Intl.NumberFormat(locale.value).format(props.viewCount) });
});
/** 紧凑日期(无时分):首页元信息行用;窄屏也不爆宽。 */
const dateText = computed(() => formatDate({ year: 'numeric', month: 'long', day: 'numeric' }));
/** 完整起止 + 时区:title 提示承载(§4.1「详情可查看完整起止与时区」)。 */
const dateFullText = computed(() =>
formatDate({
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
}),
);
function formatDate(extra: Intl.DateTimeFormatOptions): string {
const { timezone, startAt, endAt } = props.album;
const format = (value: string, withZone: boolean): string => {
const formatter = new Intl.DateTimeFormat(
locale.value,
withZone ? { ...extra, timeZone: timezone } : extra,
);
return formatter.format(new Date(value));
};
try {
const start = format(startAt, true);
return endAt === null ? start : `${start} – ${format(endAt, true)}`;
} catch {
// 非法时区名(RangeError):去掉 timeZone 按本地时区展示,优于整块失败。
const start = format(startAt, false);
return endAt === null ? start : `${start} – ${format(endAt, false)}`;
}
}
/** 场地 + 地址拼接(任一缺失时只展示存在的部分;全缺 → 整项省略)。 */
const venueText = computed(() => {
const { venue, address } = props.album.translation;
return [venue, address]
.filter((part): part is string => part !== null)
.join(' · ');
});
const descriptionText = computed(() => props.album.translation.description);
// —— 分享(V02 最小实现:复制相册链接;navigator.share 完整编排属 Task 5) ——
const shareState = ref<'idle' | 'copied' | 'failed'>('idle');
let shareResetTimer: ReturnType<typeof setTimeout> | undefined;
const albumUrl = computed(() => {
const path = router.resolve({ name: 'activity', params: { token: props.album.publicToken } }).href;
return new URL(path, window.location.origin).href;
});
async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText !== undefined) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// 走传统复制兜底
}
try {
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
const copied = document.execCommand('copy');
area.remove();
return copied;
} catch {
return false;
}
}
async function share(): Promise<void> {
const copied = await copyText(albumUrl.value);
shareState.value = copied ? 'copied' : 'failed';
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
// 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。
if (copied) {
shareResetTimer = setTimeout(() => {
shareState.value = 'idle';
}, 3000);
}
}
// —— 分类横向滚动的溢出提示(右侧渐隐,仅在确有未滚出内容时显示) ——
const chipsEl = ref<HTMLElement | null>(null);
const chipsOverflow = ref(false);
function updateChipsOverflow(): void {
const el = chipsEl.value;
if (el === null) {
chipsOverflow.value = false;
return;
}
chipsOverflow.value = el.scrollWidth - el.clientWidth > 1 && el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
}
watch(
[chipsEl, () => props.album.categories, () => props.selectedCategory],
() => updateChipsOverflow(),
{ flush: 'post' },
);
onBeforeUnmount(() => {
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
});
</script>
<template>
<div class="album-header">
<!-- 宣传图:全宽 contain(不裁海报文字);无图/加载失败整块收起。 -->
<div v-if="bannerUrl !== null" class="banner-wrap">
<img
class="banner"
:src="bannerUrl"
:alt="album.translation.title"
@error="bannerFailed = true"
>
</div>
<header class="album-info">
<div class="title-row">
<h1 ref="titleEl" class="title" :class="{ clamped: !titleExpanded }">{{ album.translation.title }}</h1>
<button type="button" class="share-btn" @click="share">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 3v12" />
<path d="m7 8 5-5 5 5" />
<path d="M5 13v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6" />
</svg>
<span>{{ t('viewer.share') }}</span>
</button>
</div>
<button v-if="titleClamped" type="button" class="text-expand" @click="titleExpanded = !titleExpanded">
{{ titleExpanded ? t('viewer.readLess') : t('viewer.readMore') }}
</button>
<span v-if="shareState !== 'idle'" class="share-status" role="status">
{{ shareState === 'copied' ? t('viewer.shareCopied') : t('viewer.shareCopyFailed') }}
<span v-if="shareState === 'failed'" class="share-url">{{ albumUrl }}</span>
</span>
<p
v-if="descriptionText !== null"
ref="descEl"
class="description"
:class="{ clamped: !descExpanded }"
>
{{ descriptionText }}
</p>
<button v-if="descClamped" type="button" class="text-expand" @click="descExpanded = !descExpanded">
{{ descExpanded ? t('viewer.readLess') : t('viewer.readMore') }}
</button>
<div class="meta">
<span class="meta-item view-count">
<svg class="meta-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
<span>{{ viewsText }}</span>
</span>
<span v-if="dateText !== ''" class="meta-item" :title="dateFullText">
<svg class="meta-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M8 3v4M16 3v4M3 10h18" />
</svg>
<span>{{ dateText }}</span>
</span>
<span v-if="venueText !== ''" class="meta-item">
<svg class="meta-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 21s-7-5.1-7-11a7 7 0 0 1 14 0c0 5.9-7 11-7 11Z" />
<circle cx="12" cy="10" r="2.5" />
</svg>
<span>{{ venueText }}</span>
</span>
</div>
</header>
<!-- 排序 tab(§4.2):高 44px;选中蓝字 + 底部 2px 线;大图打开期间锁定。 -->
<nav class="sort-tabs" :aria-label="t('album.sortLabel')">
<button
type="button"
class="sort-tab"
:class="{ active: props.sortMode === 'latest' }"
:aria-pressed="props.sortMode === 'latest'"
:disabled="props.sortLocked"
@click="emit('selectSort', 'latest')"
>
{{ t('viewer.latest') }}
</button>
<button
type="button"
class="sort-tab"
:class="{ active: props.sortMode === 'hot' }"
:aria-pressed="props.sortMode === 'hot'"
:disabled="props.sortLocked"
@click="emit('selectSort', 'hot')"
>
{{ t('viewer.popular') }}
</button>
</nav>
<!-- 分类胶囊:来自接口;无分类省略整条;All = 不传分类筛选。 -->
<nav v-if="album.categories.length > 0" class="categories" :aria-label="t('album.categories')">
<div ref="chipsEl" class="chips-track" @scroll.passive="updateChipsOverflow">
<button
type="button"
class="chip"
:class="{ active: props.selectedCategory === null }"
:aria-pressed="props.selectedCategory === null"
@click="emit('selectCategory', null)"
>
{{ t('viewer.allCategories') }}
</button>
<button
v-for="category in album.categories"
:key="category.id"
type="button"
class="chip"
:class="{ active: props.selectedCategory === category.id }"
:aria-pressed="props.selectedCategory === category.id"
@click="emit('selectCategory', category.id)"
>
{{ category.name }}
</button>
</div>
<span v-if="chipsOverflow" class="chips-fade" aria-hidden="true" />
</nav>
</div>
</template>
<style scoped>
/* token 经祖先 .viewer-shell 提供;独立挂载(无 shell)时回退同值。 */
.album-header {
background: var(--viewer-surface, #fff);
}
/* —— 宣传图:手机约 2.4:1;桌面限高 320px;contain 保海报文字(§3.3) —— */
.banner-wrap {
width: 100%;
aspect-ratio: 2.4 / 1;
max-height: 320px;
background: var(--viewer-surface, #fff);
}
.banner {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
/* —— 信息区:16px 内边距由本区负责(页面无全局包围,§3.2) —— */
.album-info {
padding: 12px var(--viewer-pad-info, 16px) 4px;
}
.title-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.title {
flex: 1;
min-width: 0;
margin: 0;
font-size: 1.375rem;
line-height: 1.3;
font-weight: 700;
color: var(--viewer-text, #172033);
overflow-wrap: break-word;
}
.title.clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.share-btn {
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
min-height: var(--viewer-touch-target, 44px);
margin: -6px calc(var(--viewer-pad-info, 16px) * -1) -6px 0;
border: none;
background: transparent;
color: var(--viewer-primary, #1a73e8);
padding: 0 6px;
align-self: flex-start;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.share-btn svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.share-btn:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
border-radius: 8px;
}
.share-status {
display: block;
margin: 2px 0 0;
color: var(--viewer-text-secondary, #647084);
font-size: 0.8125rem;
}
.share-url {
user-select: text;
color: var(--viewer-primary, #1a73e8);
word-break: break-all;
}
.text-expand {
border: none;
background: transparent;
color: var(--viewer-primary, #1a73e8);
padding: 2px 0 0;
margin: 2px 0 0;
min-height: 32px;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.text-expand:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
border-radius: 4px;
}
/* —— 说明:≤3 行 + 展开/收起;保留换行(pre-line),文本插值非 HTML 注入 —— */
.description {
margin: 8px 0 0;
color: var(--viewer-text-secondary, #647084);
font-size: 0.9375rem;
line-height: 1.55;
white-space: pre-line;
overflow-wrap: break-word;
}
.description.clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
/* —— 元信息行:窄屏自然换行(§4.1);数字 tabular-nums —— */
.meta {
display: flex;
flex-wrap: wrap;
gap: 6px 20px;
margin: 12px 0 0;
color: var(--viewer-text-secondary, #647084);
font-size: 0.8125rem;
line-height: 1.4;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
}
.meta-item span {
overflow-wrap: break-word;
}
.meta-icon {
flex: none;
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.view-count {
font-variant-numeric: tabular-nums;
}
/* —— 排序 tab —— */
.sort-tabs {
display: flex;
gap: 24px;
margin-top: 8px;
border-bottom: 1px solid var(--viewer-divider, #e5eaf1);
}
.sort-tab {
position: relative;
height: var(--viewer-touch-target, 44px);
border: none;
background: transparent;
padding: 0 2px;
color: var(--viewer-text-secondary, #647084);
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
}
.sort-tab.active {
color: var(--viewer-primary, #1a73e8);
}
/* 选中底部 2px 线(§4.2) */
.sort-tab.active::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
background: var(--viewer-primary, #1a73e8);
}
.sort-tab:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
outline-offset: -2px;
}
.sort-tab:disabled {
opacity: 0.55;
cursor: default;
}
/* —— 分类胶囊:横向滚动 + 右缘溢出提示 —— */
.categories {
position: relative;
margin-top: 10px;
}
.chips-track {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 2px var(--viewer-pad-info, 16px) 6px;
margin: -2px calc(var(--viewer-pad-info, 16px) * -1) 0;
scrollbar-width: thin;
}
.chip {
flex: none;
min-height: 36px;
border: 1px solid var(--viewer-divider, #e5eaf1);
border-radius: var(--viewer-radius-pill, 20px);
background: var(--viewer-surface, #fff);
color: var(--viewer-text, #172033);
padding: 0 16px;
font-size: 0.8125rem;
cursor: pointer;
}
.chip.active {
border-color: var(--viewer-primary, #1a73e8);
background: var(--viewer-primary, #1a73e8);
color: var(--viewer-surface, #fff);
font-weight: 600;
}
.chip:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
outline-offset: 1px;
}
/* 右缘渐隐提示仍有未滚出的分类(仅确有溢出且未滚到底时出现) */
.chips-fade {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 36px;
pointer-events: none;
background: linear-gradient(to right, transparent, var(--viewer-surface, #fff) 78%);
}
</style>