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:
@@ -48,9 +48,9 @@ test('renders album info in zh with timezone-adjusted time, chips and empty stat
|
||||
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(chips).toHaveCount(3); // Task 7 起:全部 + 各分类(可点选)
|
||||
await expect(chips.first()).toBeEnabled();
|
||||
await expect(chips).toHaveText(['全部', '签到墙', '开场']);
|
||||
|
||||
await expect(page.locator('.empty-notice')).toHaveText('照片即将上线');
|
||||
// 该相册无 banner 资产 → 不渲染图。
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { cleanupSeededAlbums, seedAlbum, seedCategory, seedPhoto } from './helpers';
|
||||
|
||||
// Task 7 瀑布流 Playwright(真实后端):35 张已发布照片(公开列表默认页 30)→
|
||||
// 初始 30 + 滚动取第 2 页 5 张;分类 chips 过滤;新照片轮询提示(__viewerPollMs
|
||||
// 注入缩短间隔,见 PhotoFeed pollIntervalMs 的 dev/test 覆盖钩子)。
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanupSeededAlbums();
|
||||
});
|
||||
|
||||
/** 5 种宽高比例轮换(瀑布流高度预留的真实形状输入)。 */
|
||||
const RATIOS: Array<{ width: number; height: number }> = [
|
||||
{ width: 480, height: 300 },
|
||||
{ width: 300, height: 480 },
|
||||
{ width: 400, height: 400 },
|
||||
{ width: 360, height: 480 },
|
||||
{ width: 480, height: 270 },
|
||||
];
|
||||
|
||||
const TOTAL = 35; // 30(第 1 页)+ 5(第 2 页)
|
||||
|
||||
/** 种「瀑布流相册」:2 分类 + 35 张已发布照片(前 20 张 catA、后 15 张 catB;
|
||||
* publishedAt 逐张递减 60s 保证列表顺序确定)。 */
|
||||
async function seedFeedAlbum(alias: string): Promise<{ albumId: string; token: string }> {
|
||||
const album = await seedAlbum({
|
||||
alias,
|
||||
translations: [{ locale: 'zh-CN', title: '瀑布流测试相册' }],
|
||||
});
|
||||
const catA = await seedCategory(album.id, 0, [{ locale: 'zh-CN', name: '签到墙' }]);
|
||||
const catB = await seedCategory(album.id, 1, [{ locale: 'zh-CN', name: '开场' }]);
|
||||
const base = Date.now();
|
||||
for (let i = 0; i < TOTAL; i++) {
|
||||
const ratio = RATIOS[i % RATIOS.length]!;
|
||||
await seedPhoto(album.id, `${alias}-p${i}`, {
|
||||
categoryId: i < 20 ? catA : catB,
|
||||
width: ratio.width,
|
||||
height: ratio.height,
|
||||
publishedAt: new Date(base - (i + 1) * 60_000),
|
||||
});
|
||||
}
|
||||
return { albumId: album.id, token: album.publicToken };
|
||||
}
|
||||
|
||||
/** 反复滚到底直至游标耗尽(end-note 出现)或循环上限。 */
|
||||
async function scrollToLoadAll(page: Page): Promise<void> {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
if ((await page.locator('.end-note').count()) > 0) return;
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
}
|
||||
|
||||
test('scroll loads both pages until exhausted and thumbs decode', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const { token } = await seedFeedAlbum('scroll');
|
||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||
await page.goto(`/a/${token}`);
|
||||
|
||||
// 第 1 页 30 张 + 哨兵在位(hasMore)。
|
||||
await expect(page.locator('.cell')).toHaveCount(30);
|
||||
await expect(page.locator('.sentinel')).toHaveCount(1);
|
||||
|
||||
// 懒加载:首屏缩略图经签名 URL 取回真实字节(解码成功 naturalWidth > 0)。
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.locator('img.cell-img').first().evaluate((img) => (img as HTMLImageElement).naturalWidth),
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await scrollToLoadAll(page);
|
||||
|
||||
// 第 2 页 5 张追加,游标耗尽 → 哨兵卸载、底部提示出现。
|
||||
await expect(page.locator('.cell')).toHaveCount(35);
|
||||
await expect(page.locator('.end-note')).toHaveText('已经到底了');
|
||||
await expect(page.locator('.sentinel')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('category chips filter the feed; All restores pagination', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const { token } = await seedFeedAlbum('filter');
|
||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||
await page.goto(`/a/${token}`);
|
||||
await expect(page.locator('.cell')).toHaveCount(30);
|
||||
|
||||
await page.getByRole('button', { name: '签到墙' }).click();
|
||||
await expect(page.locator('.cell')).toHaveCount(20);
|
||||
await expect(page.locator('.end-note')).toBeVisible(); // 20 ≤ 30 单页即尽
|
||||
|
||||
await page.getByRole('button', { name: '开场' }).click();
|
||||
await expect(page.locator('.cell')).toHaveCount(15);
|
||||
|
||||
await page.getByRole('button', { name: '全部' }).click();
|
||||
await expect(page.locator('.cell')).toHaveCount(30);
|
||||
await scrollToLoadAll(page);
|
||||
await expect(page.locator('.cell')).toHaveCount(35);
|
||||
});
|
||||
|
||||
test('new-photo poll hint appears; click refreshes to include new photos', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const { albumId, token } = await seedFeedAlbum('poll');
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('viewer.locale', 'zh-CN');
|
||||
(window as unknown as { __viewerPollMs?: number }).__viewerPollMs = 500;
|
||||
});
|
||||
await page.goto(`/a/${token}`);
|
||||
await expect(page.locator('.cell')).toHaveCount(30);
|
||||
await scrollToLoadAll(page);
|
||||
await expect(page.locator('.cell')).toHaveCount(35);
|
||||
await expect(page.locator('.new-photo-bar')).toHaveCount(0);
|
||||
|
||||
// 轮询期间直种 2 张更新的照片(publishedAt 晚于全部既有)。
|
||||
const base = Date.now();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await seedPhoto(albumId, `poll-new-${i}`, {
|
||||
categoryId: null,
|
||||
width: 480,
|
||||
height: 300,
|
||||
publishedAt: new Date(base + (i + 1) * 60_000),
|
||||
});
|
||||
}
|
||||
|
||||
// 提示条出现(不自动插入——仍 35 张)。
|
||||
await expect(page.locator('.new-photo-bar')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('.cell')).toHaveCount(35);
|
||||
|
||||
// 点击 → 整表刷新回第 1 页(含新照片),滚动后 37 张。
|
||||
await page.locator('.new-photo-bar').click();
|
||||
await expect(page.locator('.new-photo-bar')).toHaveCount(0);
|
||||
await scrollToLoadAll(page);
|
||||
await expect(page.locator('.cell')).toHaveCount(37);
|
||||
});
|
||||
+102
-1
@@ -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 },
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"jsdom": "^25.0.0",
|
||||
"sharp": "^0.35.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "~5.4.0",
|
||||
"vitest": "^2.1.0",
|
||||
|
||||
@@ -8,11 +8,16 @@ import ActivityPage from '../pages/ActivityPage.vue';
|
||||
import { router } from '../router';
|
||||
import { makeAlbum, makeEnAlbum } from './fixtures';
|
||||
|
||||
// ActivityPage(Task 6)组件测试:api client 以真实模块为底、仅替身 getPublicAlbum,
|
||||
// ActivityPage(Task 6)组件测试:api client 以真实模块为底、仅替身 getPublicAlbum /
|
||||
// getPublicPhotos(Task 7 起活动页内嵌 PhotoFeed,需喂空页避免真实 fetch),
|
||||
// 保证 ApiError/errors 映射等真实逻辑参与断言。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return { ...actual, getPublicAlbum: vi.fn() };
|
||||
return {
|
||||
...actual,
|
||||
getPublicAlbum: vi.fn(),
|
||||
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
@@ -63,8 +68,8 @@ describe('activity page (Task 6)', () => {
|
||||
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(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 → 不渲染任何计数节点。
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
containsNewPhotoId,
|
||||
layoutWaterfall,
|
||||
mergeUniqueByPhotoId,
|
||||
} from '../feed/layout';
|
||||
|
||||
// Task 7 瀑布流纯函数层:列放置/高度预留/去重/新照片判定(组件行为在 feed.spec)。
|
||||
// 数字断言手算依据:containerWidth 320、gap 8、双列 → columnWidth = floor(312/2) = 156。
|
||||
|
||||
describe('layoutWaterfall (pure placement)', () => {
|
||||
it('places each item in the shorter column with aspect-preserved height', () => {
|
||||
const layout = layoutWaterfall(
|
||||
[
|
||||
{ width: 200, height: 100 }, // A: ratio .5 → 78
|
||||
{ width: 100, height: 200 }, // B: ratio 2 → 312
|
||||
{ width: 156, height: 156 }, // C: ratio 1 → 156
|
||||
{ width: 300, height: 100 }, // D: ratio 1/3 → 52
|
||||
],
|
||||
320,
|
||||
);
|
||||
|
||||
expect(layout.columnWidth).toBe(156);
|
||||
// A→col0(空列同高取索引小);B→col1(0 < 86);C→col0(86 < 320);D→col0(250 < 320)。
|
||||
expect(layout.placements.map((p) => p.column)).toEqual([0, 1, 0, 0]);
|
||||
expect(layout.placements.map((p) => p.top)).toEqual([0, 0, 86, 250]);
|
||||
expect(layout.placements.map((p) => p.height)).toEqual([78, 312, 156, 52]);
|
||||
// 最高列 320 去掉末尾间距 → 容器高 312。
|
||||
expect(layout.height).toBe(312);
|
||||
});
|
||||
|
||||
it('computes column left offsets with the gap and alternates on equal heights', () => {
|
||||
const layout = layoutWaterfall(
|
||||
[
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
{ width: 100, height: 100 },
|
||||
],
|
||||
320,
|
||||
);
|
||||
|
||||
expect(layout.placements.map((p) => p.left)).toEqual([0, 164, 0]);
|
||||
expect(layout.placements.map((p) => p.column)).toEqual([0, 1, 0]);
|
||||
expect(layout.placements.map((p) => p.top)).toEqual([0, 0, 164]);
|
||||
// col0 累计 156+8+156+8=328,col1 156+8=164 → 容器高 328-8=320。
|
||||
expect(layout.height).toBe(320);
|
||||
});
|
||||
|
||||
it('falls back to a square placeholder for degenerate dimensions (no NaN)', () => {
|
||||
const layout = layoutWaterfall([{ width: 0, height: 500 }, { width: 400, height: 0 }], 320);
|
||||
expect(layout.placements.map((p) => p.height)).toEqual([156, 156]);
|
||||
expect(layout.placements.every((p) => Number.isFinite(p.top) && Number.isFinite(p.left))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty layout for no items and tolerates zero container width', () => {
|
||||
const empty = layoutWaterfall([], 320);
|
||||
expect(empty.placements).toEqual([]);
|
||||
expect(empty.height).toBe(0);
|
||||
|
||||
const zero = layoutWaterfall([{ width: 100, height: 100 }], 0);
|
||||
expect(zero.columnWidth).toBe(0);
|
||||
expect(zero.placements.map((p) => p.height)).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeUniqueByPhotoId (page append dedupe)', () => {
|
||||
it('appends only unseen photoIds and preserves order', () => {
|
||||
const merged = mergeUniqueByPhotoId(
|
||||
[{ photoId: 'a' }, { photoId: 'b' }],
|
||||
[{ photoId: 'b' }, { photoId: 'c' }, { photoId: 'a' }, { photoId: 'c' }],
|
||||
);
|
||||
expect(merged.map((item) => item.photoId)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('keeps the existing entry when a duplicate id arrives', () => {
|
||||
const existing = [{ photoId: 'a', width: 1 }];
|
||||
const merged = mergeUniqueByPhotoId(existing, [{ photoId: 'a', width: 2 }]);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].width).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('containsNewPhotoId (poll detection)', () => {
|
||||
it('detects ids outside the known set and returns false when all known', () => {
|
||||
expect(containsNewPhotoId(['a', 'b'], [{ photoId: 'b' }, { photoId: 'c' }])).toBe(true);
|
||||
expect(containsNewPhotoId(['a', 'b'], [{ photoId: 'b' }, { photoId: 'a' }])).toBe(false);
|
||||
// 已知集为空(空相册)时任何已发布照片都是「新照片」。
|
||||
expect(containsNewPhotoId([], [{ photoId: 'a' }])).toBe(true);
|
||||
expect(containsNewPhotoId(['a'], [])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApiError,
|
||||
getPublicPhoto,
|
||||
getPublicPhotos,
|
||||
type PublicPhoto,
|
||||
type PublicPhotoList,
|
||||
} from '../api/client';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import PhotoFeed from '../components/PhotoFeed.vue';
|
||||
|
||||
// PhotoFeed(Task 7)组件测试:api client 以真实模块为底、替身 getPublicPhotos /
|
||||
// getPublicPhoto;IntersectionObserver / ResizeObserver 以可控假件替换(jsdom 无实现),
|
||||
// clientWidth 以原型 getter mock(jsdom 恒 0)。布局纯函数已在 feed-layout.spec 覆盖。
|
||||
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return { ...actual, getPublicPhotos: vi.fn(), getPublicPhoto: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
||||
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
|
||||
|
||||
/** 可控 IO 假件:记录实例与 init(rootMargin 区分懒加载 400px / 哨兵 600px)。 */
|
||||
class FakeIntersectionObserver {
|
||||
static instances: FakeIntersectionObserver[] = [];
|
||||
readonly root: Element | Document | null = null;
|
||||
readonly thresholds: ReadonlyArray<number> = [];
|
||||
private readonly targets = new Set<Element>();
|
||||
|
||||
constructor(
|
||||
readonly callback: IntersectionObserverCallback,
|
||||
readonly init: IntersectionObserverInit = {},
|
||||
) {
|
||||
FakeIntersectionObserver.instances.push(this);
|
||||
}
|
||||
|
||||
get rootMargin(): string {
|
||||
return typeof this.init.rootMargin === 'string' ? this.init.rootMargin : '';
|
||||
}
|
||||
|
||||
get observed(): Element[] {
|
||||
return [...this.targets];
|
||||
}
|
||||
|
||||
observe(target: Element): void {
|
||||
this.targets.add(target);
|
||||
}
|
||||
|
||||
unobserve(target: Element): void {
|
||||
this.targets.delete(target);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.targets.clear();
|
||||
}
|
||||
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
trigger(targets: Element[], isIntersecting: boolean): void {
|
||||
this.callback(
|
||||
targets.map((target) => ({ target, isIntersecting }) as IntersectionObserverEntry),
|
||||
this as unknown as IntersectionObserver,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeResizeObserver {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
function lazyObserver(): FakeIntersectionObserver {
|
||||
const observer = FakeIntersectionObserver.instances.find((instance) =>
|
||||
instance.rootMargin.includes('400px'),
|
||||
);
|
||||
if (observer === undefined) throw new Error('lazy observer not created');
|
||||
return observer;
|
||||
}
|
||||
|
||||
function sentinelObserver(): FakeIntersectionObserver {
|
||||
const observer = FakeIntersectionObserver.instances.find((instance) =>
|
||||
instance.rootMargin.includes('600px'),
|
||||
);
|
||||
if (observer === undefined) throw new Error('sentinel observer not created');
|
||||
return observer;
|
||||
}
|
||||
|
||||
function makePhoto(id: string, width = 800, height = 500): PublicPhoto {
|
||||
return {
|
||||
photoId: id,
|
||||
width,
|
||||
height,
|
||||
thumb: { url: `http://img.test/${id}-thumb.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' },
|
||||
browse: { url: `http://img.test/${id}-browse.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' },
|
||||
likeCount: 0,
|
||||
publishedAt: '2026-10-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function makeList(items: PublicPhoto[], nextCursor: string | null = null): PublicPhotoList {
|
||||
return { items, nextCursor, hasMore: nextCursor !== null };
|
||||
}
|
||||
|
||||
let i18n: ViewerI18n;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
function mountFeed(props: { token?: string; locale?: string; category?: string | null } = {}): VueWrapper {
|
||||
const wrapper = mount(PhotoFeed, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
token: props.token ?? 'tok-feed',
|
||||
locale: props.locale ?? 'zh-CN',
|
||||
category: props.category ?? null,
|
||||
},
|
||||
});
|
||||
mounted.push(wrapper);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe('photo feed (Task 7)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
FakeIntersectionObserver.instances = [];
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
|
||||
// jsdom 无布局:clientWidth 恒 0 → 原型 getter mock 为 700(columnWidth=346)。
|
||||
const proto = Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'clientWidth')
|
||||
? HTMLElement.prototype
|
||||
: Element.prototype;
|
||||
vi.spyOn(proto, 'clientWidth', 'get').mockReturnValue(700);
|
||||
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')], 'cur-1'));
|
||||
mockedGetPublicPhoto.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
mounted.pop()?.unmount();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders first page as absolute-positioned cells with reserved heights', async () => {
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-feed', { locale: 'zh-CN' });
|
||||
const cells = wrapper.findAll('.cell');
|
||||
expect(cells).toHaveLength(3);
|
||||
// 700 容器宽 → columnWidth 346;800x500 比例 → 条高 round(346*.625)=216。
|
||||
// 3 条:col0 [224,0] → p2 入 col1 → p3 入 col0(top 224)→ 容器高 224+216=440。
|
||||
expect(wrapper.find('.waterfall').attributes('style')).toContain('height: 440px');
|
||||
expect(cells[1].attributes('style')).toContain('left: 354px');
|
||||
expect(cells[2].attributes('style')).toContain('top: 224px');
|
||||
});
|
||||
|
||||
it('binds img src only after the cell approaches the viewport (lazy swap)', async () => {
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findAll('img.cell-img')).toHaveLength(0);
|
||||
const cells = wrapper.findAll('.cell');
|
||||
expect(lazyObserver().observed).toHaveLength(3);
|
||||
|
||||
lazyObserver().trigger([cells[0].element, cells[1].element], true);
|
||||
await vi.waitFor(() => expect(wrapper.findAll('img.cell-img')).toHaveLength(2));
|
||||
expect(wrapper.find('img.cell-img').attributes('src')).toBe('http://img.test/p1-thumb.jpg?s=1');
|
||||
// 已显示的单元格不再被观察。
|
||||
expect(lazyObserver().observed).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('loads the next page when the sentinel intersects, dedupes and stops at exhaustion', async () => {
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
const sentinel = wrapper.find('.sentinel');
|
||||
expect(sentinel.exists()).toBe(true);
|
||||
// 第 2 页带回重复的 p3(游标边界漂移防御)→ 仅追加新 id。
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p3'), makePhoto('p4')], null));
|
||||
|
||||
sentinelObserver().trigger([sentinel.element], true);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
|
||||
'tok-feed',
|
||||
expect.objectContaining({ cursor: 'cur-1' }),
|
||||
);
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(4);
|
||||
// 游标耗尽:哨兵卸载 + 底部提示。
|
||||
expect(wrapper.find('.sentinel').exists()).toBe(false);
|
||||
expect(wrapper.find('.end-note').text()).toBe('已经到底了');
|
||||
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not fire a second page fetch while one is in flight', async () => {
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockClear();
|
||||
|
||||
let resolvePage!: (value: PublicPhotoList) => void;
|
||||
mockedGetPublicPhotos.mockReturnValueOnce(
|
||||
new Promise<PublicPhotoList>((resolve) => {
|
||||
resolvePage = resolve;
|
||||
}),
|
||||
);
|
||||
const sentinel = wrapper.find('.sentinel');
|
||||
sentinelObserver().trigger([sentinel.element], true);
|
||||
sentinelObserver().trigger([sentinel.element], true);
|
||||
await flushPromises();
|
||||
expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolvePage(makeList([makePhoto('p4')], null));
|
||||
await flushPromises();
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('switching category resets the list and passes the filter through', async () => {
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('c1p1'), makePhoto('c1p2')], null));
|
||||
|
||||
await wrapper.setProps({ category: 'cat-1' });
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
|
||||
'tok-feed',
|
||||
expect.objectContaining({ category: 'cat-1', locale: 'zh-CN' }),
|
||||
);
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('locale switch re-fetches the current category (filter preserved)', async () => {
|
||||
const wrapper = mountFeed({ category: 'cat-1' });
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.setProps({ locale: 'en' });
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
|
||||
'tok-feed',
|
||||
expect.objectContaining({ locale: 'en', category: 'cat-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the localized empty state when the album has no photos', async () => {
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([]));
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线');
|
||||
expect(wrapper.find('.cell').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('initial load failure shows the localized error with retry; retry recovers', async () => {
|
||||
mockedGetPublicPhotos
|
||||
.mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network down', 0, null))
|
||||
.mockResolvedValueOnce(makeList([makePhoto('p1')], null));
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.error[role="alert"]').text()).toContain('网络连接失败');
|
||||
expect(wrapper.find('.cell').exists()).toBe(false);
|
||||
|
||||
await wrapper.find('.error button').trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('retries a failed thumb once with a refreshed signed URL, then hides the img', async () => {
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
lazyObserver().trigger(wrapper.findAll('.cell').map((cell) => cell.element), true);
|
||||
await vi.waitFor(() => expect(wrapper.findAll('img.cell-img')).toHaveLength(3));
|
||||
|
||||
mockedGetPublicPhoto.mockResolvedValue({
|
||||
...makePhoto('p1'),
|
||||
categoryId: null,
|
||||
thumb: { url: 'http://img.test/p1-thumb.jpg?s=2', expiresAt: '2026-10-01T01:00:00.000Z' },
|
||||
});
|
||||
await wrapper.findAll('img.cell-img')[0].trigger('error');
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-feed', 'p1');
|
||||
expect(wrapper.findAll('img.cell-img')[0].attributes('src')).toBe('http://img.test/p1-thumb.jpg?s=2');
|
||||
|
||||
// 第二次失败 → 收起该图(占位底色仍在,布局不塌)。
|
||||
await wrapper.findAll('img.cell-img')[0].trigger('error');
|
||||
await flushPromises();
|
||||
expect(wrapper.findAll('img.cell-img')).toHaveLength(2);
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(3);
|
||||
expect(mockedGetPublicPhoto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('photo feed polling (Task 7)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
FakeIntersectionObserver.instances = [];
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
|
||||
const proto = Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'clientWidth')
|
||||
? HTMLElement.prototype
|
||||
: Element.prototype;
|
||||
vi.spyOn(proto, 'clientWidth', 'get').mockReturnValue(700);
|
||||
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], null));
|
||||
mockedGetPublicPhoto.mockReset();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
mounted.pop()?.unmount();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('poll detects new photos, shows the hint bar; click refreshes to page 1, clears the hint and scrolls top', async () => {
|
||||
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
|
||||
|
||||
// 轮询取到的第 1 页含未知 id p9 → 只亮提示条,不自动插入。
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1'), makePhoto('p2')], null));
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.new-photo-bar').text()).toBe('有新照片,点击查看');
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(2);
|
||||
|
||||
// 点击提示条:整表刷新(第 1 页含新照片)并回顶。
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1'), makePhoto('p2')], null));
|
||||
await wrapper.find('.new-photo-bar').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(3);
|
||||
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
|
||||
expect(scrollTo).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
|
||||
it('keeps polling silent on failure (no error state, no hint)', async () => {
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
mockedGetPublicPhotos.mockRejectedValueOnce(new ApiError('INTERNAL_ERROR', 'boom', 500, 'req-1'));
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.error').exists()).toBe(false);
|
||||
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('pauses polling while the tab is hidden and resumes when visible again', async () => {
|
||||
mountFeed();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockClear();
|
||||
|
||||
const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
vi.advanceTimersByTime(120_000);
|
||||
await flushPromises();
|
||||
expect(mockedGetPublicPhotos).not.toHaveBeenCalled();
|
||||
|
||||
hidden.mockReturnValue(false);
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await flushPromises();
|
||||
expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,14 @@ import { makeAlbum } from './fixtures';
|
||||
|
||||
// 路由解析(Task 5 脚手架测试对齐 Task 6 真实活动页):token/photoId 参数经
|
||||
// api mock 断言——活动页以 token 取数,photoId 供 Task 8 深链定位(不影响取数)。
|
||||
// getPublicPhotos 一并替身为空页(Task 7 起活动页内嵌 PhotoFeed)。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return { ...actual, getPublicAlbum: vi.fn() };
|
||||
return {
|
||||
...actual,
|
||||
getPublicAlbum: vi.fn(),
|
||||
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { components } from '@photo/contracts';
|
||||
|
||||
// 契约生成类型直连(Vite 原生 ESM,无需 mapper)
|
||||
export type PublicAlbum = components['schemas']['PublicAlbum'];
|
||||
export type PublicPhoto = components['schemas']['PublicPhoto'];
|
||||
export type PublicPhotoList = components['schemas']['PublicPhotoList'];
|
||||
export type PublicPhotoDetail = components['schemas']['PublicPhotoDetail'];
|
||||
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
ApiError,
|
||||
NETWORK_ERROR_CODE,
|
||||
getPublicPhoto,
|
||||
getPublicPhotos,
|
||||
type PublicPhoto,
|
||||
type PublicPhotoList,
|
||||
} from '../api/client';
|
||||
import { errorMessageKey } from '../api/errors';
|
||||
import { containsNewPhotoId, layoutWaterfall, mergeUniqueByPhotoId } from '../feed/layout';
|
||||
|
||||
// 观众瀑布流(Task 7):双列绝对定位(短列优先,条目高按 browse 宽高比例预留)
|
||||
// + IntersectionObserver 懒加载 + 游标无限滚动 + 分类筛选 + 新照片轮询提示。
|
||||
//
|
||||
// 关键决策:
|
||||
// · 懒加载 = 仅当单元格接近视口才绑定 <img src>(等价 data-src → src 换绑);
|
||||
// shownIds 集合跨刷新保留——刷新只换签名 URL,同 id 单元格不必重新判定;
|
||||
// IO 不可用(旧环境/jsdom)时退化为立即加载,功能不缺失。
|
||||
// · 图片 onerror 重试一次:调单图端点换新签名 URL 再换绑;再失败则收起该图
|
||||
// (占位底色仍在,布局因高度预留不跳动)。
|
||||
// · 轮询(30s,e2e 可用 window.__viewerPollMs 覆盖)只取第 1 页对比 id 集合,
|
||||
// 命中新照片只亮提示条——不自动插入(避免滚动位置跳动,需求 1.2);
|
||||
// 点击提示条 = 整表刷新回第 1 页并回顶。标签页隐藏暂停(R26 同精神)。
|
||||
// · 竞态防护:feedSequence 令牌——分类/语言/token 切换或刷新会使在途的
|
||||
// 分页/轮询结果作废;轮询失败静默(网络抖动不应打扰观众)。
|
||||
|
||||
const props = defineProps<{
|
||||
token: string;
|
||||
locale: string;
|
||||
/** 当前分类筛选(null = 全部)。由活动页 chips 持有,切换即重置列表。 */
|
||||
category: string | null;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const photos = ref<PublicPhoto[]>([]);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMore = ref(false);
|
||||
const initialLoading = ref(true);
|
||||
const initialErrorKey = ref<string | null>(null);
|
||||
const loadingMore = ref(false);
|
||||
const moreFailed = ref(false);
|
||||
const hasNew = ref(false);
|
||||
|
||||
// 懒加载/重试视图状态(按 photoId,跨刷新保留,见顶部决策注)。
|
||||
const shownIds = reactive(new Set<string>());
|
||||
const retriedIds = reactive(new Set<string>());
|
||||
const failedIds = reactive(new Set<string>());
|
||||
|
||||
let feedSequence = 0;
|
||||
|
||||
function fetchPage(cursor?: string): Promise<PublicPhotoList> {
|
||||
return getPublicPhotos(props.token, {
|
||||
locale: props.locale,
|
||||
category: props.category ?? undefined,
|
||||
cursor,
|
||||
});
|
||||
}
|
||||
|
||||
/** 整表重置加载(首载/分类切换/语言切换/token 切换/新照片刷新共用)。 */
|
||||
async function resetAndLoad(): Promise<void> {
|
||||
const sequence = ++feedSequence;
|
||||
initialLoading.value = true;
|
||||
initialErrorKey.value = null;
|
||||
moreFailed.value = false;
|
||||
loadingMore.value = false;
|
||||
hasNew.value = false;
|
||||
photos.value = [];
|
||||
nextCursor.value = null;
|
||||
hasMore.value = false;
|
||||
pruneCellElements();
|
||||
try {
|
||||
const page = await fetchPage();
|
||||
if (sequence !== feedSequence) return;
|
||||
photos.value = [...page.items];
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMore.value = page.hasMore && page.nextCursor !== null;
|
||||
} catch (error) {
|
||||
if (sequence !== feedSequence) return;
|
||||
const code = error instanceof ApiError ? error.code : NETWORK_ERROR_CODE;
|
||||
initialErrorKey.value = errorMessageKey(code);
|
||||
} finally {
|
||||
if (sequence === feedSequence) initialLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 无限滚动:哨兵进入视口(600px 预载)时取下一页;去重防御性合并。 */
|
||||
async function loadMore(): Promise<void> {
|
||||
if (initialLoading.value || initialErrorKey.value !== null) return;
|
||||
if (loadingMore.value || !hasMore.value || nextCursor.value === null) return;
|
||||
const sequence = feedSequence;
|
||||
loadingMore.value = true;
|
||||
moreFailed.value = false;
|
||||
try {
|
||||
const page = await fetchPage(nextCursor.value);
|
||||
if (sequence !== feedSequence) return;
|
||||
photos.value = mergeUniqueByPhotoId(photos.value, page.items);
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMore.value = page.hasMore && page.nextCursor !== null;
|
||||
pruneCellElements();
|
||||
} catch {
|
||||
if (sequence === feedSequence) moreFailed.value = true;
|
||||
} finally {
|
||||
if (sequence === feedSequence) loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 新照片提示条点击:整表刷新回第 1 页并回顶(提示消失)。 */
|
||||
async function refreshFromHint(): Promise<void> {
|
||||
if (initialLoading.value) return;
|
||||
await resetAndLoad();
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
// —— 布局:容器宽 → 纯函数双列放置(ResizeObserver 跟踪容器宽度变化) ——
|
||||
|
||||
const trackEl = ref<HTMLElement | null>(null);
|
||||
const sentinelEl = ref<HTMLElement | null>(null);
|
||||
const containerWidth = ref(0);
|
||||
|
||||
const layout = computed(() => layoutWaterfall(photos.value, containerWidth.value));
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
function measureContainer(): void {
|
||||
if (trackEl.value !== null) containerWidth.value = trackEl.value.clientWidth;
|
||||
}
|
||||
|
||||
watch(
|
||||
trackEl,
|
||||
(el) => {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
if (el === null) return;
|
||||
measureContainer();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(measureContainer);
|
||||
resizeObserver.observe(el);
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
// —— 懒加载/哨兵 IntersectionObserver ——
|
||||
|
||||
const cellElements = new Map<Element, string>();
|
||||
let lazyObserver: IntersectionObserver | null = null;
|
||||
let sentinelObserver: IntersectionObserver | null = null;
|
||||
|
||||
function ensureLazyObserver(): IntersectionObserver | null {
|
||||
if (lazyObserver !== null) return lazyObserver;
|
||||
if (typeof IntersectionObserver === 'undefined') return null;
|
||||
lazyObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const photoId = cellElements.get(entry.target);
|
||||
if (photoId !== undefined) shownIds.add(photoId);
|
||||
lazyObserver?.unobserve(entry.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '400px 0px' },
|
||||
);
|
||||
return lazyObserver;
|
||||
}
|
||||
|
||||
/** v-for 函数 ref:登记 el → photoId 并纳入懒观察;IO 不可用退化立即显示。
|
||||
* 按下标取 photos(卸载时 ref 收到 null 先行返回——函数 ref 在整表替换后
|
||||
* 会对旧 vnode 重算,此时闭包里的数组访问必须可空)。 */
|
||||
function registerCell(el: unknown, index: number): void {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const photo = photos.value[index];
|
||||
if (photo === undefined) return;
|
||||
cellElements.set(el, photo.photoId);
|
||||
if (shownIds.has(photo.photoId)) return;
|
||||
const observer = ensureLazyObserver();
|
||||
if (observer === null) {
|
||||
shownIds.add(photo.photoId);
|
||||
return;
|
||||
}
|
||||
observer.observe(el);
|
||||
}
|
||||
|
||||
/** 移除已脱离文档的单元格映射(整表替换后 DOM 销毁的 el 不再可达)。 */
|
||||
function pruneCellElements(): void {
|
||||
for (const [el] of cellElements) {
|
||||
if (!el.isConnected) cellElements.delete(el);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
sentinelEl,
|
||||
(el) => {
|
||||
sentinelObserver?.disconnect();
|
||||
sentinelObserver = null;
|
||||
if (el === null) return;
|
||||
sentinelObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) void loadMore();
|
||||
},
|
||||
{ rootMargin: '600px 0px' },
|
||||
);
|
||||
sentinelObserver.observe(el);
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
/** 缩略图加载失败:重试一次(单图端点换新签名 URL);再失败收起图片。 */
|
||||
async function onImgError(photo: PublicPhoto): Promise<void> {
|
||||
const { photoId } = photo;
|
||||
if (failedIds.has(photoId)) return;
|
||||
if (retriedIds.has(photoId)) {
|
||||
failedIds.add(photoId);
|
||||
return;
|
||||
}
|
||||
retriedIds.add(photoId);
|
||||
try {
|
||||
const detail = await getPublicPhoto(props.token, photoId);
|
||||
photo.thumb = detail.thumb;
|
||||
photo.browse = detail.browse;
|
||||
} catch {
|
||||
failedIds.add(photoId);
|
||||
}
|
||||
}
|
||||
|
||||
// —— 新照片轮询 ——
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
/** e2e/开发注入点:window.__viewerPollMs 覆盖轮询间隔(仅测试加速用)。 */
|
||||
function pollIntervalMs(): number {
|
||||
const override = (globalThis as { __viewerPollMs?: unknown }).__viewerPollMs;
|
||||
return typeof override === 'number' && override > 0 ? override : DEFAULT_POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer !== undefined) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
stopPolling();
|
||||
pollTimer = setInterval(() => void pollOnce(), pollIntervalMs());
|
||||
}
|
||||
|
||||
/** 取当前筛选的第 1 页对比 id 集合;发现新照片只亮提示条(不自动插入)。失败静默。 */
|
||||
async function pollOnce(): Promise<void> {
|
||||
if (
|
||||
document.hidden ||
|
||||
initialLoading.value ||
|
||||
initialErrorKey.value !== null ||
|
||||
hasNew.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const sequence = feedSequence;
|
||||
try {
|
||||
const page = await fetchPage();
|
||||
if (sequence !== feedSequence) return;
|
||||
if (containsNewPhotoId(photos.value.map((photo) => photo.photoId), page.items)) {
|
||||
hasNew.value = true;
|
||||
}
|
||||
} catch {
|
||||
// 轮询失败静默:网络抖动不打扰观众(下一次间隔重试)。
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
if (document.hidden) stopPolling();
|
||||
else startPolling();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void resetAndLoad();
|
||||
startPolling();
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling();
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
lazyObserver?.disconnect();
|
||||
sentinelObserver?.disconnect();
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
|
||||
// 分类/语言/token 变化:整表重置(语言切换保留当前分类——由 props 自带)。
|
||||
watch(
|
||||
[() => props.token, () => props.locale, () => props.category],
|
||||
() => void resetAndLoad(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="photo-feed">
|
||||
<button v-if="hasNew" type="button" class="new-photo-bar" @click="refreshFromHint">
|
||||
{{ t('album.newPhotos') }}
|
||||
</button>
|
||||
|
||||
<p v-if="initialLoading" class="status">{{ t('album.loading') }}</p>
|
||||
|
||||
<div v-else-if="initialErrorKey !== null" class="error" role="alert">
|
||||
<p>{{ t(initialErrorKey) }}</p>
|
||||
<button type="button" @click="resetAndLoad">{{ t('album.retry') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="photos.length === 0" class="feed">
|
||||
<p class="empty-notice">{{ t('album.emptyNotice') }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div ref="trackEl" class="waterfall" :style="{ height: `${layout.height}px` }">
|
||||
<div
|
||||
v-for="placement in layout.placements"
|
||||
:key="photos[placement.index].photoId"
|
||||
:ref="(el) => registerCell(el, placement.index)"
|
||||
class="cell"
|
||||
:style="{
|
||||
left: `${placement.left}px`,
|
||||
top: `${placement.top}px`,
|
||||
width: `${layout.columnWidth}px`,
|
||||
height: `${placement.height}px`,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="shownIds.has(photos[placement.index].photoId) && !failedIds.has(photos[placement.index].photoId)"
|
||||
class="cell-img"
|
||||
:src="photos[placement.index].thumb.url"
|
||||
:alt="`${t('route.photoLabel')} ${placement.index + 1}`"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="onImgError(photos[placement.index])"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="loadingMore" class="status more-status">{{ t('album.loadingMore') }}</p>
|
||||
<div v-else-if="moreFailed" class="more-failed">
|
||||
<button type="button" @click="loadMore">{{ t('album.retry') }}</button>
|
||||
</div>
|
||||
<p v-else-if="!hasMore" class="end-note">{{ t('album.end') }}</p>
|
||||
|
||||
<div v-if="hasMore" ref="sentinelEl" class="sentinel" aria-hidden="true" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 极简基线样式:视觉打磨留待 P4a。 */
|
||||
.photo-feed {
|
||||
position: relative;
|
||||
}
|
||||
.new-photo-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #1d4ed8;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.status {
|
||||
color: #6b7280;
|
||||
}
|
||||
.more-status {
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.error button,
|
||||
.more-failed button {
|
||||
margin-top: 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.more-failed {
|
||||
text-align: center;
|
||||
}
|
||||
.feed {
|
||||
border: 1px dashed #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-notice {
|
||||
margin: 0;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.waterfall {
|
||||
position: relative;
|
||||
}
|
||||
.cell {
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(160deg, #f3f4f6, #e5e7eb);
|
||||
}
|
||||
.cell-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.end-note {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
padding: 12px 0;
|
||||
margin: 0;
|
||||
}
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
// apps/viewer/src/feed/layout.ts —— 瀑布流纯函数层(Task 7):双列绝对定位布局
|
||||
// 与列表纯数据操作。抽出为无 DOM 依赖的模块以便 vitest 直接覆盖放置/去重逻辑。
|
||||
//
|
||||
// 布局模型(选型见计划 Task 7 Step 1:绝对定位而非 CSS columns——滚动位置可
|
||||
// 恢复、条目高按 width/height 比例预留):
|
||||
// · 列宽 = (容器宽 - (列数-1)*列间距) / 列数,向下取整;
|
||||
// · 条目高 = round(列宽 * height/width)(宽高来自 browse 变体,服务端必返);
|
||||
// 宽高非正的脏数据回退正方形容高(不产生 NaN/0 塌陷);
|
||||
// · 放置规则:总是放入当前「最矮」列,同高取索引小者(确定性输出);
|
||||
// · 容器高 = 最高列去掉末尾间距(间距只在条目之间生效)。
|
||||
|
||||
/** 布局输入:仅需宽高(PublicPhoto 满足)。 */
|
||||
export interface WaterfallSource {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** 单条放置结果:index 对应输入数组下标,left/top/height 为像素值。 */
|
||||
export interface WaterfallPlacement {
|
||||
index: number;
|
||||
column: number;
|
||||
left: number;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface WaterfallLayout {
|
||||
placements: WaterfallPlacement[];
|
||||
/** 单列宽度(所有列等宽)。 */
|
||||
columnWidth: number;
|
||||
/** 容器内容总高(px)。 */
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const WATERFALL_COLUMNS = 2;
|
||||
export const WATERFALL_GAP = 8;
|
||||
|
||||
/**
|
||||
* 双列(可推广 N 列)瀑布流布局:逐条放入最矮列。O(n) 全量重算——分页追加
|
||||
* 也整体重算(n 为已加载条数,P1 量级下开销可忽略),换来 resize/刷新的确定性。
|
||||
*/
|
||||
export function layoutWaterfall(
|
||||
items: readonly WaterfallSource[],
|
||||
containerWidth: number,
|
||||
options: { columns?: number; gap?: number } = {},
|
||||
): WaterfallLayout {
|
||||
const columns = Math.max(1, options.columns ?? WATERFALL_COLUMNS);
|
||||
const gap = Math.max(0, options.gap ?? WATERFALL_GAP);
|
||||
const columnWidth =
|
||||
containerWidth > 0
|
||||
? Math.floor((containerWidth - gap * (columns - 1)) / columns)
|
||||
: 0;
|
||||
|
||||
const columnHeights = new Array<number>(columns).fill(0);
|
||||
const placements: WaterfallPlacement[] = [];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const ratio =
|
||||
item.width > 0 && item.height > 0 ? item.height / item.width : 1;
|
||||
const height = Math.max(1, Math.round(columnWidth * ratio));
|
||||
let column = 0;
|
||||
for (let c = 1; c < columns; c += 1) {
|
||||
if (columnHeights[c] < columnHeights[column]) column = c;
|
||||
}
|
||||
placements.push({
|
||||
index,
|
||||
column,
|
||||
left: column * (columnWidth + gap),
|
||||
top: columnHeights[column],
|
||||
height,
|
||||
});
|
||||
columnHeights[column] += height + gap;
|
||||
});
|
||||
|
||||
const height =
|
||||
placements.length > 0 ? Math.max(...columnHeights) - gap : 0;
|
||||
return { placements, columnWidth, height };
|
||||
}
|
||||
|
||||
/** 追加分页结果时按 photoId 去重(防御:游标边界漂移、轮询与分页竞争)。 */
|
||||
export function mergeUniqueByPhotoId<T extends { photoId: string }>(
|
||||
existing: readonly T[],
|
||||
incoming: readonly T[],
|
||||
): T[] {
|
||||
const seen = new Set(existing.map((item) => item.photoId));
|
||||
const merged = [...existing];
|
||||
for (const item of incoming) {
|
||||
if (seen.has(item.photoId)) continue;
|
||||
seen.add(item.photoId);
|
||||
merged.push(item);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** 轮询判定:incoming 中是否存在 known 集合之外的新照片。 */
|
||||
export function containsNewPhotoId(
|
||||
knownIds: Iterable<string>,
|
||||
incoming: readonly { photoId: string }[],
|
||||
): boolean {
|
||||
const known = new Set(knownIds);
|
||||
return incoming.some((item) => !known.has(item.photoId));
|
||||
}
|
||||
@@ -3,10 +3,14 @@ export default {
|
||||
home: { hint: 'Open an album link to start viewing' },
|
||||
album: {
|
||||
loading: 'Loading…',
|
||||
loadingMore: 'Loading more…',
|
||||
emptyNotice: 'Photos coming soon',
|
||||
time: 'Time',
|
||||
venue: 'Venue',
|
||||
categories: 'Categories',
|
||||
all: 'All',
|
||||
end: 'You have reached the end',
|
||||
newPhotos: 'New photos — tap to refresh',
|
||||
retry: 'Retry',
|
||||
},
|
||||
lang: { switch: 'Switch language' },
|
||||
|
||||
@@ -3,10 +3,14 @@ export default {
|
||||
home: { hint: '打开相册链接即可观看' },
|
||||
album: {
|
||||
loading: '加载中…',
|
||||
loadingMore: '加载更多…',
|
||||
emptyNotice: '照片即将上线',
|
||||
time: '时间',
|
||||
venue: '地点',
|
||||
categories: '分类',
|
||||
all: '全部',
|
||||
end: '已经到底了',
|
||||
newPhotos: '有新照片,点击查看',
|
||||
retry: '重试',
|
||||
},
|
||||
lang: { switch: '切换语言' },
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
import PhotoFeed from '../components/PhotoFeed.vue';
|
||||
|
||||
// 观众活动页(P1b Task 6):信息区 + 空态 + 时区展示;瀑布流(Task 7)与大图
|
||||
// (Task 8,复用本组件于 /a/:token/p/:photoId 深链)随后补充。
|
||||
@@ -92,6 +93,14 @@ const venueText = computed(() => {
|
||||
.filter((part): part is string => part !== null)
|
||||
.join(' · ');
|
||||
});
|
||||
|
||||
// 分类筛选(Task 7):id 级持有(语言切换只换名称,选中态跨语言保留——
|
||||
// PhotoFeed 收到同一 category 值不重置,locale 变化才整表重取)。
|
||||
const selectedCategory = ref<string | null>(null);
|
||||
|
||||
function selectCategory(categoryId: string | null): void {
|
||||
selectedCategory.value = categoryId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -127,17 +136,32 @@ const venueText = computed(() => {
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
<!-- Task 7 接线分类过滤;当前仅展示(禁用态占位)。 -->
|
||||
<!-- 分类筛选 chips:全部 + 各分类;选中态高亮(aria-pressed)。 -->
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ active: selectedCategory === null }"
|
||||
:aria-pressed="selectedCategory === null"
|
||||
@click="selectCategory(null)"
|
||||
>
|
||||
{{ t('album.all') }}
|
||||
</button>
|
||||
<button
|
||||
v-for="category in album.categories"
|
||||
:key="category.id"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ active: selectedCategory === category.id }"
|
||||
:aria-pressed="selectedCategory === category.id"
|
||||
@click="selectCategory(category.id)"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 瀑布流(Task 7)前的列表占位:空态文案由 i18n 提供。 -->
|
||||
<div class="feed">
|
||||
<p class="empty-notice">{{ t('album.emptyNotice') }}</p>
|
||||
</div>
|
||||
<!-- 瀑布流(Task 7):懒加载/无限滚动/轮询提示在 PhotoFeed 内实现。 -->
|
||||
<PhotoFeed :token="token" :locale="locale" :category="selectedCategory" />
|
||||
<!-- viewCount 契约占位 null:不渲染任何浏览计数节点。 -->
|
||||
</template>
|
||||
</section>
|
||||
@@ -207,18 +231,11 @@ const venueText = computed(() => {
|
||||
color: #374151;
|
||||
padding: 2px 12px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.feed {
|
||||
border: 1px dashed #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-notice {
|
||||
margin: 0;
|
||||
color: #9ca3af;
|
||||
.chip.active {
|
||||
border-color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
</style>
|
||||
|
||||
Generated
+3
@@ -72,6 +72,9 @@ importers:
|
||||
jsdom:
|
||||
specifier: ^25.0.0
|
||||
version: 25.0.1
|
||||
sharp:
|
||||
specifier: ^0.35.0
|
||||
version: 0.35.4(@types/node@22.20.3)
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.9.3
|
||||
|
||||
Reference in New Issue
Block a user