diff --git a/apps/viewer/e2e/likes.spec.ts b/apps/viewer/e2e/likes.spec.ts index f116f43..50be2a0 100644 --- a/apps/viewer/e2e/likes.spec.ts +++ b/apps/viewer/e2e/likes.spec.ts @@ -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); diff --git a/apps/viewer/e2e/smoke.spec.ts b/apps/viewer/e2e/smoke.spec.ts index 0fe6f88..c7a57aa 100644 --- a/apps/viewer/e2e/smoke.spec.ts +++ b/apps/viewer/e2e/smoke.spec.ts @@ -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(); }); diff --git a/apps/viewer/src/App.vue b/apps/viewer/src/App.vue index 3111f28..b875ab8 100644 --- a/apps/viewer/src/App.vue +++ b/apps/viewer/src/App.vue @@ -1,9 +1,12 @@ diff --git a/apps/viewer/src/__tests__/activity.spec.ts b/apps/viewer/src/__tests__/activity.spec.ts index ec0a88c..08ffa44 100644 --- a/apps/viewer/src/__tests__/activity.spec.ts +++ b/apps/viewer/src/__tests__/activity.spec.ts @@ -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 { + 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'); + }); }); diff --git a/apps/viewer/src/__tests__/album-header.spec.ts b/apps/viewer/src/__tests__/album-header.spec.ts new file mode 100644 index 0000000..92b41b0 --- /dev/null +++ b/apps/viewer/src/__tests__/album-header.spec.ts @@ -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; + 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): 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'); + }); +}); diff --git a/apps/viewer/src/__tests__/i18n.spec.ts b/apps/viewer/src/__tests__/i18n.spec.ts index 4b616dd..cbfa656 100644 --- a/apps/viewer/src/__tests__/i18n.spec.ts +++ b/apps/viewer/src/__tests__/i18n.spec.ts @@ -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'); diff --git a/apps/viewer/src/__tests__/viewer-shell.spec.ts b/apps/viewer/src/__tests__/viewer-shell.spec.ts new file mode 100644 index 0000000..a451611 --- /dev/null +++ b/apps/viewer/src/__tests__/viewer-shell.spec.ts @@ -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(); + 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 = { + '--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); + }); +}); diff --git a/apps/viewer/src/components/LocaleSwitcher.vue b/apps/viewer/src/components/LocaleSwitcher.vue index 4022c22..e3ceabc 100644 --- a/apps/viewer/src/components/LocaleSwitcher.vue +++ b/apps/viewer/src/components/LocaleSwitcher.vue @@ -1,40 +1,145 @@ diff --git a/apps/viewer/src/components/album/AlbumHeader.vue b/apps/viewer/src/components/album/AlbumHeader.vue new file mode 100644 index 0000000..fd3bddf --- /dev/null +++ b/apps/viewer/src/components/album/AlbumHeader.vue @@ -0,0 +1,553 @@ + + + + + diff --git a/apps/viewer/src/i18n/locales/en.ts b/apps/viewer/src/i18n/locales/en.ts index bb94fd6..6e6446a 100644 --- a/apps/viewer/src/i18n/locales/en.ts +++ b/apps/viewer/src/i18n/locales/en.ts @@ -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: { diff --git a/apps/viewer/src/i18n/locales/zh-CN.ts b/apps/viewer/src/i18n/locales/zh-CN.ts index 4a7f722..48d6f89 100644 --- a/apps/viewer/src/i18n/locales/zh-CN.ts +++ b/apps/viewer/src/i18n/locales/zh-CN.ts @@ -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: { diff --git a/apps/viewer/src/pages/ActivityPage.vue b/apps/viewer/src/pages/ActivityPage.vue index f686a83..3e45542 100644 --- a/apps/viewer/src/pages/ActivityPage.vue +++ b/apps/viewer/src/pages/ActivityPage.vue @@ -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(null); const loadErrorKey = ref(null); const loading = ref(true); -// banner 签名 URL 加载失败(onerror)后隐藏,布局自适应(无残破图占位)。 -const bannerFailed = ref(false); /** 相册浏览数(Task 10):album 加载初值 + 上报确认/ stats.updated 校准。 */ const viewCount = ref(null); @@ -64,7 +63,6 @@ let loadSequence = 0; async function load(): Promise { 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(null); @@ -356,32 +317,17 @@ onBeforeUnmount(() => { @@ -466,10 +364,16 @@ onBeforeUnmount(() => {