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
+4 -3
View File
@@ -251,9 +251,10 @@ test('reduced motion suppresses bubbles while counts still calibrate', async ({
});
await expect(page.locator('.activity[data-connection="open"]')).toBeVisible();
// 顶栏开关减少动画(localStorage 偏好持久)。
await page.locator('.motion-toggle').click();
await expect(page.locator('.motion-toggle')).toHaveAttribute('aria-pressed', 'true');
// 顶栏「⋯」菜单内开关减少动画(UI-02 V01;localStorage 偏好持久)。
await page.locator('.topbar .icon-btn').click();
await page.locator('.prefs-item').click();
await expect(page.locator('.prefs-item')).toHaveAttribute('aria-checked', 'true');
// 他者点赞:无气泡(动画停用),计数照常被 burst 校准。
await apiLike(token, photoIds[0]!, 'e2e-motion-other', 1);
+3 -2
View File
@@ -4,6 +4,7 @@ import { expect, test } from '@playwright/test';
// 真实观众旅程(活动页/瀑布流/大图深链)由 Task 6-8/11 补充。
test('dev server serves viewer root with top bar and locale switcher', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.brand')).toHaveText(/现场相册|Live Gallery/);
await expect(page.locator('button.locale-switcher')).toBeVisible();
// UI-02 V01:品牌为英文 PhotoLive(双语一致),语言切换为紧凑控件。
await expect(page.locator('.brand-name')).toHaveText('PhotoLive');
await expect(page.locator('.locale-trigger')).toBeVisible();
});
+192 -52
View File
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed, watchEffect } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import LocaleSwitcher from './components/LocaleSwitcher.vue';
import { reducedMotion, toggleReducedMotion } from './ui/prefs';
// UI-02 V01 视觉 token:变量全部作用域在 .viewer-shell 类上(非 :root 全局),
// embed 分支不挂该类 → 嵌入 iframe 不消费任何 token(隔离红线)。
import './styles/viewer-tokens.css';
const route = useRoute();
const { t, locale } = useI18n();
@@ -17,36 +20,90 @@ watchEffect(() => {
document.title = t('app.title');
document.documentElement.setAttribute('lang', locale.value);
});
// —— 顶栏轻量菜单(UI-02 §3.2):减少动画入口收进语言旁「⋯」,可发现但不占整行 ——
const prefsOpen = ref(false);
const prefsRootEl = ref<HTMLElement | null>(null);
function closePrefs(): void {
prefsOpen.value = false;
}
function onPrefsKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') closePrefs();
}
function onOuterPointerDown(event: PointerEvent): void {
if (prefsOpen.value && prefsRootEl.value !== null && !prefsRootEl.value.contains(event.target as Node)) {
closePrefs();
}
}
onMounted(() => {
document.addEventListener('pointerdown', onOuterPointerDown, true);
document.addEventListener('keydown', onPrefsKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onOuterPointerDown, true);
document.removeEventListener('keydown', onPrefsKeydown);
});
</script>
<template>
<div v-if="isEmbedLayout" class="embed-shell">
<RouterView />
</div>
<div v-else class="app">
<div v-else class="viewer-shell">
<header class="topbar">
<span class="brand">{{ t('app.title') }}</span>
<span class="brand">
<svg class="brand-logo" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="1" y="1" width="22" height="22" rx="6" fill="var(--viewer-primary)" />
<circle cx="12" cy="12" r="5.5" fill="none" stroke="#FFFFFF" stroke-width="2" />
<circle cx="18" cy="6" r="1.6" fill="#FFFFFF" />
</svg>
<span class="brand-name">{{ t('viewer.brand') }}</span>
</span>
<div class="topbar-actions">
<!-- 减少动画(Task 10):隐藏点赞气泡/心跳动画,计数照常更新。 -->
<button
type="button"
class="motion-toggle"
:aria-pressed="reducedMotion"
@click="toggleReducedMotion"
>
{{ t('prefs.reduceMotion') }}
</button>
<LocaleSwitcher />
<div ref="prefsRootEl" class="prefs-menu">
<button
type="button"
class="icon-btn"
:aria-label="t('viewer.settings')"
aria-haspopup="menu"
:aria-expanded="prefsOpen"
@click="prefsOpen = !prefsOpen"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="5" cy="12" r="2" fill="currentColor" />
<circle cx="12" cy="12" r="2" fill="currentColor" />
<circle cx="19" cy="12" r="2" fill="currentColor" />
</svg>
</button>
<div v-if="prefsOpen" class="prefs-popover" role="menu" :aria-label="t('viewer.settings')">
<button
type="button"
class="prefs-item"
role="menuitemcheckbox"
:aria-checked="reducedMotion"
@click="toggleReducedMotion"
>
<span class="prefs-check" aria-hidden="true">{{ reducedMotion ? '✓' : '' }}</span>
{{ t('viewer.reduceMotion') }}
</button>
</div>
</div>
</div>
</header>
<main class="content">
<main class="viewer-main">
<RouterView />
</main>
</div>
</template>
<style>
/* 极简基线样式:视觉打磨留待 P4a */
/* 基线重置(embed 共存安全:仅盒模型/边距/字体兜底,无视觉决策)。 */
* {
box-sizing: border-box;
}
@@ -55,46 +112,129 @@ body {
font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
color: #1f2328;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid #e5e7eb;
}
.brand {
font-weight: 600;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.motion-toggle {
border: 1px solid #d1d5db;
border-radius: 6px;
background: #f9fafb;
color: #374151;
padding: 2px 10px;
font-size: 0.875rem;
cursor: pointer;
}
.motion-toggle[aria-pressed='true'] {
border-color: #1d4ed8;
background: #eff6ff;
color: #1d4ed8;
}
.content {
flex: 1;
padding: 16px;
}
/* 嵌入页外壳:无内边距/最小高度(iframe 内容高度由 resize 上报驱动)。 */
.embed-shell {
display: block;
}
/* —— UI-02 V01 普通观众外壳:所有规则作用域在 .viewer-shell 下(隔离红线) —— */
.viewer-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
font-family: var(--viewer-font-family);
color: var(--viewer-text);
background: var(--viewer-surface);
}
.topbar {
position: sticky;
top: 0;
z-index: 120;
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
padding: 0 var(--viewer-pad-info);
border-bottom: 1px solid var(--viewer-divider);
background: var(--viewer-surface);
}
.brand {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.brand-logo {
width: 24px;
height: 24px;
flex: none;
}
.brand-name {
font-size: 1.0625rem;
font-weight: 700;
letter-spacing: 0.01em;
color: var(--viewer-text);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 4px;
}
.prefs-menu {
position: relative;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--viewer-touch-target);
height: var(--viewer-touch-target);
border: none;
border-radius: 50%;
background: transparent;
color: var(--viewer-text-secondary);
cursor: pointer;
}
.icon-btn:hover {
background: var(--viewer-page-bg);
}
.icon-btn:focus-visible {
outline: 2px solid var(--viewer-primary);
outline-offset: -2px;
}
.icon-btn svg {
width: 22px;
height: 22px;
}
.prefs-popover {
position: absolute;
top: calc(100% + 2px);
right: 0;
z-index: 130;
min-width: 176px;
border: 1px solid var(--viewer-divider);
border-radius: 12px;
background: var(--viewer-surface);
box-shadow: 0 8px 24px rgba(23, 32, 51, 0.12);
padding: 4px;
display: grid;
}
.prefs-item {
display: flex;
align-items: center;
gap: 8px;
min-height: var(--viewer-touch-target);
border: none;
border-radius: 8px;
background: transparent;
color: var(--viewer-text);
padding: 0 12px;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
}
.prefs-item:hover {
background: var(--viewer-page-bg);
}
.prefs-item:focus-visible {
outline: 2px solid var(--viewer-primary);
outline-offset: -2px;
}
.prefs-check {
display: inline-block;
width: 1em;
color: var(--viewer-primary);
font-weight: 700;
}
/* V01 §3.2:内容页不再有全局 16px 包围——宣传图/照片边到边,内边距由
* 信息区(AlbumHeader)与照片区(PhotoFeed,Task 3)各自负责。 */
.viewer-main {
flex: 1;
width: 100%;
}
@media (min-width: 960px) {
.viewer-shell {
background: var(--viewer-page-bg);
}
}
</style>
+57 -4
View File
@@ -2,7 +2,7 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
import App from '../App.vue';
import { ApiError, getPublicAlbum, type PublicAlbum } from '../api/client';
import { ApiError, getPublicAlbum, getPublicPhotos, type PublicAlbum } from '../api/client';
import { createViewerI18n, type ViewerI18n } from '../i18n';
import ActivityPage from '../pages/ActivityPage.vue';
import { router } from '../router';
@@ -32,6 +32,7 @@ vi.mock('../realtime/ws', () => {
});
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
// 逐挂载独立 i18n 实例 + 逐用例卸载:vue-i18n 在 app.unmount 时 dispose 共享实例
// (见 i18n/index.ts 注释),复用单例会让后续用例的 locale 写入失效;残留组件的
@@ -51,6 +52,14 @@ function mountApp(): VueWrapper {
return wrapper;
}
/** UI-02 V01 紧凑语言控件:先展开菜单再点目标语言选项(SUPPORTED_LOCALES 序)。 */
async function pickLanguage(wrapper: VueWrapper, option: '中文' | 'English'): Promise<void> {
await wrapper.find('.locale-trigger').trigger('click');
const target = wrapper.findAll('.locale-option').find((btn) => btn.text() === option);
expect(target).toBeDefined();
await target!.trigger('click');
}
describe('activity page (Task 6)', () => {
beforeEach(async () => {
localStorage.clear();
@@ -83,9 +92,9 @@ describe('activity page (Task 6)', () => {
expect(chips.every((chip) => chip.attributes('disabled') === undefined)).toBe(true);
expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线');
// 无 banner 资产 → 不渲染 img;viewCount 占位 null → 不渲染任何计数节点。
// 无 banner 资产 → 不渲染 img;viewCount 未知(null)→ 浏览项显示「—」而非 0(UI-02 §4.1)。
expect(wrapper.find('img.banner').exists()).toBe(false);
expect(wrapper.find('.view-count').exists()).toBe(false);
expect(wrapper.find('.view-count').text()).toBe('—');
expect(document.title).toBe('2026 公司年会');
});
@@ -125,7 +134,7 @@ describe('activity page (Task 6)', () => {
expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'zh-CN');
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
await wrapper.find('button.locale-switcher').trigger('click');
await pickLanguage(wrapper, 'English');
await flushPromises();
expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'en');
@@ -220,4 +229,48 @@ describe('activity page (Task 6)', () => {
await flushPromises();
expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026');
});
// —— UI-02 V02:排序/分类经 AlbumHeader 上抛,沿用本页序列重置机制 ——
it('selecting the Popular tab refetches the sequence with sort=hot (active state moves)', async () => {
const wrapper = mountPage();
await flushPromises();
mockedGetPublicPhotos.mockClear();
const tabs = wrapper.findAll('.sort-tab');
expect(tabs.map((tab) => tab.text())).toEqual(['最新', '热门']);
expect(tabs[0].classes()).toContain('active');
expect(tabs[0].attributes('aria-pressed')).toBe('true');
await tabs[1].trigger('click');
await flushPromises();
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-activity', expect.objectContaining({ sort: 'hot' }));
expect(wrapper.findAll('.sort-tab')[1].classes()).toContain('active');
});
it('category chips filter the sequence and the selection survives a locale switch', async () => {
const wrapper = mountPage();
await flushPromises();
mockedGetPublicPhotos.mockClear();
await wrapper.findAll('.categories .chip')[1].trigger('click'); // 「签到墙」= cat-sign
await flushPromises();
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-activity', expect.objectContaining({ category: 'cat-sign' }));
mockedGetPublicAlbum.mockImplementation((_token, locale) =>
Promise.resolve(locale === 'en' ? makeEnAlbum() : makeAlbum()),
);
i18n.global.locale.value = 'en';
await flushPromises();
// 语言只换显示与内容请求,分类 id 跨语言保留(§4.2)。
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
'tok-activity',
expect.objectContaining({ category: 'cat-sign', locale: 'en' }),
);
const chips = wrapper.findAll('.categories .chip');
expect(chips[1].classes()).toContain('active');
expect(chips[1].text()).toBe('Check-in Wall');
});
});
@@ -0,0 +1,200 @@
import { mount, type VueWrapper } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MockInstance } from 'vitest';
import AlbumHeader from '../components/album/AlbumHeader.vue';
import { createViewerI18n, type ViewerI18n } from '../i18n';
import { router } from '../router';
import { makeAlbum } from './fixtures';
// UI-02 V02 AlbumHeader 单测:字段渲染/缺失省略、浏览数 Intl 格式与未知回退、
// 相册时区日期、标题/说明钳制展开、排序 tab 与分类胶囊事件、分享复制。
// (筛选决策与序列重置在 activity.spec.ts 集成覆盖。)
let i18n: ViewerI18n;
const mounted: VueWrapper[] = [];
function mountHeader(props: {
album: ReturnType<typeof makeAlbum>;
viewCount: number | null;
sortMode?: 'latest' | 'hot';
selectedCategory?: string | null;
sortLocked?: boolean;
}): VueWrapper {
const wrapper = mount(AlbumHeader, {
props: {
sortMode: 'latest',
selectedCategory: null,
...props,
},
global: { plugins: [router, i18n] },
});
mounted.push(wrapper);
return wrapper;
}
function stubClipboard(writeText: (text: string) => Promise<void>): MockInstance {
const fn = vi.fn(writeText);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: fn },
configurable: true,
});
return fn as unknown as MockInstance;
}
describe('album header info area (UI-02 V02)', () => {
let scrollSpy: MockInstance | null = null;
let clientSpy: MockInstance | null = null;
beforeEach(() => {
localStorage.clear();
i18n = createViewerI18n('zh-CN');
});
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount();
scrollSpy?.mockRestore();
scrollSpy = null;
clientSpy?.mockRestore();
clientSpy = null;
Reflect.deleteProperty(navigator, 'clipboard');
});
it('renders banner, title, multi-line description, Intl view count, venue and sort/category toolbar', () => {
const album = makeAlbum({
banner: { url: 'http://127.0.0.1:9000/photo-live/dev/albums/a1/assets/banner.jpg?sig=1', expiresAt: '2026-10-01T00:00:00.000Z' },
viewCount: 1286,
});
const wrapper = mountHeader({ album, viewCount: 1286 });
const img = wrapper.find('img.banner');
expect(img.exists()).toBe(true);
expect(img.attributes('src')).toContain('/albums/a1/assets/banner.jpg?sig=1');
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
// pre-line:换行保留;文本插值(非 HTML 注入)。
expect(wrapper.find('.description').text()).toBe('年度盛会\n多行介绍');
// Intl.NumberFormat 分组 + 词条拼接(§4.1)。
expect(wrapper.find('.view-count').text()).toBe('1,286 次浏览');
expect(wrapper.find('.meta').text()).toContain('大宴会厅 · 上海市浦东新区世纪大道 100 号');
// Asia/Shanghai(夹具时区):UTC 10-01 → 本地 10月1日。
expect(wrapper.find('.meta').text()).toContain('10月1日');
// 工具栏:排序 tab + 分类胶囊(All + 接口分类)。
expect(wrapper.findAll('.sort-tab').map((tab) => tab.text())).toEqual(['最新', '热门']);
expect(wrapper.findAll('.categories .chip').map((chip) => chip.text())).toEqual(['全部', '签到墙', '开场']);
// jsdom 无布局:未溢出 → 不渲染展开按钮。
expect(wrapper.findAll('.text-expand')).toHaveLength(0);
});
it('omits missing fields naturally: no banner/description/venue, views shows —, no categories row', () => {
const album = makeAlbum({
banner: null,
viewCount: null,
categories: [],
translation: { locale: 'zh-CN', title: '无字段活动', description: null, venue: null, address: null },
});
const wrapper = mountHeader({ album, viewCount: null });
expect(wrapper.find('img.banner').exists()).toBe(false);
expect(wrapper.find('.description').exists()).toBe(false);
expect(wrapper.findAll('.meta-item')).toHaveLength(2); // 浏览(—)+ 日期;地点整项省略
expect(wrapper.find('.view-count').text()).toBe('—');
expect(wrapper.find('.categories').exists()).toBe(false);
});
it('collapses the banner on image load failure (info area stays usable)', async () => {
const album = makeAlbum({
banner: { url: 'http://127.0.0.1:9000/expired.jpg?sig=stale', expiresAt: '2020-01-01T00:00:00.000Z' },
});
const wrapper = mountHeader({ album, viewCount: 5 });
expect(wrapper.find('img.banner').exists()).toBe(true);
await wrapper.find('img.banner').trigger('error');
expect(wrapper.find('img.banner').exists()).toBe(false);
expect(wrapper.find('h1.title').exists()).toBe(true);
});
it('formats the date in the album timezone (not the device timezone) with full range on title', () => {
// 测试机时区任意(含 UTC+8):按设备时区会得到 10月1日——断言纽约日界回退。
const wrapper = mountHeader({ album: makeAlbum({ timezone: 'America/New_York' }), viewCount: null });
const dateItem = wrapper.find('.meta-item[title]');
expect(dateItem.exists()).toBe(true);
// UTC 10-01 02:00/12:00 → New York(EDT)09-30 22:00 / 10-01 08:00。
expect(dateItem.text()).toContain('9月30');
expect(dateItem.text()).toContain('10月1');
const full = dateItem.attributes('title');
expect(full).toContain('22:00');
expect(full).toContain('08:00');
expect(full).toContain('GMT-4');
});
it('expands and collapses the clamped description and title (buttons only when overflowing)', async () => {
scrollSpy = vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(120);
clientSpy = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(60);
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
await nextTick(); // 溢出测量在 post watcher 中执行,等微任务落定
const expands = wrapper.findAll('.text-expand');
expect(expands).toHaveLength(2); // 标题 + 说明均溢出
expect(wrapper.find('.title').classes()).toContain('clamped');
expect(wrapper.find('.description').classes()).toContain('clamped');
await expands[1].trigger('click'); // 说明「展开」
expect(wrapper.find('.description').classes()).not.toContain('clamped');
expect(wrapper.find('.title').classes()).toContain('clamped');
expect(wrapper.findAll('.text-expand')[1].text()).toBe('收起');
await wrapper.findAll('.text-expand')[1].trigger('click'); // 说明「收起」
expect(wrapper.find('.description').classes()).toContain('clamped');
await wrapper.findAll('.text-expand')[0].trigger('click'); // 标题「展开」
expect(wrapper.find('.title').classes()).not.toContain('clamped');
});
it('sort tabs: latest active by default, clicking popular emits select-sort hot, locked disables', async () => {
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
const tabs = wrapper.findAll('.sort-tab');
expect(tabs[0].classes()).toContain('active');
expect(tabs[0].attributes('aria-pressed')).toBe('true');
expect(tabs[1].classes()).not.toContain('active');
await tabs[1].trigger('click');
expect(wrapper.emitted('selectSort')).toEqual([['hot']]);
const locked = mountHeader({ album: makeAlbum(), viewCount: null, sortLocked: true });
expect(locked.findAll('.sort-tab').every((tab) => tab.attributes('disabled') !== undefined)).toBe(true);
});
it('category chips: selected state from prop, clicks emit the category id and All emits null', async () => {
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null, selectedCategory: 'cat-sign' });
const chips = wrapper.findAll('.categories .chip');
expect(chips[1].classes()).toContain('active');
expect(chips[1].attributes('aria-pressed')).toBe('true');
expect(chips[0].classes()).not.toContain('active');
await chips[2].trigger('click');
await chips[0].trigger('click');
expect(wrapper.emitted('selectCategory')).toEqual([['cat-open'], [null]]);
});
it('share copies the album url to the clipboard and reports success; failure keeps a selectable url', async () => {
const writeText = stubClipboard(() => Promise.resolve());
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
await wrapper.find('.share-btn').trigger('click');
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith('http://localhost:3000/a/tokfixture0000000000aaaa');
expect(wrapper.find('.share-status').text()).toContain('链接已复制');
expect(wrapper.find('.share-url').exists()).toBe(false);
// 复制失败:轻提示 + 可选中文本(§5.4 回退语义,完整 navigator.share 属 Task 5)。
writeText.mockImplementation(() => Promise.reject(new Error('denied')));
await wrapper.find('.share-btn').trigger('click');
await Promise.resolve();
await Promise.resolve();
expect(wrapper.find('.share-status').text()).toContain('复制失败');
expect(wrapper.find('.share-url').text()).toBe('http://localhost:3000/a/tokfixture0000000000aaaa');
});
});
+15 -3
View File
@@ -28,15 +28,27 @@ describe('viewer i18n', () => {
expect(wrapper.text()).toContain('打开相册链接即可观看');
expect(document.title).toBe('现场相册');
await wrapper.find('button.locale-switcher').trigger('click');
// UI-02 V01 紧凑语言控件:展开菜单 → 选择 English。
await wrapper.find('.locale-trigger').trigger('click');
await nextTick();
const english = wrapper.findAll('.locale-option').find((btn) => btn.text() === 'English');
expect(english).toBeDefined();
await english!.trigger('click');
await nextTick();
expect(wrapper.text()).toContain('Open an album link to start viewing');
expect(document.title).toBe('Live Gallery');
expect(document.documentElement.getAttribute('lang')).toBe('en');
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('en');
// 切回 zh-CN 同样生效
await wrapper.find('button.locale-switcher').trigger('click');
// 切回 zh-CN 同样生效(菜单内当前语言 English 为勾选态)。
await wrapper.find('.locale-trigger').trigger('click');
await nextTick();
const options = wrapper.findAll('.locale-option');
const englishNow = options.find((btn) => btn.text() === 'English');
expect(englishNow!.attributes('aria-checked')).toBe('true');
const chinese = options.find((btn) => btn.text() === '中文');
expect(chinese!.attributes('aria-checked')).toBe('false');
await chinese!.trigger('click');
await nextTick();
expect(wrapper.text()).toContain('打开相册链接即可观看');
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-CN');
@@ -0,0 +1,112 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import App from '../App.vue';
import { createViewerI18n, type ViewerI18n } from '../i18n';
import { router } from '../router';
// UI-02 V01 外壳与视觉 token:
// · token 断言直接读 viewer-tokens.css 源文件(vitest 不加载样式表),
// 作用域正确性由渲染分支验证——普通路由挂 .viewer-shell(token 生效),
// embed 路由走 .embed-shell 裸分支(不挂类 → 不消费任何 token,隔离红线);
// · 顶栏:品牌 PhotoLive + 紧凑语言控件 + 「⋯」轻量菜单内的减少动画入口。
vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>();
return {
...actual,
// embed 路由数据:404 → 静默 gone(无 UI、无轮询),外壳分支足以验证。
// ApiError 经 actual 命名空间构造(工厂被提升,不能引用顶层 import 绑定)。
fetchPublicEmbedSlot: vi.fn().mockRejectedValue(
new actual.ApiError('EMBED_SLOT_NOT_FOUND', 'slot gone', 404, 'req-shell'),
),
};
});
let i18n: ViewerI18n;
let mounted: VueWrapper | null = null;
function mountApp(): VueWrapper {
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
mounted = wrapper;
return wrapper;
}
describe('viewer shell tokens and embed isolation (UI-02 V01)', () => {
beforeEach(() => {
localStorage.clear();
i18n = createViewerI18n('zh-CN');
});
afterEach(() => {
mounted?.unmount();
mounted = null;
document.documentElement.className = '';
});
it('declares all UI-02 §3.1 tokens scoped to .viewer-shell (not :root/global)', () => {
const cssPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'styles', 'viewer-tokens.css');
const css = readFileSync(cssPath, 'utf8');
const expected: Record<string, string> = {
'--viewer-primary': '#1A73E8',
'--viewer-surface': '#FFFFFF',
'--viewer-page-bg': '#F3F6FA',
'--viewer-text': '#172033',
'--viewer-text-secondary': '#647084',
'--viewer-divider': '#E5EAF1',
'--viewer-dark-bg': '#0E1218',
'--viewer-like-accent': '#FF667D',
'--viewer-font-family': "system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif",
'--viewer-radius-img': '4px',
'--viewer-radius-pill': '20px',
'--viewer-pad-info': '16px',
'--viewer-pad-photo': '4px',
'--viewer-touch-target': '44px',
};
for (const [name, value] of Object.entries(expected)) {
expect(css, `${name} token`).toContain(`${name}: ${value};`);
}
// 不得把 token 泄漏到全局根(embed iframe 会继承 :root 变量)。
expect(css).not.toContain(':root');
expect(css).toContain('.viewer-shell');
});
it('normal routes render .viewer-shell with PhotoLive brand top bar and prefs menu', async () => {
await router.push('/');
await router.isReady();
const app = mountApp();
expect(app.find('.viewer-shell').exists()).toBe(true);
expect(app.find('.embed-shell').exists()).toBe(false);
const topbar = app.find('header.topbar');
expect(topbar.exists()).toBe(true);
expect(topbar.find('.brand-name').text()).toBe('PhotoLive');
expect(topbar.find('.locale-trigger').exists()).toBe(true);
// 「⋯」轻量菜单(§3.2):减少动画入口可发现、可切换。
const more = topbar.find('.icon-btn');
expect(more.attributes('aria-haspopup')).toBe('menu');
await more.trigger('click');
const item = app.find('.prefs-item');
expect(item.text()).toBe('减少动画');
const before = item.attributes('aria-checked');
await item.trigger('click');
expect(app.find('.prefs-item').attributes('aria-checked')).toBe(before === 'true' ? 'false' : 'true');
// 还原模块级偏好单例,避免影响后续用例。
await app.find('.prefs-item').trigger('click');
});
it('embed routes keep the bare shell: no .viewer-shell, no top bar, no outer padding wrapper', async () => {
await router.push('/embed/slot-shell-test');
await router.isReady();
const app = mountApp();
await flushPromises();
expect(app.find('.embed-shell').exists()).toBe(true);
expect(app.find('.viewer-shell').exists()).toBe(false);
expect(app.find('header.topbar').exists()).toBe(false);
expect(app.find('.viewer-main').exists()).toBe(false);
});
});
+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>
+16
View File
@@ -63,6 +63,22 @@ export default {
// 轮播中被移除(ws photo.hidden)的轻提示。
photoRemoved: 'This photo has been removed',
backToAlbum: 'Back to album',
// —— UI-02 (V01/V02) additions ——
brand: 'PhotoLive',
settings: 'Settings',
share: 'Share',
shareCopied: 'Link copied',
shareCopyFailed: 'Copy failed — please copy the link manually:',
views: '{count} views',
viewsUnknown: '—',
readMore: 'More',
readLess: 'Less',
latest: 'Latest',
popular: 'Popular',
allCategories: 'All',
noPhotos: 'Photos coming soon',
newPhotos: 'New photos',
reduceMotion: 'Reduce motion',
},
route: { tokenLabel: 'Album token', photoLabel: 'Photo' },
errors: {
+16
View File
@@ -63,6 +63,22 @@ export default {
// PHOTO_NOT_PUBLIC 专用:不存在/未发布/已隐藏同码(防枚举),文案覆盖两种成因。
photoUnavailable: '照片不存在或已下线',
backToAlbum: '返回相册',
// —— UI-02(V01/V02)新增 ——
brand: 'PhotoLive',
settings: '设置',
share: '分享',
shareCopied: '链接已复制',
shareCopyFailed: '复制失败,请手动复制链接:',
views: '{count} 次浏览',
viewsUnknown: '—',
readMore: '展开',
readLess: '收起',
latest: '最新',
popular: '热门',
allCategories: '全部',
noPhotos: '照片即将上线',
newPhotos: '有新照片',
reduceMotion: '减少动画',
},
route: { tokenLabel: '相册令牌', photoLabel: '照片' },
errors: {
+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;
+22
View File
@@ -0,0 +1,22 @@
/*
* UI-02 §3.1 统一视觉 token(V01)。
* 作用域红线:变量只挂在 .viewer-shell(普通观众路由外壳)上——embed 路由
* (App.vue 的 .embed-shell 分支)不挂此类,嵌入 iframe 布局不消费任何
* token,亦不受本文件影响。消费方一律 var(--viewer-*),不再散写色值。
*/
.viewer-shell {
--viewer-primary: #1A73E8;
--viewer-surface: #FFFFFF;
--viewer-page-bg: #F3F6FA;
--viewer-text: #172033;
--viewer-text-secondary: #647084;
--viewer-divider: #E5EAF1;
--viewer-dark-bg: #0E1218;
--viewer-like-accent: #FF667D;
--viewer-font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
--viewer-radius-img: 4px;
--viewer-radius-pill: 20px;
--viewer-pad-info: 16px;
--viewer-pad-photo: 4px;
--viewer-touch-target: 44px;
}