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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user