feat(viewer): album page with locale fallback and timezone display

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 09:52:22 +08:00
parent 04b5641901
commit adc45b1563
15 changed files with 879 additions and 61 deletions
+131
View File
@@ -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('相册不存在或已下线');
});
+135
View File
@@ -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();
}
}