+
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(() => {