feat(viewer): album page with locale fallback and timezone display
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
cleanupSeededAlbums,
|
||||
makePublicToken,
|
||||
seedAlbum,
|
||||
seedBannerAsset,
|
||||
seedCategory,
|
||||
} from './helpers';
|
||||
|
||||
// Task 6 活动页 Playwright(真实后端:API 8080 由 playwright.config webServer 拉起
|
||||
// 或复用已在跑的实例;种子走 Prisma/MinIO 直种,见 helpers.ts)。
|
||||
// 语言前置:无头 Chromium 默认 en-US——用 addInitScript 预置 localStorage,避免
|
||||
// 依赖浏览器语言;zh 断言由此确定性地落在 zh-CN UI。
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanupSeededAlbums();
|
||||
});
|
||||
|
||||
test('renders album info in zh with timezone-adjusted time, chips and empty state', async ({ page }) => {
|
||||
const album = await seedAlbum({
|
||||
alias: 'main',
|
||||
translations: [
|
||||
{
|
||||
locale: 'zh-CN',
|
||||
title: '2026 公司年会',
|
||||
description: '年度盛会',
|
||||
venue: '大宴会厅',
|
||||
address: '上海市浦东新区世纪大道 100 号',
|
||||
},
|
||||
{ locale: 'en', title: 'Annual Party 2026', description: 'Yearly gala' },
|
||||
],
|
||||
});
|
||||
await seedCategory(album.id, 0, [
|
||||
{ locale: 'zh-CN', name: '签到墙' },
|
||||
{ locale: 'en', name: 'Check-in Wall' },
|
||||
]);
|
||||
await seedCategory(album.id, 1, [{ locale: 'zh-CN', name: '开场' }]);
|
||||
|
||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||
await page.goto(`/a/${album.publicToken}`);
|
||||
|
||||
await expect(page.locator('h1.title')).toHaveText('2026 公司年会');
|
||||
await expect(page.locator('.description')).toHaveText('年度盛会');
|
||||
// Asia/Shanghai:UTC 10-01 02:00/12:00 → 本地 10-01(不含 9/30 越日错误)。
|
||||
const time = page.locator('.meta');
|
||||
await expect(time).toContainText('10月1日');
|
||||
await expect(time).not.toContainText('9月30');
|
||||
await expect(page.locator('.meta')).toContainText('大宴会厅 · 上海市浦东新区世纪大道 100 号');
|
||||
|
||||
const chips = page.locator('.chip');
|
||||
await expect(chips).toHaveCount(2);
|
||||
await expect(chips.first()).toBeDisabled();
|
||||
await expect(chips).toHaveText(['签到墙', '开场']);
|
||||
|
||||
await expect(page.locator('.empty-notice')).toHaveText('照片即将上线');
|
||||
// 该相册无 banner 资产 → 不渲染图。
|
||||
await expect(page.locator('img.banner')).toHaveCount(0);
|
||||
await expect(page).toHaveTitle('2026 公司年会');
|
||||
});
|
||||
|
||||
test('switching locale re-fetches and shows the English translation', async ({ page }) => {
|
||||
const album = await seedAlbum({
|
||||
alias: 'switch',
|
||||
translations: [
|
||||
{ locale: 'zh-CN', title: '双语测试相册', description: '中文介绍' },
|
||||
{ locale: 'en', title: 'Bilingual Test Album', description: 'English intro', venue: 'Main Hall' },
|
||||
],
|
||||
});
|
||||
|
||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||
await page.goto(`/a/${album.publicToken}`);
|
||||
await expect(page.locator('h1.title')).toHaveText('双语测试相册');
|
||||
|
||||
await page.locator('button.locale-switcher').click();
|
||||
await expect(page.locator('h1.title')).toHaveText('Bilingual Test Album');
|
||||
await expect(page.locator('.description')).toHaveText('English intro');
|
||||
await expect(page.locator('.meta')).toContainText('Main Hall');
|
||||
await expect(page.locator('.empty-notice')).toHaveText('Photos coming soon');
|
||||
await expect(page).toHaveTitle('Bilingual Test Album');
|
||||
});
|
||||
|
||||
test('en UI with a zh-only album falls back to zh content while chrome stays en', async ({ page }) => {
|
||||
const album = await seedAlbum({
|
||||
alias: 'fallback',
|
||||
translations: [{ locale: 'zh-CN', title: '只有中文的相册', venue: '大宴会厅' }],
|
||||
});
|
||||
|
||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'en'));
|
||||
await page.goto(`/a/${album.publicToken}`);
|
||||
|
||||
// 内容回退:resolvedLocale=zh-CN(服务端返回 zh 翻译)。
|
||||
await expect(page.locator('h1.title')).toHaveText('只有中文的相册');
|
||||
await expect(page.locator('.meta')).toContainText('大宴会厅');
|
||||
// UI 语言仍为用户选择(en)。
|
||||
await expect(page.locator('.empty-notice')).toHaveText('Photos coming soon');
|
||||
});
|
||||
|
||||
test('banner signed URL loads a real image', async ({ page }) => {
|
||||
const album = await seedAlbum({
|
||||
alias: 'banner',
|
||||
translations: [{ locale: 'zh-CN', title: '有封面的相册' }],
|
||||
});
|
||||
await seedBannerAsset(album.id);
|
||||
|
||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||
await page.goto(`/a/${album.publicToken}`);
|
||||
|
||||
const banner = page.locator('img.banner');
|
||||
await expect(banner).toBeVisible();
|
||||
// 签名 URL 可取回真实字节(加载完成 naturalWidth > 0;失败则保持 0)。
|
||||
await expect
|
||||
.poll(async () => banner.evaluate((img) => (img as HTMLImageElement).naturalWidth))
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('offline and ghost tokens show the localized unavailable error', async ({ page }) => {
|
||||
const offline = await seedAlbum({
|
||||
alias: 'offline',
|
||||
status: 'offline',
|
||||
translations: [{ locale: 'zh-CN', title: '已下线相册' }],
|
||||
});
|
||||
|
||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||
await page.goto(`/a/${offline.publicToken}`);
|
||||
await expect(page.locator('.error[role="alert"]')).toContainText('相册不存在或已下线');
|
||||
await expect(page.locator('h1.title')).toHaveCount(0);
|
||||
|
||||
// 幽灵 token:同码同文案(防枚举)。
|
||||
await page.goto(`/a/${makePublicToken()}`);
|
||||
await expect(page.locator('.error[role="alert"]')).toContainText('相册不存在或已下线');
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// apps/viewer/e2e/helpers.ts —— Playwright 夹具:真实后端直种(决策见计划 Task 6
|
||||
// Step 2:真实 API + compose 基础设施,fixture 走 Prisma/Storage 直种,镜像
|
||||
// services/api/test/public.e2e-spec.ts 的种子形状)。公开端点匿名可达,无需登录态。
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { config } from 'dotenv';
|
||||
import { PrismaClient } from '@photo/db-cn';
|
||||
import { createStorageFromEnv, type MinioStorageAdapter } from '@photo/storage';
|
||||
|
||||
// 仓库根 .env 唯一来源(同 services/api test/load-env.ts 约定)。必须在
|
||||
// PrismaClient / createStorageFromEnv 构造前执行——两者均在首次查询/调用时读 env。
|
||||
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url));
|
||||
config({ path: join(repoRoot, '.env') });
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const storage: MinioStorageAdapter = createStorageFromEnv();
|
||||
|
||||
const KEY_ROOT = 'photo-live/dev';
|
||||
|
||||
/** 契约 pattern ^[0-9a-z]{20,32}$:24 字符 [0-9a-z](同 albums.service 生成式)。 */
|
||||
export function makePublicToken(): string {
|
||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
let token = '';
|
||||
const bytes = randomBytes(24);
|
||||
for (let i = 0; i < 24; i++) {
|
||||
token += alphabet[bytes[i] % alphabet.length];
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export interface SeedAlbumOptions {
|
||||
alias: string;
|
||||
defaultLocale?: string;
|
||||
translations: Array<{
|
||||
locale: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
venue?: string;
|
||||
address?: string;
|
||||
}>;
|
||||
status?: string;
|
||||
publicEnabled?: boolean;
|
||||
timezone?: string;
|
||||
startAt?: Date;
|
||||
endAt?: Date | null;
|
||||
}
|
||||
|
||||
const seededAlbumIds: string[] = [];
|
||||
|
||||
/** 经 Prisma 直种相册 + 翻译(行形状同 api 公开 e2e fixture)。 */
|
||||
export async function seedAlbum(options: SeedAlbumOptions): Promise<{ id: string; publicToken: string }> {
|
||||
const album = await prisma.albums.create({
|
||||
data: {
|
||||
publicToken: makePublicToken(),
|
||||
ownerId: `u-viewer-e2e-${options.alias}`,
|
||||
startAt: options.startAt ?? new Date('2026-10-01T02:00:00.000Z'),
|
||||
endAt: options.endAt === undefined ? new Date('2026-10-01T12:00:00.000Z') : options.endAt,
|
||||
timezone: options.timezone ?? 'Asia/Shanghai',
|
||||
status: options.status ?? 'active',
|
||||
defaultLocale: options.defaultLocale ?? 'zh-CN',
|
||||
publicEnabled: options.publicEnabled ?? true,
|
||||
},
|
||||
select: { id: true, publicToken: true },
|
||||
});
|
||||
seededAlbumIds.push(album.id);
|
||||
await prisma.album_translations.createMany({
|
||||
data: options.translations.map((t) => ({
|
||||
albumId: album.id,
|
||||
locale: t.locale,
|
||||
title: t.title,
|
||||
description: t.description ?? null,
|
||||
venue: t.venue ?? null,
|
||||
address: t.address ?? null,
|
||||
})),
|
||||
});
|
||||
return album;
|
||||
}
|
||||
|
||||
/** 直种分类 + 翻译(名称按 locale 解析),返回 id。 */
|
||||
export async function seedCategory(
|
||||
albumId: string,
|
||||
sortOrder: number,
|
||||
translations: Array<{ locale: string; name: string }>,
|
||||
): Promise<string> {
|
||||
const category = await prisma.categories.create({
|
||||
data: { albumId, sortOrder },
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.category_translations.createMany({
|
||||
data: translations.map((t) => ({ categoryId: category.id, locale: t.locale, name: t.name })),
|
||||
});
|
||||
return category.id;
|
||||
}
|
||||
|
||||
/** 640x200 纯色 JPEG(sharp 生成,仅作真实可取回对象——公开端点只做签名,不校验内容)。 */
|
||||
export const BANNER_JPEG = Buffer.from(
|
||||
'/9j/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxOUlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0//wAARCADIAoADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAH/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAQF/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AgDSRgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/2Q==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
/** 直种 banner 资产行 + MinIO 真实对象(kind=banner, state=effective, locale 全局)。 */
|
||||
export async function seedBannerAsset(albumId: string): Promise<void> {
|
||||
const objectKey = `${KEY_ROOT}/albums/${albumId}/assets/banner.jpg`;
|
||||
await storage.put(objectKey, BANNER_JPEG, 'image/jpeg');
|
||||
await prisma.album_assets.create({
|
||||
data: { albumId, kind: 'banner', locale: null, objectKey, state: 'effective' },
|
||||
});
|
||||
}
|
||||
|
||||
/** 清理本文件种下的相册域行(扁平字符串外键、无 DB 级联——按子表在前手工删;
|
||||
* category_translations 只挂 categoryId,先取分类 id 再删翻译)。
|
||||
* MinIO 对象不在此删:services/api e2e 的 reset 会整体清 `photo-live/dev` 前缀,
|
||||
* dev 桶内残余有界(同 api e2e 约定)。 */
|
||||
export async function cleanupSeededAlbums(): Promise<void> {
|
||||
try {
|
||||
if (seededAlbumIds.length > 0) {
|
||||
const categories = await prisma.categories.findMany({
|
||||
where: { albumId: { in: seededAlbumIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const categoryIds = categories.map((c) => c.id);
|
||||
if (categoryIds.length > 0) {
|
||||
await prisma.category_translations.deleteMany({ where: { categoryId: { in: categoryIds } } });
|
||||
}
|
||||
await prisma.categories.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
|
||||
await prisma.album_assets.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
|
||||
await prisma.album_translations.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
|
||||
await prisma.albums.deleteMany({ where: { id: { in: seededAlbumIds } } });
|
||||
seededAlbumIds.length = 0;
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,12 @@
|
||||
"vue-router": "~4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@photo/db-cn": "workspace:*",
|
||||
"@photo/storage": "workspace:*",
|
||||
"@types/node": "^22.10.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "~5.4.0",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// 冒烟占位(Task 5):真实旅程(活动页/瀑布流/大图/admin 联调)随 Task 6-8/11 扩充。
|
||||
// webServer 自动拉起 vite dev(5175,/v1 代理 → 127.0.0.1:8080),本地已有实例则复用。
|
||||
// Task 6 起 e2e 打真实后端:webServer 先等 API(8080,仓库根 `pnpm dev:api`
|
||||
// 构建+启动;已在跑的实例直接复用),再等 viewer dev(5175,/v1 代理 → 8080)。
|
||||
// compose 基础设施(MySQL/MinIO/Redis)需预先 `docker compose up -d`(计划约定)。
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
@@ -12,10 +13,19 @@ export default defineConfig({
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
webServer: {
|
||||
command: 'pnpm run dev',
|
||||
url: 'http://127.0.0.1:5175',
|
||||
reuseExistingServer: true,
|
||||
timeout: 60_000,
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: 'pnpm dev:api',
|
||||
cwd: '../..',
|
||||
url: 'http://127.0.0.1:8080/v1/health',
|
||||
reuseExistingServer: true,
|
||||
timeout: 180_000, // 首次含 tsc 全量构建
|
||||
},
|
||||
{
|
||||
command: 'pnpm run dev',
|
||||
url: 'http://127.0.0.1:5175',
|
||||
reuseExistingServer: true,
|
||||
timeout: 60_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
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 { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import ActivityPage from '../pages/ActivityPage.vue';
|
||||
import { router } from '../router';
|
||||
import { makeAlbum, makeEnAlbum } from './fixtures';
|
||||
|
||||
// ActivityPage(Task 6)组件测试:api client 以真实模块为底、仅替身 getPublicAlbum,
|
||||
// 保证 ApiError/errors 映射等真实逻辑参与断言。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return { ...actual, getPublicAlbum: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
|
||||
// 逐挂载独立 i18n 实例 + 逐用例卸载:vue-i18n 在 app.unmount 时 dispose 共享实例
|
||||
// (见 i18n/index.ts 注释),复用单例会让后续用例的 locale 写入失效;残留组件的
|
||||
// locale watch 也会在后续用例切换语言时再度触发取数,污染调用序断言。
|
||||
let i18n: ViewerI18n;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
function mountPage(): VueWrapper {
|
||||
const wrapper = mount(ActivityPage, { global: { plugins: [router, i18n] } });
|
||||
mounted.push(wrapper);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function mountApp(): VueWrapper {
|
||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
mounted.push(wrapper);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe('activity page (Task 6)', () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
document.title = '';
|
||||
mockedGetPublicAlbum.mockReset().mockResolvedValue(makeAlbum());
|
||||
await router.push('/a/tok-activity');
|
||||
await router.isReady();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
mounted.pop()?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders album info in zh: title, pre-wrap description, venue · address, categories chips and empty notice', async () => {
|
||||
const wrapper = mountPage();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-activity', 'zh-CN');
|
||||
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
|
||||
expect(wrapper.find('.description').text()).toBe('年度盛会\n多行介绍');
|
||||
expect(wrapper.find('.meta').text()).toContain('大宴会厅 · 上海市浦东新区世纪大道 100 号');
|
||||
// Asia/Shanghai:UTC 10-01 02:00/12:00 → 本地 10-01 10:00/20:00。
|
||||
expect(wrapper.find('.meta').text()).toContain('10月1日');
|
||||
|
||||
const chips = wrapper.findAll('.chip');
|
||||
expect(chips.map((chip) => chip.text())).toEqual(['签到墙', '开场']);
|
||||
expect(chips.every((chip) => chip.attributes('disabled') !== undefined)).toBe(true);
|
||||
|
||||
expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线');
|
||||
// 无 banner 资产 → 不渲染 img;viewCount 占位 null → 不渲染任何计数节点。
|
||||
expect(wrapper.find('img.banner').exists()).toBe(false);
|
||||
expect(wrapper.find('.view-count').exists()).toBe(false);
|
||||
expect(document.title).toBe('2026 公司年会');
|
||||
});
|
||||
|
||||
it('formats the time range in the album timezone (America/New_York shifts UTC date back one day)', async () => {
|
||||
mockedGetPublicAlbum.mockResolvedValue(
|
||||
makeAlbum({ timezone: 'America/New_York' }),
|
||||
);
|
||||
const wrapper = mountPage();
|
||||
await flushPromises();
|
||||
|
||||
// UTC 10-01 02:00 → New York(EDT, UTC-4)09-30 22:00;endAt 12:00Z → 10-01 08:00。
|
||||
const time = wrapper.find('.meta').text();
|
||||
expect(time).toContain('9月30');
|
||||
expect(time).toContain('10月1');
|
||||
expect(time).not.toContain('9月29');
|
||||
});
|
||||
|
||||
it('renders a single date when endAt is missing (no range separator)', async () => {
|
||||
mockedGetPublicAlbum.mockResolvedValue(
|
||||
makeAlbum({ timezone: 'America/New_York', endAt: null }),
|
||||
);
|
||||
const wrapper = mountPage();
|
||||
await flushPromises();
|
||||
|
||||
const time = wrapper.find('.meta').text();
|
||||
expect(time).toContain('9月30');
|
||||
expect(time).not.toContain('–'); // 区间分隔符不应出现
|
||||
expect(time).not.toContain('10月1');
|
||||
});
|
||||
|
||||
it('switching locale re-fetches with ?locale= and shows resolved English content', async () => {
|
||||
mockedGetPublicAlbum.mockImplementation((_token, locale) =>
|
||||
Promise.resolve(locale === 'en' ? makeEnAlbum() : makeAlbum()),
|
||||
);
|
||||
const wrapper = mountApp();
|
||||
await flushPromises();
|
||||
expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'zh-CN');
|
||||
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
|
||||
|
||||
await wrapper.find('button.locale-switcher').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicAlbum).toHaveBeenLastCalledWith('tok-activity', 'en');
|
||||
expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026');
|
||||
expect(wrapper.find('.empty-notice').text()).toBe('Photos coming soon');
|
||||
expect(document.title).toBe('Annual Party 2026');
|
||||
});
|
||||
|
||||
it('ALBUM_NOT_PUBLIC renders the localized message with retry; retry recovers', async () => {
|
||||
mockedGetPublicAlbum
|
||||
.mockRejectedValueOnce(new ApiError('ALBUM_NOT_PUBLIC', 'album not public', 404, 'req-1'))
|
||||
.mockResolvedValueOnce(makeAlbum());
|
||||
const wrapper = mountPage();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.error[role="alert"]').text()).toContain('相册不存在或已下线');
|
||||
expect(wrapper.find('h1.title').exists()).toBe(false);
|
||||
|
||||
await wrapper.find('.error button').trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
|
||||
});
|
||||
|
||||
it('renders banner img with the signed url when present, nothing when absent', async () => {
|
||||
mockedGetPublicAlbum.mockResolvedValue(
|
||||
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' } }),
|
||||
);
|
||||
const withBanner = mountPage();
|
||||
await flushPromises();
|
||||
const img = withBanner.find('img.banner');
|
||||
expect(img.exists()).toBe(true);
|
||||
expect(img.attributes('src')).toContain('/albums/a1/assets/banner.jpg?sig=1');
|
||||
|
||||
mockedGetPublicAlbum.mockResolvedValue(makeAlbum());
|
||||
const withoutBanner = mountPage();
|
||||
await flushPromises();
|
||||
expect(withoutBanner.find('img.banner').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('hides the banner on img load failure (layout adapts, no broken image)', async () => {
|
||||
mockedGetPublicAlbum.mockResolvedValue(
|
||||
makeAlbum({ banner: { url: 'http://127.0.0.1:9000/photo-live/dev/expired.jpg?sig=stale', expiresAt: '2020-01-01T00:00:00.000Z' } }),
|
||||
);
|
||||
const wrapper = mountPage();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('img.banner').exists()).toBe(true);
|
||||
|
||||
await wrapper.find('img.banner').trigger('error');
|
||||
await nextTick();
|
||||
expect(wrapper.find('img.banner').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the localized loading state until the request settles', async () => {
|
||||
let resolve!: (value: PublicAlbum) => void;
|
||||
mockedGetPublicAlbum.mockReturnValue(
|
||||
new Promise<PublicAlbum>((res) => {
|
||||
resolve = res;
|
||||
}),
|
||||
);
|
||||
const wrapper = mountPage();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.status').text()).toBe('加载中…');
|
||||
expect(wrapper.find('h1.title').exists()).toBe(false);
|
||||
|
||||
resolve(makeAlbum());
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.status').exists()).toBe(false);
|
||||
expect(wrapper.find('h1.title').text()).toBe('2026 公司年会');
|
||||
});
|
||||
|
||||
it('discards stale responses when the locale changes quickly (last request wins)', async () => {
|
||||
const pending: Array<(value: PublicAlbum | PromiseLike<PublicAlbum>) => void> = [];
|
||||
mockedGetPublicAlbum.mockImplementation(
|
||||
() =>
|
||||
new Promise<PublicAlbum>((res) => {
|
||||
pending.push(res);
|
||||
}),
|
||||
);
|
||||
const wrapper = mountPage();
|
||||
await flushPromises();
|
||||
|
||||
// zh 请求在途时切到 en(触发第二个请求),en 先返回、zh 后返回——zh 为过期响应。
|
||||
i18n.global.locale.value = 'en';
|
||||
await flushPromises();
|
||||
expect(pending).toHaveLength(2);
|
||||
|
||||
pending[1](makeEnAlbum());
|
||||
await flushPromises();
|
||||
expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026');
|
||||
|
||||
pending[0](makeAlbum());
|
||||
await flushPromises();
|
||||
expect(wrapper.find('h1.title').text()).toBe('Annual Party 2026');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { PublicAlbum } from '../api/client';
|
||||
|
||||
// 组件测试共享夹具:公开相册响应的最小完整形状(对齐契约 PublicAlbum 必返字段)。
|
||||
// vi.mock 工厂需在各 spec 文件内联(hoisting 限制),此处只承载数据构造。
|
||||
export function makeAlbum(
|
||||
overrides: Partial<PublicAlbum> & { translation?: Partial<PublicAlbum['translation']> } = {},
|
||||
): PublicAlbum {
|
||||
const { translation, ...rest } = overrides;
|
||||
return {
|
||||
publicToken: 'tokfixture0000000000aaaa',
|
||||
resolvedLocale: 'zh-CN',
|
||||
defaultLocale: 'zh-CN',
|
||||
timezone: 'Asia/Shanghai',
|
||||
startAt: '2026-10-01T02:00:00.000Z',
|
||||
endAt: '2026-10-01T12:00:00.000Z',
|
||||
translation: {
|
||||
locale: 'zh-CN',
|
||||
title: '2026 公司年会',
|
||||
description: '年度盛会\n多行介绍',
|
||||
venue: '大宴会厅',
|
||||
address: '上海市浦东新区世纪大道 100 号',
|
||||
...translation,
|
||||
},
|
||||
categories: [
|
||||
{ id: 'cat-sign', name: '签到墙', sortOrder: 0 },
|
||||
{ id: 'cat-open', name: '开场', sortOrder: 1 },
|
||||
],
|
||||
banner: null,
|
||||
viewCount: null,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeEnAlbum(): PublicAlbum {
|
||||
return makeAlbum({
|
||||
resolvedLocale: 'en',
|
||||
translation: {
|
||||
locale: 'en',
|
||||
title: 'Annual Party 2026',
|
||||
description: 'Yearly gala',
|
||||
venue: 'Grand Ballroom',
|
||||
address: '100 Century Ave, Pudong, Shanghai',
|
||||
},
|
||||
categories: [
|
||||
{ id: 'cat-sign', name: 'Check-in Wall', sortOrder: 0 },
|
||||
{ id: 'cat-open', name: 'Opening', sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,27 +1,36 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
import App from '../App.vue';
|
||||
import { LOCALE_STORAGE_KEY, i18n } from '../i18n';
|
||||
import { LOCALE_STORAGE_KEY, createViewerI18n } from '../i18n';
|
||||
import { router } from '../router';
|
||||
|
||||
// i18n 切换机制(顶栏 chrome 层):活动页级的「切换语言 → 重取内容」由
|
||||
// activity.spec.ts 覆盖,此处聚焦首页 chrome(无需 api mock)。
|
||||
// 逐挂载独立 i18n + 逐用例卸载(背景见 activity.spec.ts 头注)。
|
||||
let i18n: ReturnType<typeof createViewerI18n>;
|
||||
let wrapper: VueWrapper;
|
||||
|
||||
describe('viewer i18n', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
i18n.global.locale.value = 'zh-CN';
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
await router.push('/');
|
||||
await router.isReady();
|
||||
wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('switching locale re-renders messages and persists the choice', async () => {
|
||||
await router.push('/a/tok-i18n');
|
||||
await router.isReady();
|
||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
|
||||
expect(wrapper.text()).toContain('照片即将上线');
|
||||
expect(wrapper.text()).toContain('打开相册链接即可观看');
|
||||
expect(document.title).toBe('现场相册');
|
||||
|
||||
await wrapper.find('button.locale-switcher').trigger('click');
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toContain('Photos coming soon');
|
||||
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');
|
||||
@@ -29,7 +38,7 @@ describe('viewer i18n', () => {
|
||||
// 切回 zh-CN 同样生效
|
||||
await wrapper.find('button.locale-switcher').trigger('click');
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toContain('照片即将上线');
|
||||
expect(wrapper.text()).toContain('打开相册链接即可观看');
|
||||
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-CN');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,58 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../App.vue';
|
||||
import { i18n } from '../i18n';
|
||||
import { getPublicAlbum } from '../api/client';
|
||||
import { createViewerI18n } from '../i18n';
|
||||
import { router } from '../router';
|
||||
import { makeAlbum } from './fixtures';
|
||||
|
||||
// 路由解析(Task 5 脚手架测试对齐 Task 6 真实活动页):token/photoId 参数经
|
||||
// api mock 断言——活动页以 token 取数,photoId 供 Task 8 深链定位(不影响取数)。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return { ...actual, getPublicAlbum: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
|
||||
// 逐挂载独立 i18n + 逐用例卸载(背景见 activity.spec.ts 头注)。
|
||||
let i18n: ReturnType<typeof createViewerI18n>;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
describe('viewer routing', () => {
|
||||
beforeEach(() => {
|
||||
i18n.global.locale.value = 'zh-CN';
|
||||
beforeEach(async () => {
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
mockedGetPublicAlbum.mockReset().mockResolvedValue(makeAlbum());
|
||||
});
|
||||
|
||||
it('activity route resolves token param and renders empty notice', async () => {
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
mounted.pop()?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('activity route resolves token param and loads the album', async () => {
|
||||
await router.push('/a/tok-abc');
|
||||
await router.isReady();
|
||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
expect(wrapper.text()).toContain('tok-abc');
|
||||
mounted.push(wrapper);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-abc', 'zh-CN');
|
||||
expect(wrapper.text()).toContain('2026 公司年会');
|
||||
expect(wrapper.text()).toContain('照片即将上线');
|
||||
expect(wrapper.text()).not.toContain('photo-');
|
||||
});
|
||||
|
||||
it('deep link resolves token and photoId params on the activity page', async () => {
|
||||
await router.push('/a/tok-abc/p/photo-42');
|
||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
await router.isReady();
|
||||
expect(wrapper.text()).toContain('tok-abc');
|
||||
expect(wrapper.text()).toContain('photo-42');
|
||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
mounted.push(wrapper);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-abc', 'zh-CN');
|
||||
expect(wrapper.text()).toContain('2026 公司年会');
|
||||
// photoId 仅用于 Task 8 大图定位,不进入信息区文案。
|
||||
expect(wrapper.text()).not.toContain('photo-42');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { setLocale, type ViewerLocale } from '../i18n';
|
||||
import { LOCALE_STORAGE_KEY, type ViewerLocale } from '../i18n';
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
@@ -9,8 +9,17 @@ const nextLocale = computed<ViewerLocale>(() => (locale.value === 'zh-CN' ? 'en'
|
||||
// 按钮始终显示目标语言的自称,便于不识当前语言的用户发现
|
||||
const nextLabel = computed(() => (nextLocale.value === 'zh-CN' ? '中文' : 'English'));
|
||||
|
||||
// 直接写当前 composer(可写 locale):与注入的 i18n 实例解耦——组件测试可挂载
|
||||
// 独立实例而不被运行时单例牵制。持久化随切换写入,写失败不阻断。
|
||||
// 注意先取 next 再赋值:赋值后 nextLocale 已重算为目标的对侧。
|
||||
function switchLocale(): void {
|
||||
setLocale(nextLocale.value);
|
||||
const next = nextLocale.value;
|
||||
locale.value = next;
|
||||
try {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
||||
} catch {
|
||||
// 忽略持久化失败
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -25,19 +25,21 @@ function resolveInitialLocale(): ViewerLocale {
|
||||
return FALLBACK_LOCALE;
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: resolveInitialLocale(),
|
||||
fallbackLocale: FALLBACK_LOCALE,
|
||||
messages: { 'zh-CN': zhCN, en },
|
||||
});
|
||||
|
||||
// 切换语言并持久化;写失败不阻断切换。
|
||||
export function setLocale(locale: ViewerLocale): void {
|
||||
i18n.global.locale.value = locale;
|
||||
try {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// 忽略持久化失败
|
||||
}
|
||||
/**
|
||||
* 构建 viewer i18n 实例(运行时单例与测试各自构造)。
|
||||
* 组件测试必须每次挂载用新实例:vue-i18n 的 install 会包装 app.unmount 调
|
||||
* i18n.dispose()——共享单例被任一测试卸载后全局 composer 反应性即永久失效
|
||||
* (locale 写入不再传播)。运行时 main.ts 只挂载一次,不受影响。
|
||||
*/
|
||||
export function createViewerI18n(initialLocale: ViewerLocale = resolveInitialLocale()) {
|
||||
return createI18n({
|
||||
legacy: false,
|
||||
locale: initialLocale,
|
||||
fallbackLocale: FALLBACK_LOCALE,
|
||||
messages: { 'zh-CN': zhCN, en },
|
||||
});
|
||||
}
|
||||
|
||||
export type ViewerI18n = ReturnType<typeof createViewerI18n>;
|
||||
|
||||
export const i18n = createViewerI18n();
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
export default {
|
||||
app: { title: 'Live Gallery' },
|
||||
home: { hint: 'Open an album link to start viewing' },
|
||||
album: { emptyNotice: 'Photos coming soon' },
|
||||
album: {
|
||||
loading: 'Loading…',
|
||||
emptyNotice: 'Photos coming soon',
|
||||
time: 'Time',
|
||||
venue: 'Venue',
|
||||
categories: 'Categories',
|
||||
retry: 'Retry',
|
||||
},
|
||||
lang: { switch: 'Switch language' },
|
||||
route: { tokenLabel: 'Album token', photoLabel: 'Photo' },
|
||||
errors: {
|
||||
albumNotFound: 'Album not found',
|
||||
albumNotAvailable: 'Album unavailable',
|
||||
// ALBUM_NOT_PUBLIC 专用:offline / disabled / ghost token 同码(防枚举)。
|
||||
albumNotAvailable: 'Album not found or offline',
|
||||
photoNotFound: 'Photo not found',
|
||||
notFound: 'Page not found',
|
||||
authExpired: 'Session expired',
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
export default {
|
||||
app: { title: '现场相册' },
|
||||
home: { hint: '打开相册链接即可观看' },
|
||||
album: { emptyNotice: '照片即将上线' },
|
||||
album: {
|
||||
loading: '加载中…',
|
||||
emptyNotice: '照片即将上线',
|
||||
time: '时间',
|
||||
venue: '地点',
|
||||
categories: '分类',
|
||||
retry: '重试',
|
||||
},
|
||||
lang: { switch: '切换语言' },
|
||||
route: { tokenLabel: '相册令牌', photoLabel: '照片' },
|
||||
errors: {
|
||||
albumNotFound: '相册不存在',
|
||||
albumNotAvailable: '相册不可用',
|
||||
// ALBUM_NOT_PUBLIC 专用:offline / publicEnabled=false / 幽灵 token 同码(防枚举),
|
||||
// 文案需同时覆盖「不存在」与「已下线」两种用户可理解成因。
|
||||
albumNotAvailable: '相册不存在或已下线',
|
||||
photoNotFound: '照片不存在',
|
||||
notFound: '页面不存在',
|
||||
authExpired: '登录已过期',
|
||||
|
||||
@@ -1,21 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ApiError, NETWORK_ERROR_CODE, getPublicAlbum, type PublicAlbum } from '../api/client';
|
||||
import { errorMessageKey } from '../api/errors';
|
||||
|
||||
// 脚手架占位页:Task 6 起替换为活动信息区 + 空态,Task 7/8 补瀑布流与大图。
|
||||
// 单图深链(/a/:token/p/:photoId)复用本组件,photoId 用于定位目标照片。
|
||||
// 观众活动页(P1b Task 6):信息区 + 空态 + 时区展示;瀑布流(Task 7)与大图
|
||||
// (Task 8,复用本组件于 /a/:token/p/:photoId 深链)随后补充。
|
||||
//
|
||||
// 语言模型(文档化决策):
|
||||
// · UI 语言 = 用户选择(i18n locale,LocaleSwitcher 切换,localStorage 持久);
|
||||
// · 内容语言 = 同一选择值作为 ?locale= 查询参数传给公开端点;
|
||||
// · resolvedLocale(响应)仅说明服务端实际返回的翻译语言(请求语言缺翻译时
|
||||
// 回退 defaultLocale)——不反向驱动 UI 语言,避免「切了英文却被拽回中文」。
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const token = computed(() => String(route.params.token ?? ''));
|
||||
const photoId = computed(() => (route.params.photoId !== undefined ? String(route.params.photoId) : null));
|
||||
|
||||
const album = ref<PublicAlbum | null>(null);
|
||||
const loadErrorKey = ref<string | null>(null);
|
||||
const loading = ref(true);
|
||||
// banner 签名 URL 加载失败(onerror)后隐藏,布局自适应(无残破图占位)。
|
||||
const bannerFailed = ref(false);
|
||||
|
||||
// 快速连续切换语言/路由时的竞态防护:仅最新请求的结果可落地。
|
||||
let loadSequence = 0;
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const sequence = ++loadSequence;
|
||||
loading.value = true;
|
||||
bannerFailed.value = false;
|
||||
try {
|
||||
const data = await getPublicAlbum(token.value, locale.value);
|
||||
if (sequence !== loadSequence) return;
|
||||
album.value = data;
|
||||
loadErrorKey.value = null;
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return;
|
||||
album.value = null;
|
||||
const code = error instanceof ApiError ? error.code : NETWORK_ERROR_CODE;
|
||||
loadErrorKey.value = errorMessageKey(code);
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
// token 变化(相册间跳转)与 UI 语言切换(内容随之换语言重取)都触发重载。
|
||||
watch([token, locale], load);
|
||||
|
||||
// 文档标题随相册标题;加载中/出错回退应用名。App.vue 的同名 watchEffect 先注册
|
||||
// 先执行,此处后写覆盖(locale 变化时两者都重跑,最终值以本页为准)。
|
||||
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(' · ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="activity">
|
||||
<p class="meta">{{ t('route.tokenLabel') }}: {{ token }}</p>
|
||||
<p v-if="photoId !== null" class="meta">{{ t('route.photoLabel') }}: {{ photoId }}</p>
|
||||
<p class="empty-notice">{{ t('album.emptyNotice') }}</p>
|
||||
<p v-if="loading" class="status">{{ t('album.loading') }}</p>
|
||||
|
||||
<div v-else-if="loadErrorKey !== null" class="error" role="alert">
|
||||
<p>{{ t(loadErrorKey) }}</p>
|
||||
<button type="button" @click="load">{{ t('album.retry') }}</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="album !== null">
|
||||
<img
|
||||
v-if="album.banner !== null && !bannerFailed"
|
||||
class="banner"
|
||||
:src="album.banner.url"
|
||||
:alt="album.translation.title"
|
||||
@error="bannerFailed = true"
|
||||
/>
|
||||
|
||||
<header class="info">
|
||||
<h1 class="title">{{ album.translation.title }}</h1>
|
||||
<p v-if="album.translation.description !== null" class="description">{{ album.translation.description }}</p>
|
||||
<dl class="meta">
|
||||
<div v-if="timeText !== ''" class="meta-row">
|
||||
<dt>{{ t('album.time') }}</dt>
|
||||
<dd>{{ timeText }}</dd>
|
||||
</div>
|
||||
<div v-if="venueText !== ''" class="meta-row">
|
||||
<dt>{{ t('album.venue') }}</dt>
|
||||
<dd>{{ venueText }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<!-- Task 7 接线分类过滤;当前仅展示(禁用态占位)。 -->
|
||||
<nav v-if="album.categories.length > 0" class="categories" :aria-label="t('album.categories')">
|
||||
<button v-for="category in album.categories" :key="category.id" type="button" class="chip" disabled>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 瀑布流(Task 7)前的列表占位:空态文案由 i18n 提供。 -->
|
||||
<div class="feed">
|
||||
<p class="empty-notice">{{ t('album.emptyNotice') }}</p>
|
||||
</div>
|
||||
<!-- viewCount 契约占位 null:不渲染任何浏览计数节点。 -->
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 极简基线样式:视觉打磨留待 P4a。 */
|
||||
.activity {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.status {
|
||||
color: #6b7280;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.error button {
|
||||
margin-top: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.banner {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.description {
|
||||
margin: 0 0 12px;
|
||||
color: #374151;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.meta {
|
||||
margin: 0 0 12px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: 3em 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.meta-row dt {
|
||||
color: #6b7280;
|
||||
}
|
||||
.meta-row dd {
|
||||
margin: 0;
|
||||
}
|
||||
.categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.chip {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 999px;
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
padding: 2px 12px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.chip:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.feed {
|
||||
border: 1px dashed #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-notice {
|
||||
margin: 0;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"]
|
||||
}
|
||||
|
||||
Generated
+12
@@ -51,12 +51,24 @@ importers:
|
||||
specifier: ~4.4.0
|
||||
version: 4.4.5(vue@3.4.38(typescript@5.9.3))
|
||||
devDependencies:
|
||||
'@photo/db-cn':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db-cn
|
||||
'@photo/storage':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/storage
|
||||
'@types/node':
|
||||
specifier: ^22.10.0
|
||||
version: 22.20.3
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.4(vite@5.4.21(@types/node@22.20.3))(vue@3.4.38(typescript@5.9.3))
|
||||
'@vue/test-utils':
|
||||
specifier: ^2.4.6
|
||||
version: 2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.4.38(vue@3.4.38(typescript@5.9.3)))(vue@3.4.38(typescript@5.9.3))
|
||||
dotenv:
|
||||
specifier: ^16.4.5
|
||||
version: 16.6.1
|
||||
jsdom:
|
||||
specifier: ^25.0.0
|
||||
version: 25.0.1
|
||||
|
||||
Reference in New Issue
Block a user