feat(viewer): waterfall feed with lazy load, filter and new-photo hint

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 10:23:27 +08:00
parent adc45b1563
commit d0b79dd0c9
15 changed files with 1305 additions and 27 deletions
+102 -1
View File
@@ -1,10 +1,13 @@
// apps/viewer/e2e/helpers.ts —— Playwright 夹具:真实后端直种(决策见计划 Task 6
// Step 2:真实 API + compose 基础设施,fixture 走 Prisma/Storage 直种,镜像
// services/api/test/public.e2e-spec.ts 的种子形状)。公开端点匿名可达,无需登录态。
// Task 7 起补 seedPhoto:直种「已发布完成态」照片(photos + thumb/browse 变体行
// + MinIO 真实 JPEG 对象,行形状同 worker 发布事务落库形态)。
import { randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from 'dotenv';
import sharp from 'sharp';
import { PrismaClient } from '@photo/db-cn';
import { createStorageFromEnv, type MinioStorageAdapter } from '@photo/storage';
@@ -108,13 +111,111 @@ export async function seedBannerAsset(albumId: string): Promise<void> {
});
}
/** 测试内生成真实 JPEG(sharp,同 services/api test 约定;尺寸小控制体积/耗时)。 */
async function makeJpeg(width: number, height: number): Promise<Buffer> {
return sharp({
create: { width, height, channels: 3, background: { r: 96, g: 130, b: 182 } },
})
.jpeg({ quality: 70 })
.toBuffer();
}
/** cuid 形状 ID(对象键含 photoId,须先有 ID 再一次 insert 成形,同 api 约定)。 */
function cuidLike(alias: string): string {
return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`;
}
/** 与 worker publish.service variantsObjectKey 同布局的变体键。 */
function variantKey(albumId: string, photoId: string, variant: 'thumb' | 'browse'): string {
return `${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/${variant}.jpg`;
}
export interface SeedPhotoOptions {
categoryId?: string | null;
/** browse 宽高(驱动列表占位与瀑布流比例;thumb 减半生成)。 */
width?: number;
height?: number;
publishedAt: Date;
}
/** 直种已发布照片:photos 行(ready/published,entityVersion=2 对齐发布事务)+
* thumb/browse 变体行 + MinIO 真实对象。publishedAt 必传(列表排序确定性)。 */
export async function seedPhoto(
albumId: string,
alias: string,
options: SeedPhotoOptions,
): Promise<string> {
const photoId = cuidLike(alias);
const width = options.width ?? 480;
const height = options.height ?? 300;
const thumbWidth = Math.max(1, Math.round(width / 2));
const thumbHeight = Math.max(1, Math.round(height / 2));
const [thumb, browse] = await Promise.all([
makeJpeg(thumbWidth, thumbHeight),
makeJpeg(width, height),
]);
await storage.put(variantKey(albumId, photoId, 'thumb'), thumb, 'image/jpeg');
await storage.put(variantKey(albumId, photoId, 'browse'), browse, 'image/jpeg');
await prisma.photos.create({
data: {
id: photoId,
albumId,
uploaderId: `u-viewer-e2e-${alias}`,
categoryId: options.categoryId ?? null,
objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`,
bytes: browse.length,
width,
height,
uploadedAt: options.publishedAt,
publishedAt: options.publishedAt,
processingState: 'ready',
visibility: 'published',
entityVersion: 2,
},
});
await prisma.photo_variants.createMany({
data: [
{
photoId,
variant: 'thumb',
objectKey: variantKey(albumId, photoId, 'thumb'),
bytes: thumb.length,
version: 1,
width: thumbWidth,
height: thumbHeight,
},
{
photoId,
variant: 'browse',
objectKey: variantKey(albumId, photoId, 'browse'),
bytes: browse.length,
version: 1,
width,
height,
},
],
});
return photoId;
}
/** 清理本文件种下的相册域行(扁平字符串外键、无 DB 级联——按子表在前手工删;
* category_translations 只挂 categoryId,先取分类 id 再删翻译)。
* category_translations 只挂 categoryId,先取分类 id 再删翻译;Task 7 起照片域
* 同理:先按相册取照片 id 删变体行,再删 photos 主行)。
* MinIO 对象不在此删:services/api e2e 的 reset 会整体清 `photo-live/dev` 前缀,
* dev 桶内残余有界(同 api e2e 约定)。 */
export async function cleanupSeededAlbums(): Promise<void> {
try {
if (seededAlbumIds.length > 0) {
const photos = await prisma.photos.findMany({
where: { albumId: { in: seededAlbumIds } },
select: { id: true },
});
if (photos.length > 0) {
await prisma.photo_variants.deleteMany({
where: { photoId: { in: photos.map((p) => p.id) } },
});
}
await prisma.photos.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
const categories = await prisma.categories.findMany({
where: { albumId: { in: seededAlbumIds } },
select: { id: true },