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