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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user