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
+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);
});
});