Files
photo-live/apps/viewer/src/components/album/AlbumHeader.vue
T
浅念 9c3915b387 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>
2026-09-21 21:22:36 +08:00

554 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>