feat(viewer): photo viewer with zoom, navigation and deep link
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -198,6 +198,13 @@ export async function seedPhoto(
|
|||||||
return photoId;
|
return photoId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 直删照片域行(Task 8 e2e:模拟已分享深链的照片下线——变体行在前、主行在后;
|
||||||
|
* MinIO 对象随 dev 前缀清理约定,不在此删)。 */
|
||||||
|
export async function deleteSeededPhoto(photoId: string): Promise<void> {
|
||||||
|
await prisma.photo_variants.deleteMany({ where: { photoId } });
|
||||||
|
await prisma.photos.deleteMany({ where: { id: photoId } });
|
||||||
|
}
|
||||||
|
|
||||||
/** 清理本文件种下的相册域行(扁平字符串外键、无 DB 级联——按子表在前手工删;
|
/** 清理本文件种下的相册域行(扁平字符串外键、无 DB 级联——按子表在前手工删;
|
||||||
* category_translations 只挂 categoryId,先取分类 id 再删翻译;Task 7 起照片域
|
* category_translations 只挂 categoryId,先取分类 id 再删翻译;Task 7 起照片域
|
||||||
* 同理:先按相册取照片 id 删变体行,再删 photos 主行)。
|
* 同理:先按相册取照片 id 删变体行,再删 photos 主行)。
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { cleanupSeededAlbums, deleteSeededPhoto, seedAlbum, seedPhoto } from './helpers';
|
||||||
|
|
||||||
|
// Task 8 大图查看器 Playwright(真实后端):点击开大图(browse 经签名 URL 取回
|
||||||
|
// 真实字节)→ 左右切换保持列表序列 → 滚轮缩放(受控 transform)→ 关闭回原滚动
|
||||||
|
// 位置 → 深链直达(单图先行 + 背后列表构成序列)→ 404 深链本地化提示 → 越过
|
||||||
|
// 末页边界的导航扩展序列。
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanupSeededAlbums();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 种「查看器相册」:count 张已发布照片,publishedAt 逐张递减 60s(列表顺序确定)。
|
||||||
|
* 返回 token 与按列表顺序排列的 photoId。 */
|
||||||
|
async function seedViewerAlbum(alias: string, count: number): Promise<{ token: string; photoIds: string[] }> {
|
||||||
|
const album = await seedAlbum({
|
||||||
|
alias,
|
||||||
|
translations: [{ locale: 'zh-CN', title: '大图查看测试相册' }],
|
||||||
|
});
|
||||||
|
const photoIds: string[] = [];
|
||||||
|
const base = Date.now();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const ratio = RATIOS[i % RATIOS.length]!;
|
||||||
|
photoIds.push(
|
||||||
|
await seedPhoto(album.id, `${alias}-p${i}`, {
|
||||||
|
width: ratio.width,
|
||||||
|
height: ratio.height,
|
||||||
|
publishedAt: new Date(base - (i + 1) * 60_000),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { token: album.publicToken, photoIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLocale(page: Page, token: string, path = ''): Promise<void> {
|
||||||
|
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||||
|
await page.goto(`/a/${token}${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 大图 <img> 的自然尺寸(签名 URL 取回真实字节则 > 0)。 */
|
||||||
|
async function browseNaturalWidth(page: Page): Promise<number> {
|
||||||
|
return page.locator('img.photo').evaluate((img) => (img as HTMLImageElement).naturalWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('open from feed, navigate in sequence, zoom, close restores scroll and url', async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const { token, photoIds } = await seedViewerAlbum('viewer-open', 8);
|
||||||
|
await openLocale(page, token);
|
||||||
|
|
||||||
|
await expect(page.locator('.cell')).toHaveCount(8);
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 600));
|
||||||
|
await page.waitForTimeout(100); // 等 scrollEnd 后的懒加载稳定
|
||||||
|
|
||||||
|
// 点击第 3 格 → 大图:browse 图取回真实字节,URL 换为深链。
|
||||||
|
await page.locator('.cell').nth(2).click();
|
||||||
|
await expect(page.locator('.lightbox')).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() => browseNaturalWidth(page))
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[2]!));
|
||||||
|
expect(page.url()).toContain(`/a/${token}/p/${photoIds[2]}`);
|
||||||
|
|
||||||
|
// 记录开图后的滚动位置(关闭需精确回位)。
|
||||||
|
const scrollBefore = await page.evaluate(() => window.scrollY);
|
||||||
|
expect(scrollBefore).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// 右切 ×3:第 4/5/6 张依次换 src,URL 同步 replace。
|
||||||
|
for (const step of [3, 4, 5]) {
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[step]!), { timeout: 10_000 });
|
||||||
|
}
|
||||||
|
expect(page.url()).toContain(`/a/${token}/p/${photoIds[5]}`);
|
||||||
|
await expect(page.locator('.counter')).toHaveText('6 / 8');
|
||||||
|
|
||||||
|
// 滚轮缩放(无需 ctrl):transform scale > 1;双击复位。
|
||||||
|
const box = await page.locator('.lightbox').boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||||||
|
await page.mouse.wheel(0, -360);
|
||||||
|
await page.mouse.wheel(0, -360);
|
||||||
|
const zoomedScale = await page.locator('img.photo').evaluate((img) => {
|
||||||
|
const matrix = new DOMMatrixReadOnly(getComputedStyle(img).transform);
|
||||||
|
return matrix.a;
|
||||||
|
});
|
||||||
|
expect(zoomedScale).toBeGreaterThan(1.2);
|
||||||
|
await page.locator('img.photo').dblclick();
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
page.locator('img.photo').evaluate((img) => new DOMMatrixReadOnly(getComputedStyle(img).transform).a),
|
||||||
|
)
|
||||||
|
.toBeCloseTo(1, 1);
|
||||||
|
|
||||||
|
// 关闭:URL 弹回相册路由,滚动精确回位。
|
||||||
|
await page.locator('.control.close').click();
|
||||||
|
await expect(page.locator('.lightbox')).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/a/${token}/?$`));
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => window.scrollY))
|
||||||
|
.toBe(scrollBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deep link opens on the target photo with the list loaded behind', async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const { token, photoIds } = await seedViewerAlbum('viewer-deep', 8);
|
||||||
|
const target = photoIds[4]!; // 第 5 张
|
||||||
|
await openLocale(page, token, `/p/${target}`);
|
||||||
|
|
||||||
|
await expect(page.locator('.lightbox')).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() => browseNaturalWidth(page))
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(target));
|
||||||
|
await expect(page.locator('.counter')).toHaveText('5 / 8');
|
||||||
|
// 背后列表已构成序列(8 张)。
|
||||||
|
await expect(page.locator('.cell')).toHaveCount(8);
|
||||||
|
|
||||||
|
// 序列可切换:左切到第 4 张,URL 同步。
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[3]!), { timeout: 10_000 });
|
||||||
|
expect(page.url()).toContain(`/a/${token}/p/${photoIds[3]}`);
|
||||||
|
|
||||||
|
// 关闭 → 回相册路由(深链直达无本会话 push:replace 语义)。
|
||||||
|
await page.locator('.control.close').click();
|
||||||
|
await expect(page.locator('.lightbox')).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/a/${token}/?$`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deep link to a removed photo shows the localized notice and a working back link', async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const { token, photoIds } = await seedViewerAlbum('viewer-404', 2);
|
||||||
|
const gone = photoIds[1]!;
|
||||||
|
await deleteSeededPhoto(gone); // 模拟照片已下线(旧分享链接)
|
||||||
|
|
||||||
|
await openLocale(page, token, `/p/${gone}`);
|
||||||
|
const notice = page.locator('.deep-link-notice');
|
||||||
|
await expect(notice).toBeVisible();
|
||||||
|
await expect(notice).toContainText('照片不存在或已下线');
|
||||||
|
await expect(page.locator('.lightbox')).toHaveCount(0);
|
||||||
|
|
||||||
|
const back = notice.getByRole('link', { name: '返回相册' });
|
||||||
|
await back.click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/a/${token}/?$`));
|
||||||
|
await expect(page.locator('.deep-link-notice')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('heading', { name: '大图查看测试相册' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigating past the first page extends the sequence', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
// 32 张:公开列表默认页 30 → 第 31/32 张需导航越过末尾时拉第 2 页。
|
||||||
|
const { token, photoIds } = await seedViewerAlbum('viewer-extend', 32);
|
||||||
|
await openLocale(page, token);
|
||||||
|
|
||||||
|
await expect(page.locator('.cell')).toHaveCount(30);
|
||||||
|
await page.locator('.cell').nth(29).click(); // 第 1 页最后一张
|
||||||
|
await expect(page.locator('.lightbox')).toBeVisible();
|
||||||
|
|
||||||
|
// 打开即临近末页边界:相邻预取已后台拉取第 2 页(设计行为),右切越过末尾
|
||||||
|
// 落在第 31 张(序列第 2 页),列表同步扩展。
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[30]!), { timeout: 15_000 });
|
||||||
|
await expect(page.locator('.counter')).toHaveText('31 / 32');
|
||||||
|
// 背后列表同步扩展到 32 张。
|
||||||
|
await expect(page.locator('.cell')).toHaveCount(32);
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||||
|
import { computed, defineComponent } from 'vue';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
@@ -9,10 +10,13 @@ import {
|
|||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||||
import PhotoFeed from '../components/PhotoFeed.vue';
|
import PhotoFeed from '../components/PhotoFeed.vue';
|
||||||
|
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
|
||||||
|
|
||||||
// PhotoFeed(Task 7)组件测试:api client 以真实模块为底、替身 getPublicPhotos /
|
// PhotoFeed(Task 7;Task 8 起数据层在 PhotoSequence)组件测试:api client 以真实
|
||||||
// getPublicPhoto;IntersectionObserver / ResizeObserver 以可控假件替换(jsdom 无实现),
|
// 模块为底、替身 getPublicPhotos / getPublicPhoto;IntersectionObserver /
|
||||||
// clientWidth 以原型 getter mock(jsdom 恒 0)。布局纯函数已在 feed-layout.spec 覆盖。
|
// ResizeObserver 以可控假件替换(jsdom 无实现),clientWidth 以原型 getter mock
|
||||||
|
// (jsdom 恒 0)。序列数据/轮询已在 sequence.spec.ts 覆盖;布局纯函数在
|
||||||
|
// feed-layout.spec。宿主组件持 sequence(对齐 ActivityPage 的真实组装方式)。
|
||||||
|
|
||||||
vi.mock('../api/client', async (importOriginal) => {
|
vi.mock('../api/client', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('../api/client')>();
|
const actual = await importOriginal<typeof import('../api/client')>();
|
||||||
@@ -109,20 +113,32 @@ function makeList(items: PublicPhoto[], nextCursor: string | null = null): Publi
|
|||||||
let i18n: ViewerI18n;
|
let i18n: ViewerI18n;
|
||||||
const mounted: VueWrapper[] = [];
|
const mounted: VueWrapper[] = [];
|
||||||
|
|
||||||
function mountFeed(props: { token?: string; locale?: string; category?: string | null } = {}): VueWrapper {
|
/** 宿主:创建 sequence 并渲染 PhotoFeed(镜像 ActivityPage 组装);捕获 open 事件。 */
|
||||||
const wrapper = mount(PhotoFeed, {
|
function mountFeed(props: { token?: string; locale?: string; category?: string | null } = {}): {
|
||||||
global: { plugins: [i18n] },
|
wrapper: VueWrapper;
|
||||||
props: {
|
sequence: PhotoSequence;
|
||||||
token: props.token ?? 'tok-feed',
|
opened: string[];
|
||||||
locale: props.locale ?? 'zh-CN',
|
} {
|
||||||
category: props.category ?? null,
|
const opened: string[] = [];
|
||||||
|
const Host = defineComponent({
|
||||||
|
components: { PhotoFeed },
|
||||||
|
setup() {
|
||||||
|
const sequence = usePhotoSequence({
|
||||||
|
token: computed(() => props.token ?? 'tok-feed'),
|
||||||
|
locale: computed(() => props.locale ?? 'zh-CN'),
|
||||||
|
category: computed(() => props.category ?? null),
|
||||||
|
});
|
||||||
|
return { sequence, onOpen: (photoId: string) => opened.push(photoId) };
|
||||||
},
|
},
|
||||||
|
template: `<PhotoFeed :sequence="sequence" @open="onOpen" />`,
|
||||||
});
|
});
|
||||||
|
const wrapper = mount(Host, { global: { plugins: [i18n] } });
|
||||||
mounted.push(wrapper);
|
mounted.push(wrapper);
|
||||||
return wrapper;
|
const sequence = (wrapper.vm as unknown as { sequence: PhotoSequence }).sequence;
|
||||||
|
return { wrapper, sequence, opened };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('photo feed (Task 7)', () => {
|
describe('photo feed (Task 7 / Task 8 sequence props)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
i18n = createViewerI18n('zh-CN');
|
i18n = createViewerI18n('zh-CN');
|
||||||
@@ -144,11 +160,10 @@ describe('photo feed (Task 7)', () => {
|
|||||||
}
|
}
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders first page as absolute-positioned cells with reserved heights', async () => {
|
it('renders first page as absolute-positioned cells with reserved heights', async () => {
|
||||||
const wrapper = mountFeed();
|
const { wrapper } = mountFeed();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-feed', { locale: 'zh-CN' });
|
expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-feed', { locale: 'zh-CN' });
|
||||||
@@ -161,8 +176,16 @@ describe('photo feed (Task 7)', () => {
|
|||||||
expect(cells[2].attributes('style')).toContain('top: 224px');
|
expect(cells[2].attributes('style')).toContain('top: 224px');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits open with the photoId when a cell is clicked', async () => {
|
||||||
|
const { wrapper, opened } = mountFeed();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await wrapper.findAll('.cell')[1].trigger('click');
|
||||||
|
expect(opened).toEqual(['p2']);
|
||||||
|
});
|
||||||
|
|
||||||
it('binds img src only after the cell approaches the viewport (lazy swap)', async () => {
|
it('binds img src only after the cell approaches the viewport (lazy swap)', async () => {
|
||||||
const wrapper = mountFeed();
|
const { wrapper } = mountFeed();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(wrapper.findAll('img.cell-img')).toHaveLength(0);
|
expect(wrapper.findAll('img.cell-img')).toHaveLength(0);
|
||||||
@@ -177,7 +200,7 @@ describe('photo feed (Task 7)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads the next page when the sentinel intersects, dedupes and stops at exhaustion', async () => {
|
it('loads the next page when the sentinel intersects, dedupes and stops at exhaustion', async () => {
|
||||||
const wrapper = mountFeed();
|
const { wrapper } = mountFeed();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
const sentinel = wrapper.find('.sentinel');
|
const sentinel = wrapper.find('.sentinel');
|
||||||
@@ -199,59 +222,9 @@ describe('photo feed (Task 7)', () => {
|
|||||||
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
|
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 () => {
|
it('renders the localized empty state when the album has no photos', async () => {
|
||||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([]));
|
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([]));
|
||||||
const wrapper = mountFeed();
|
const { wrapper } = mountFeed();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线');
|
expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线');
|
||||||
@@ -262,7 +235,7 @@ describe('photo feed (Task 7)', () => {
|
|||||||
mockedGetPublicPhotos
|
mockedGetPublicPhotos
|
||||||
.mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network down', 0, null))
|
.mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network down', 0, null))
|
||||||
.mockResolvedValueOnce(makeList([makePhoto('p1')], null));
|
.mockResolvedValueOnce(makeList([makePhoto('p1')], null));
|
||||||
const wrapper = mountFeed();
|
const { wrapper } = mountFeed();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(wrapper.find('.error[role="alert"]').text()).toContain('网络连接失败');
|
expect(wrapper.find('.error[role="alert"]').text()).toContain('网络连接失败');
|
||||||
@@ -274,7 +247,7 @@ describe('photo feed (Task 7)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('retries a failed thumb once with a refreshed signed URL, then hides the img', async () => {
|
it('retries a failed thumb once with a refreshed signed URL, then hides the img', async () => {
|
||||||
const wrapper = mountFeed();
|
const { wrapper } = mountFeed();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
lazyObserver().trigger(wrapper.findAll('.cell').map((cell) => cell.element), true);
|
lazyObserver().trigger(wrapper.findAll('.cell').map((cell) => cell.element), true);
|
||||||
await vi.waitFor(() => expect(wrapper.findAll('img.cell-img')).toHaveLength(3));
|
await vi.waitFor(() => expect(wrapper.findAll('img.cell-img')).toHaveLength(3));
|
||||||
@@ -298,83 +271,3 @@ describe('photo feed (Task 7)', () => {
|
|||||||
expect(mockedGetPublicPhoto).toHaveBeenCalledTimes(1);
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||||
|
import { defineComponent, nextTick, ref } from 'vue';
|
||||||
|
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 PhotoLightbox from '../components/PhotoLightbox.vue';
|
||||||
|
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
|
||||||
|
|
||||||
|
// PhotoLightbox(Task 8)组件测试:browse 渲染/导航/序列扩展、wheel 缩放与双击
|
||||||
|
// 复位、Esc/背景/按钮关闭、指针轻扫切换、签名过期静默重取与 404 移除上报。
|
||||||
|
// 深链解析与 URL 同步在 viewer-route.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);
|
||||||
|
|
||||||
|
function makePhoto(id: string, likeCount = 0): PublicPhoto {
|
||||||
|
return {
|
||||||
|
photoId: id,
|
||||||
|
width: 800,
|
||||||
|
height: 500,
|
||||||
|
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,
|
||||||
|
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[] = [];
|
||||||
|
|
||||||
|
/** 宿主:sequence + v-model photoId(镜像 ActivityPage 组装)。页面数据经
|
||||||
|
* mockedGetPublicPhotos 的当前 mock 队列供给(与真实时序一致:挂载后到达)。 */
|
||||||
|
function mountLightbox(photoId: string): VueWrapper {
|
||||||
|
const Host = defineComponent({
|
||||||
|
components: { PhotoLightbox },
|
||||||
|
setup() {
|
||||||
|
const sequence = usePhotoSequence({
|
||||||
|
token: ref('tok-lb'),
|
||||||
|
locale: ref('zh-CN'),
|
||||||
|
category: ref(null),
|
||||||
|
});
|
||||||
|
const current = ref(photoId);
|
||||||
|
return { sequence, photoId: current };
|
||||||
|
},
|
||||||
|
template: `<PhotoLightbox v-model:photo-id="photoId" :sequence="sequence" />`,
|
||||||
|
});
|
||||||
|
const wrapper = mount(Host, { global: { plugins: [i18n] } });
|
||||||
|
mounted.push(wrapper);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lightbox(wrapper: VueWrapper): VueWrapper<Record<string, never>> {
|
||||||
|
return wrapper.getComponent(PhotoLightbox) as unknown as VueWrapper<Record<string, never>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function img(wrapper: VueWrapper) {
|
||||||
|
return wrapper.find('img.photo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** jsdom 无 PointerEvent 构造器:MouseEvent 补 pointerId/pointerType 字段。 */
|
||||||
|
function pointerEvent(
|
||||||
|
type: 'pointerdown' | 'pointermove' | 'pointerup',
|
||||||
|
init: { pointerId?: number; clientX?: number; clientY?: number; pointerType?: string },
|
||||||
|
): PointerEvent {
|
||||||
|
const event = new MouseEvent(type, {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
clientX: init.clientX ?? 0,
|
||||||
|
clientY: init.clientY ?? 0,
|
||||||
|
});
|
||||||
|
return Object.assign(event, {
|
||||||
|
pointerId: init.pointerId ?? 1,
|
||||||
|
pointerType: init.pointerType ?? 'touch',
|
||||||
|
}) as unknown as PointerEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keydown(key: string): void {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key, cancelable: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** test-utils 的 trigger 无法写 MouseEvent 只读坐标:直接派生 WheelEvent。 */
|
||||||
|
function wheel(wrapper: VueWrapper, deltaY: number, clientX = 100, clientY = 80): void {
|
||||||
|
wrapper.find('.lightbox').element.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY, clientX, clientY }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('photo lightbox (Task 8)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
i18n = createViewerI18n('zh-CN');
|
||||||
|
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')]));
|
||||||
|
mockedGetPublicPhoto.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (mounted.length > 0) {
|
||||||
|
mounted.pop()?.unmount();
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the current browse url with counter and like count', async () => {
|
||||||
|
mockedGetPublicPhotos.mockResolvedValue(
|
||||||
|
makeList([makePhoto('p1'), makePhoto('p2', 7), makePhoto('p3')]),
|
||||||
|
);
|
||||||
|
const wrapper = mountLightbox('p2');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=1');
|
||||||
|
expect(wrapper.find('.counter').text()).toBe('2 / 3');
|
||||||
|
expect(wrapper.find('.likes').text()).toBe('7 次点赞');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates with buttons and arrow keys, keeping the list order', async () => {
|
||||||
|
const wrapper = mountLightbox('p2');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await wrapper.find('.nav.next').trigger('click');
|
||||||
|
expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]);
|
||||||
|
|
||||||
|
keydown('ArrowLeft');
|
||||||
|
await flushPromises();
|
||||||
|
expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3'], ['p2']]);
|
||||||
|
|
||||||
|
keydown('ArrowLeft');
|
||||||
|
await flushPromises();
|
||||||
|
expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3'], ['p2'], ['p1']]);
|
||||||
|
|
||||||
|
// 首张:previous 按钮禁用,再按左键不再发事件;末张同理(无更多页)。
|
||||||
|
expect(wrapper.find('.nav.prev').attributes('disabled')).toBeDefined();
|
||||||
|
keydown('ArrowLeft');
|
||||||
|
await flushPromises();
|
||||||
|
expect(lightbox(wrapper).emitted('update:photoId')).toHaveLength(3);
|
||||||
|
expect(wrapper.find('.nav.next').attributes('disabled')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes on Esc, close button and backdrop click', async () => {
|
||||||
|
const wrapper = mountLightbox('p2');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
keydown('Escape');
|
||||||
|
expect(lightbox(wrapper).emitted('close')).toHaveLength(1);
|
||||||
|
|
||||||
|
await wrapper.find('.control.close').trigger('click');
|
||||||
|
expect(lightbox(wrapper).emitted('close')).toHaveLength(2);
|
||||||
|
|
||||||
|
await wrapper.find('.backdrop').trigger('click');
|
||||||
|
expect(lightbox(wrapper).emitted('close')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zooms with wheel around the cursor and clamps to [1, 4]; dblclick resets', async () => {
|
||||||
|
const wrapper = mountLightbox('p2');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
// jsdom 下容器 rect 为 0 → 锚点 = 事件坐标(100, 80)。
|
||||||
|
wheel(wrapper, -240);
|
||||||
|
await nextTick();
|
||||||
|
let style = img(wrapper).attributes('style') ?? '';
|
||||||
|
expect(style).toContain('scale(');
|
||||||
|
const zoomedScale = Number(/scale\(([\d.]+)\)/.exec(style)?.[1]);
|
||||||
|
expect(zoomedScale).toBeGreaterThan(1);
|
||||||
|
// 不动点:锚点屏幕位置不变(translate 补偿)。
|
||||||
|
const zoomedTx = Number(/translate\((-?[\d.]+)px/.exec(style)?.[1]);
|
||||||
|
expect(100 * zoomedScale + zoomedTx).toBeCloseTo(100, 5);
|
||||||
|
|
||||||
|
// 连续放大钳制到 4。
|
||||||
|
for (let i = 0; i < 8; i++) wheel(wrapper, -600);
|
||||||
|
await nextTick();
|
||||||
|
style = img(wrapper).attributes('style') ?? '';
|
||||||
|
expect(Number(/scale\(([\d.]+)\)/.exec(style)?.[1])).toBe(4);
|
||||||
|
|
||||||
|
// 缩到底回到 1 且平移归零(复位对齐)。
|
||||||
|
for (let i = 0; i < 12; i++) wheel(wrapper, 600);
|
||||||
|
await nextTick();
|
||||||
|
style = img(wrapper).attributes('style') ?? '';
|
||||||
|
expect(style).toContain('translate(0px, 0px) scale(1)');
|
||||||
|
|
||||||
|
// 双击复位。
|
||||||
|
wheel(wrapper, -240);
|
||||||
|
await nextTick();
|
||||||
|
await img(wrapper).trigger('dblclick');
|
||||||
|
expect(img(wrapper).attributes('style')).toContain('scale(1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swipe navigates when flat; drag on backdrop does not close', async () => {
|
||||||
|
const wrapper = mountLightbox('p2');
|
||||||
|
await flushPromises();
|
||||||
|
const root = wrapper.find('.lightbox');
|
||||||
|
|
||||||
|
// 左滑 80px → 下一张。
|
||||||
|
root.element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1, clientX: 200, clientY: 150 }));
|
||||||
|
root.element.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 150, clientY: 150 }));
|
||||||
|
root.element.dispatchEvent(pointerEvent('pointerup', { pointerId: 1, clientX: 120, clientY: 150 }));
|
||||||
|
await flushPromises();
|
||||||
|
expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]);
|
||||||
|
|
||||||
|
// 拖拽距离超过阈值后收尾在背景上:click 不关闭。
|
||||||
|
root.element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1, clientX: 200, clientY: 150 }));
|
||||||
|
root.element.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 100, clientY: 160 }));
|
||||||
|
root.element.dispatchEvent(pointerEvent('pointerup', { pointerId: 1, clientX: 100, clientY: 160 }));
|
||||||
|
await wrapper.find('.backdrop').trigger('click');
|
||||||
|
expect(lightbox(wrapper).emitted('close')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends the sequence when navigating past the end with more pages', async () => {
|
||||||
|
let resolvePage!: (value: PublicPhotoList) => void;
|
||||||
|
mockedGetPublicPhotos.mockReset();
|
||||||
|
mockedGetPublicPhotos
|
||||||
|
.mockResolvedValueOnce(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')], 'cur-1'))
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise<PublicPhotoList>((resolve) => {
|
||||||
|
resolvePage = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const wrapper = mountLightbox('p2');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
// 临近末尾的预取已在途(受控 promise 挂起)→ 右切越过末尾与预取共用同一请求。
|
||||||
|
await wrapper.find('.nav.next').trigger('click'); // → p3(预取仍未完成)
|
||||||
|
expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]);
|
||||||
|
|
||||||
|
await wrapper.find('.nav.next').trigger('click'); // 越过末尾:等待扩展
|
||||||
|
expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]); // 未落地前不切换
|
||||||
|
expect(wrapper.find('.loading').exists()).toBe(true);
|
||||||
|
|
||||||
|
resolvePage(makeList([makePhoto('p4')], null));
|
||||||
|
await flushPromises();
|
||||||
|
expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3'], ['p4']]);
|
||||||
|
expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('silently refreshes an expired signed url on img error', async () => {
|
||||||
|
const wrapper = mountLightbox('p2');
|
||||||
|
await flushPromises();
|
||||||
|
mockedGetPublicPhoto.mockResolvedValue({
|
||||||
|
...makePhoto('p2'),
|
||||||
|
categoryId: null,
|
||||||
|
browse: { url: 'http://img.test/p2-browse.jpg?s=2', expiresAt: '2026-10-01T01:00:00.000Z' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await img(wrapper).trigger('error');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-lb', 'p2');
|
||||||
|
expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports removal with the pre-removal index when the photo is gone (404)', async () => {
|
||||||
|
const wrapper = mountLightbox('p2');
|
||||||
|
await flushPromises();
|
||||||
|
mockedGetPublicPhoto.mockRejectedValue(new ApiError('PHOTO_NOT_PUBLIC', 'gone', 404, 'req-1'));
|
||||||
|
|
||||||
|
await img(wrapper).trigger('error');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(lightbox(wrapper).emitted('removed')).toEqual([['p2', 1]]);
|
||||||
|
expect((wrapper.vm as unknown as { sequence: PhotoSequence }).sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a retryable failure notice for non-404 refresh failures', async () => {
|
||||||
|
const wrapper = mountLightbox('p2');
|
||||||
|
await flushPromises();
|
||||||
|
mockedGetPublicPhoto.mockRejectedValue(new ApiError('NETWORK_ERROR', 'down', 0, null));
|
||||||
|
|
||||||
|
await img(wrapper).trigger('error');
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.find('.img-failed[role="alert"]').text()).toContain('图片加载失败');
|
||||||
|
|
||||||
|
// 重试:清标记并强制 <img> 重挂载(key 变化),错误链可再次触发。
|
||||||
|
mockedGetPublicPhoto.mockResolvedValue({
|
||||||
|
...makePhoto('p2'),
|
||||||
|
categoryId: null,
|
||||||
|
browse: { url: 'http://img.test/p2-browse.jpg?s=3', expiresAt: '2026-10-01T02:00:00.000Z' },
|
||||||
|
});
|
||||||
|
await wrapper.find('.img-failed button').trigger('click');
|
||||||
|
await nextTick();
|
||||||
|
expect(wrapper.find('.img-failed').exists()).toBe(false); // 占位收起,img 回到加载中
|
||||||
|
expect(img(wrapper).exists()).toBe(true);
|
||||||
|
await img(wrapper).trigger('error');
|
||||||
|
await flushPromises();
|
||||||
|
expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=3');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import App from '../App.vue';
|
import App from '../App.vue';
|
||||||
import { getPublicAlbum } from '../api/client';
|
import { getPublicAlbum, getPublicPhoto } from '../api/client';
|
||||||
import { createViewerI18n } from '../i18n';
|
import { createViewerI18n } from '../i18n';
|
||||||
import { router } from '../router';
|
import { router } from '../router';
|
||||||
import { makeAlbum } from './fixtures';
|
import { makeAlbum } from './fixtures';
|
||||||
|
|
||||||
// 路由解析(Task 5 脚手架测试对齐 Task 6 真实活动页):token/photoId 参数经
|
// 路由解析(Task 5 脚手架测试对齐 Task 6 真实活动页):token/photoId 参数经
|
||||||
// api mock 断言——活动页以 token 取数,photoId 供 Task 8 深链定位(不影响取数)。
|
// api mock 断言——活动页以 token 取数,photoId 驱动 Task 8 深链(单图先行 + 序列
|
||||||
// getPublicPhotos 一并替身为空页(Task 7 起活动页内嵌 PhotoFeed)。
|
// 兜底)。getPublicPhotos / getPublicPhoto 一并替身(Task 7 起活动页内嵌 PhotoFeed,
|
||||||
|
// Task 8 起深链取单图)。
|
||||||
vi.mock('../api/client', async (importOriginal) => {
|
vi.mock('../api/client', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('../api/client')>();
|
const actual = await importOriginal<typeof import('../api/client')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
getPublicAlbum: vi.fn(),
|
getPublicAlbum: vi.fn(),
|
||||||
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
|
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
|
||||||
|
getPublicPhoto: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||||
|
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
|
||||||
|
|
||||||
// 逐挂载独立 i18n + 逐用例卸载(背景见 activity.spec.ts 头注)。
|
// 逐挂载独立 i18n + 逐用例卸载(背景见 activity.spec.ts 头注)。
|
||||||
let i18n: ReturnType<typeof createViewerI18n>;
|
let i18n: ReturnType<typeof createViewerI18n>;
|
||||||
@@ -49,6 +52,16 @@ describe('viewer routing', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('deep link resolves token and photoId params on the activity page', async () => {
|
it('deep link resolves token and photoId params on the activity page', async () => {
|
||||||
|
mockedGetPublicPhoto.mockResolvedValue({
|
||||||
|
photoId: 'photo-42',
|
||||||
|
width: 800,
|
||||||
|
height: 500,
|
||||||
|
thumb: { url: 'http://img.test/photo-42-thumb.jpg?s=1', expiresAt: '2026-10-01T00:00:00.000Z' },
|
||||||
|
browse: { url: 'http://img.test/photo-42-browse.jpg?s=1', expiresAt: '2026-10-01T00:00:00.000Z' },
|
||||||
|
likeCount: 0,
|
||||||
|
publishedAt: '2026-10-01T00:00:00.000Z',
|
||||||
|
categoryId: null,
|
||||||
|
});
|
||||||
await router.push('/a/tok-abc/p/photo-42');
|
await router.push('/a/tok-abc/p/photo-42');
|
||||||
await router.isReady();
|
await router.isReady();
|
||||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||||
@@ -56,8 +69,10 @@ describe('viewer routing', () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-abc', 'zh-CN');
|
expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-abc', 'zh-CN');
|
||||||
|
expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-abc', 'photo-42');
|
||||||
expect(wrapper.text()).toContain('2026 公司年会');
|
expect(wrapper.text()).toContain('2026 公司年会');
|
||||||
// photoId 仅用于 Task 8 大图定位,不进入信息区文案。
|
// 深链以大图呈现(solo 序列),id 本身不进入文案。
|
||||||
|
expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/photo-42-browse.jpg?s=1');
|
||||||
expect(wrapper.text()).not.toContain('photo-42');
|
expect(wrapper.text()).not.toContain('photo-42');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||||
|
import { defineComponent, ref } from 'vue';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
getPublicPhoto,
|
||||||
|
getPublicPhotos,
|
||||||
|
type PublicPhoto,
|
||||||
|
type PublicPhotoList,
|
||||||
|
} from '../api/client';
|
||||||
|
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
|
||||||
|
|
||||||
|
// PhotoSequence(Task 8 数据层):分页追加去重、深链 solo 条目(就位/自然归位/
|
||||||
|
// 越上限保留)、单图签名刷新(换绑/404 移除)与轮询提示。jsdom 下 IO/RO 无关
|
||||||
|
// (本层无 DOM 观察者);轮询用假时钟驱动。
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 挂载一个仅创建 sequence 的宿主组件(composable 的 onMounted/watch 需组件上下文)。 */
|
||||||
|
function mountSequence(options: { token?: string; category?: string | null } = {}): {
|
||||||
|
wrapper: VueWrapper;
|
||||||
|
sequence: PhotoSequence;
|
||||||
|
} {
|
||||||
|
let sequence!: PhotoSequence;
|
||||||
|
const Host = defineComponent({
|
||||||
|
setup() {
|
||||||
|
sequence = usePhotoSequence({
|
||||||
|
token: ref(options.token ?? 'tok-seq'),
|
||||||
|
locale: ref('zh-CN'),
|
||||||
|
category: ref(options.category ?? null),
|
||||||
|
});
|
||||||
|
return () => null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wrapper = mount(Host);
|
||||||
|
return { wrapper, sequence };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('photo sequence pagination (Task 8)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], 'cur-1'));
|
||||||
|
mockedGetPublicPhoto.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the first page and exposes cursor state', async () => {
|
||||||
|
const { wrapper, sequence } = mountSequence();
|
||||||
|
expect(sequence.initialLoading).toBe(true);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-seq', { locale: 'zh-CN' });
|
||||||
|
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2']);
|
||||||
|
expect(sequence.initialLoading).toBe(false);
|
||||||
|
expect(sequence.hasMore).toBe(true);
|
||||||
|
expect(sequence.pagesLoaded).toBe(1);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends the next page with dedupe and stops at cursor exhaustion', async () => {
|
||||||
|
const { wrapper, sequence } = mountSequence();
|
||||||
|
await flushPromises();
|
||||||
|
// 第 2 页带回重复 p2(游标边界漂移防御)→ 仅追加新 id。
|
||||||
|
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p2'), makePhoto('p3')], null));
|
||||||
|
|
||||||
|
await sequence.loadMore();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-seq', expect.objectContaining({ cursor: 'cur-1' }));
|
||||||
|
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p3']);
|
||||||
|
expect(sequence.hasMore).toBe(false);
|
||||||
|
expect(sequence.pagesLoaded).toBe(2);
|
||||||
|
|
||||||
|
// 游标耗尽后再 loadMore 为无操作(大图导航越过末尾的安全边界)。
|
||||||
|
await sequence.loadMore();
|
||||||
|
expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(2);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes against an in-flight loadMore (single fetch while pending)', async () => {
|
||||||
|
const { wrapper, sequence } = mountSequence();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
let resolvePage!: (value: PublicPhotoList) => void;
|
||||||
|
mockedGetPublicPhotos.mockReturnValueOnce(
|
||||||
|
new Promise<PublicPhotoList>((resolve) => {
|
||||||
|
resolvePage = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
void sequence.loadMore();
|
||||||
|
void sequence.loadMore();
|
||||||
|
await flushPromises();
|
||||||
|
expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
resolvePage(makeList([makePhoto('p9')], null));
|
||||||
|
await flushPromises();
|
||||||
|
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p9']);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('photo sequence deep-link solo entries (Task 8)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], null));
|
||||||
|
mockedGetPublicPhoto.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensureInSequence returns the existing index without duplicating', async () => {
|
||||||
|
const { wrapper, sequence } = mountSequence();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(sequence.ensureInSequence(makePhoto('p2'))).toBe(1);
|
||||||
|
expect(sequence.photos).toHaveLength(2);
|
||||||
|
expect(sequence.isSolo('p2')).toBe(false);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends an unknown photo as a solo entry navigable at the tail', async () => {
|
||||||
|
const { wrapper, sequence } = mountSequence();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const index = sequence.ensureInSequence(makePhoto('p9'));
|
||||||
|
expect(index).toBe(2);
|
||||||
|
expect(sequence.isSolo('p9')).toBe(true);
|
||||||
|
expect(sequence.photos[2]?.photoId).toBe('p9');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces the solo copy with its natural position when a later page brings it', async () => {
|
||||||
|
mockedGetPublicPhotos.mockReset();
|
||||||
|
mockedGetPublicPhotos
|
||||||
|
.mockResolvedValueOnce(makeList([makePhoto('p1'), makePhoto('p2')], 'cur-1'))
|
||||||
|
.mockResolvedValueOnce(makeList([makePhoto('p4'), makePhoto('p9')], null));
|
||||||
|
const { wrapper, sequence } = mountSequence();
|
||||||
|
await flushPromises();
|
||||||
|
sequence.ensureInSequence(makePhoto('p9')); // 深链目标不在第 1 页
|
||||||
|
|
||||||
|
// 相邻页把 p4 与 p9 带回:solo 副本被自然位置副本取代,顺序纠正。
|
||||||
|
await sequence.loadMore();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p4', 'p9']);
|
||||||
|
expect(sequence.isSolo('p9')).toBe(false);
|
||||||
|
expect(sequence.indexOf('p9')).toBe(3);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps surviving solos across a full reset, and drops ones covered by the page', async () => {
|
||||||
|
const { wrapper, sequence } = mountSequence();
|
||||||
|
await flushPromises();
|
||||||
|
sequence.ensureInSequence(makePhoto('p9')); // 保留
|
||||||
|
sequence.ensureInSequence(makePhoto('p2')); // 第 1 页含 p2 → 自然副本接管
|
||||||
|
|
||||||
|
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p2'), makePhoto('p1')], null));
|
||||||
|
await sequence.resetAndLoad();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p2', 'p1', 'p9']);
|
||||||
|
expect(sequence.isSolo('p9')).toBe(true);
|
||||||
|
expect(sequence.isSolo('p2')).toBe(false);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a photo from the sequence (404 removal path)', async () => {
|
||||||
|
const { wrapper, sequence } = mountSequence();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
sequence.removePhoto('p1');
|
||||||
|
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p2']);
|
||||||
|
expect(sequence.indexOf('p1')).toBe(-1);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('photo sequence url refresh (Task 8)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], null));
|
||||||
|
mockedGetPublicPhoto.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces signed urls in place on success', async () => {
|
||||||
|
const { wrapper, sequence } = mountSequence();
|
||||||
|
await flushPromises();
|
||||||
|
mockedGetPublicPhoto.mockResolvedValue({
|
||||||
|
...makePhoto('p1'),
|
||||||
|
categoryId: null,
|
||||||
|
thumb: { url: 'http://img.test/p1-thumb.jpg?s=2', expiresAt: '2026-10-01T01:00:00.000Z' },
|
||||||
|
browse: { url: 'http://img.test/p1-browse.jpg?s=2', expiresAt: '2026-10-01T01:00:00.000Z' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await sequence.refreshPhotoUrls('p1')).toBe('replaced');
|
||||||
|
expect(sequence.photos[0]?.browse.url).toBe('http://img.test/p1-browse.jpg?s=2');
|
||||||
|
expect(sequence.photos[0]?.thumb.url).toBe('http://img.test/p1-thumb.jpg?s=2');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the photo on PHOTO_NOT_PUBLIC', async () => {
|
||||||
|
const { wrapper, sequence } = mountSequence();
|
||||||
|
await flushPromises();
|
||||||
|
mockedGetPublicPhoto.mockRejectedValue(new ApiError('PHOTO_NOT_PUBLIC', 'gone', 404, 'req-1'));
|
||||||
|
|
||||||
|
expect(await sequence.refreshPhotoUrls('p1')).toBe('removed');
|
||||||
|
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p2']);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the photo on transient failures', async () => {
|
||||||
|
const { wrapper, sequence } = mountSequence();
|
||||||
|
await flushPromises();
|
||||||
|
mockedGetPublicPhoto.mockRejectedValue(new ApiError('NETWORK_ERROR', 'down', 0, null));
|
||||||
|
|
||||||
|
expect(await sequence.refreshPhotoUrls('p1')).toBe('failed');
|
||||||
|
expect(sequence.photos).toHaveLength(2);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('photo sequence polling (Task 8)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], null));
|
||||||
|
mockedGetPublicPhoto.mockReset();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raises the hint when page 1 contains an unknown id; refresh scrolls top', async () => {
|
||||||
|
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
|
const { wrapper, sequence } = mountSequence();
|
||||||
|
await flushPromises();
|
||||||
|
expect(sequence.hasNew).toBe(false);
|
||||||
|
|
||||||
|
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1')], null));
|
||||||
|
vi.advanceTimersByTime(30_000);
|
||||||
|
await flushPromises();
|
||||||
|
expect(sequence.hasNew).toBe(true);
|
||||||
|
expect(sequence.photos).toHaveLength(2); // 只亮提示不自动插入
|
||||||
|
|
||||||
|
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p9'), makePhoto('p1'), makePhoto('p2')], null));
|
||||||
|
await sequence.refreshFromHint();
|
||||||
|
await flushPromises();
|
||||||
|
expect(sequence.hasNew).toBe(false);
|
||||||
|
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p9', 'p1', 'p2']);
|
||||||
|
expect(scrollTo).toHaveBeenCalledWith(0, 0);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays silent when a poll fails', async () => {
|
||||||
|
const { wrapper, sequence } = mountSequence();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
mockedGetPublicPhotos.mockRejectedValueOnce(new ApiError('INTERNAL_ERROR', 'boom', 500, 'req-1'));
|
||||||
|
vi.advanceTimersByTime(30_000);
|
||||||
|
await flushPromises();
|
||||||
|
expect(sequence.hasNew).toBe(false);
|
||||||
|
expect(sequence.initialErrorKey).toBe(null);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import App from '../App.vue';
|
||||||
|
import { ApiError, getPublicAlbum, getPublicPhoto, getPublicPhotos, type PublicPhoto, type PublicPhotoList } from '../api/client';
|
||||||
|
import { createViewerI18n } from '../i18n';
|
||||||
|
import { router } from '../router';
|
||||||
|
import { makeAlbum } from './fixtures';
|
||||||
|
|
||||||
|
// 大图查看器路由编排(Task 8):URL 同步(打开 push / 切换 replace / 关闭 back 或
|
||||||
|
// replace)、滚动锚点恢复、深链解析顺序(单图先行 → 相邻页构成序列)、深链 404
|
||||||
|
// 本地化提示与返回链接。使用真实 router 单例(jsdom 提供_history),对齐
|
||||||
|
// routing.spec.ts 的既定模式;api 三端点替身。
|
||||||
|
|
||||||
|
vi.mock('../api/client', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../api/client')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getPublicAlbum: vi.fn(),
|
||||||
|
getPublicPhotos: vi.fn(),
|
||||||
|
getPublicPhoto: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||||
|
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
||||||
|
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
|
||||||
|
|
||||||
|
function makePhoto(id: string): PublicPhoto {
|
||||||
|
return {
|
||||||
|
photoId: id,
|
||||||
|
width: 800,
|
||||||
|
height: 500,
|
||||||
|
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: ReturnType<typeof createViewerI18n>;
|
||||||
|
const mounted: VueWrapper[] = [];
|
||||||
|
|
||||||
|
function mountApp(): VueWrapper {
|
||||||
|
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||||
|
mounted.push(wrapper);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keydown(key: string): void {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key, cancelable: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('viewer url sync and deep link (Task 8)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
i18n = createViewerI18n('zh-CN');
|
||||||
|
mockedGetPublicAlbum.mockReset().mockResolvedValue(makeAlbum());
|
||||||
|
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')]));
|
||||||
|
mockedGetPublicPhoto.mockReset().mockImplementation(async (_token, photoId) => ({
|
||||||
|
...makePhoto(photoId),
|
||||||
|
categoryId: null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (mounted.length > 0) {
|
||||||
|
mounted.pop()?.unmount();
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('open pushes the photo route, navigate replaces, close restores scroll and pops back', async () => {
|
||||||
|
await router.push('/a/tok-view');
|
||||||
|
await router.isReady();
|
||||||
|
const wrapper = mountApp();
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.findAll('.cell')).toHaveLength(3);
|
||||||
|
|
||||||
|
const pushSpy = vi.spyOn(router, 'push');
|
||||||
|
const replaceSpy = vi.spyOn(router, 'replace');
|
||||||
|
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
|
vi.stubGlobal('scrollY', 650);
|
||||||
|
|
||||||
|
// 点击第 2 格开大图:push 深链路由,browse 渲染。
|
||||||
|
await wrapper.findAll('.cell')[1].trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/p2-browse.jpg?s=1');
|
||||||
|
expect(router.currentRoute.value.path).toBe('/a/tok-view/p/p2');
|
||||||
|
expect(pushSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// 键盘右切:replace(不新增历史条目)。
|
||||||
|
keydown('ArrowRight');
|
||||||
|
await flushPromises();
|
||||||
|
expect(router.currentRoute.value.path).toBe('/a/tok-view/p/p3');
|
||||||
|
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(pushSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Esc 关闭:恢复滚动锚点 + 历史出栈回相册路由(jsdom 的 popstate 异步,需等待)。
|
||||||
|
scrollTo.mockClear();
|
||||||
|
keydown('Escape');
|
||||||
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/a/tok-view'));
|
||||||
|
expect(wrapper.find('.lightbox').exists()).toBe(false);
|
||||||
|
expect(scrollTo).toHaveBeenCalledWith(0, 650);
|
||||||
|
expect(replaceSpy).toHaveBeenCalledTimes(1); // 关闭未额外 replace
|
||||||
|
});
|
||||||
|
|
||||||
|
it('browser back from the photo route closes the lightbox without touching history further', async () => {
|
||||||
|
await router.push('/a/tok-view');
|
||||||
|
await router.isReady();
|
||||||
|
const wrapper = mountApp();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
vi.stubGlobal('scrollY', 320);
|
||||||
|
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
|
await wrapper.findAll('.cell')[0].trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.find('.lightbox').exists()).toBe(true);
|
||||||
|
|
||||||
|
scrollTo.mockClear();
|
||||||
|
router.back(); // 等价浏览器后退(popstate 异步派发)
|
||||||
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/a/tok-view'));
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.find('.lightbox').exists()).toBe(false);
|
||||||
|
expect(scrollTo).toHaveBeenCalledWith(0, 320);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deep link renders the single photo first, then naturalizes it as pages arrive', async () => {
|
||||||
|
// 第 1 页挂起(受控):验证「先单图、后序列」的解析顺序。
|
||||||
|
let resolveFirstPage!: (value: PublicPhotoList) => void;
|
||||||
|
mockedGetPublicPhotos.mockReset();
|
||||||
|
mockedGetPublicPhotos
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise<PublicPhotoList>((resolve) => {
|
||||||
|
resolveFirstPage = resolve;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(makeList([makePhoto('p4'), makePhoto('p5')], null));
|
||||||
|
|
||||||
|
await router.push('/a/tok-view/p/p5');
|
||||||
|
await router.isReady();
|
||||||
|
const wrapper = mountApp();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
// 单图先行:列表仍在加载,大图已可看(solo 序列 1/1)。
|
||||||
|
expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-view', 'p5');
|
||||||
|
expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/p5-browse.jpg?s=1');
|
||||||
|
expect(wrapper.find('.counter').text()).toBe('1 / 1');
|
||||||
|
|
||||||
|
// 第 1 页到达(无 p5)→ 深链 watcher 追拉第 2 页 → p5 归位自然顺序(5/5)。
|
||||||
|
resolveFirstPage(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')], 'cur-1'));
|
||||||
|
await vi.waitFor(() => expect(wrapper.findAll('.cell')).toHaveLength(5));
|
||||||
|
expect(wrapper.find('.counter').text()).toBe('5 / 5');
|
||||||
|
|
||||||
|
// 序列已构成:左切到前一张。
|
||||||
|
keydown('ArrowLeft');
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/p4-browse.jpg?s=1');
|
||||||
|
expect(router.currentRoute.value.path).toBe('/a/tok-view/p/p4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deep link to a non-public photo shows the localized notice with a back link', async () => {
|
||||||
|
mockedGetPublicPhoto.mockRejectedValue(new ApiError('PHOTO_NOT_PUBLIC', 'hidden', 404, 'req-1'));
|
||||||
|
|
||||||
|
await router.push('/a/tok-view/p/gone');
|
||||||
|
await router.isReady();
|
||||||
|
const wrapper = mountApp();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.find('.lightbox').exists()).toBe(false);
|
||||||
|
const notice = wrapper.find('.deep-link-notice[role="alert"]');
|
||||||
|
expect(notice.text()).toContain('照片不存在或已下线');
|
||||||
|
expect(notice.text()).not.toContain('PHOTO_NOT_PUBLIC'); // 不裸露错误码
|
||||||
|
|
||||||
|
const back = notice.find('a');
|
||||||
|
expect(back.text()).toBe('返回相册');
|
||||||
|
expect(back.attributes('href')).toBe('/a/tok-view');
|
||||||
|
await back.trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
expect(router.currentRoute.value.path).toBe('/a/tok-view');
|
||||||
|
expect(wrapper.find('.deep-link-notice').exists()).toBe(false);
|
||||||
|
expect(wrapper.text()).toContain('2026 公司年会');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
INITIAL_ZOOM,
|
||||||
|
MAX_SCALE,
|
||||||
|
MIN_SCALE,
|
||||||
|
clampScale,
|
||||||
|
panBy,
|
||||||
|
resetZoom,
|
||||||
|
wheelFactor,
|
||||||
|
zoomAt,
|
||||||
|
type ZoomState,
|
||||||
|
} from '../viewer/zoom';
|
||||||
|
|
||||||
|
// 大图查看器缩放状态机(Task 8)纯函数层:钳制、不动点缩放、平移门控与复位。
|
||||||
|
|
||||||
|
describe('zoom state machine (Task 8)', () => {
|
||||||
|
it('clamps scale into [1, 4]', () => {
|
||||||
|
expect(clampScale(0.2)).toBe(MIN_SCALE);
|
||||||
|
expect(clampScale(3.9)).toBe(3.9);
|
||||||
|
expect(clampScale(11)).toBe(MAX_SCALE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zooms around the given anchor point: the anchor stays fixed on screen', () => {
|
||||||
|
const state: ZoomState = { scale: 1, tx: 0, ty: 0 };
|
||||||
|
// 锚点 (100, 40)(相对图片中心):缩放前屏幕位置 = 100*1+0 = 100。
|
||||||
|
const next = zoomAt(state, 2, 100, 40);
|
||||||
|
expect(next.scale).toBe(2);
|
||||||
|
expect(next.tx).toBe(100 * (1 - 2));
|
||||||
|
expect(next.ty).toBe(40 * (1 - 2));
|
||||||
|
// 锚点屏幕位置不变:100*2 + (-100) = 100。
|
||||||
|
expect(100 * next.scale + next.tx).toBe(100);
|
||||||
|
expect(40 * next.scale + next.ty).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps at max scale and keeps the anchor fixed with the actual factor', () => {
|
||||||
|
const state: ZoomState = { scale: 3, tx: -50, ty: 20 };
|
||||||
|
const next = zoomAt(state, 10, 200, 0);
|
||||||
|
expect(next.scale).toBe(MAX_SCALE);
|
||||||
|
// 实际因子 4/3;锚点 200*3-50 = 550 → 200*4 + tx' = 550 → tx' = -250。
|
||||||
|
expect(200 * next.scale + next.tx).toBe(200 * state.scale + state.tx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets translation when scale returns to min', () => {
|
||||||
|
const state: ZoomState = { scale: 2, tx: -120, ty: 80 };
|
||||||
|
const next = zoomAt(state, 0.1, 300, 300);
|
||||||
|
expect(next).toEqual(resetZoom());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pans only when zoomed in; pan accumulates', () => {
|
||||||
|
const flat: ZoomState = { ...INITIAL_ZOOM };
|
||||||
|
expect(panBy(flat, 40, 20)).toBe(flat); // scale=1:无平移空间
|
||||||
|
|
||||||
|
const zoomed = zoomAt(flat, 2, 0, 0);
|
||||||
|
const panned = panBy(panBy(zoomed, 10, -5), 6, 6);
|
||||||
|
expect(panned.tx).toBe(16);
|
||||||
|
expect(panned.ty).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wheel factor maps deltaY sign consistently and stays continuous', () => {
|
||||||
|
expect(wheelFactor(-120)).toBeGreaterThan(1); // 上滚/外张 → 放大
|
||||||
|
expect(wheelFactor(120)).toBeLessThan(1); // 下滚/内收 → 缩小
|
||||||
|
expect(wheelFactor(0)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,120 +1,47 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import {
|
import type { PublicPhoto } from '../api/client';
|
||||||
ApiError,
|
import type { PhotoSequence } from '../feed/sequence';
|
||||||
NETWORK_ERROR_CODE,
|
import { layoutWaterfall } from '../feed/layout';
|
||||||
getPublicPhoto,
|
|
||||||
getPublicPhotos,
|
|
||||||
type PublicPhoto,
|
|
||||||
type PublicPhotoList,
|
|
||||||
} from '../api/client';
|
|
||||||
import { errorMessageKey } from '../api/errors';
|
|
||||||
import { containsNewPhotoId, layoutWaterfall, mergeUniqueByPhotoId } from '../feed/layout';
|
|
||||||
|
|
||||||
// 观众瀑布流(Task 7):双列绝对定位(短列优先,条目高按 browse 宽高比例预留)
|
// 观众瀑布流(Task 7;Task 8 起数据层上提):双列绝对定位(短列优先,条目高按
|
||||||
// + IntersectionObserver 懒加载 + 游标无限滚动 + 分类筛选 + 新照片轮询提示。
|
// browse 宽高比例预留)+ IntersectionObserver 懒加载 + 哨兵无限滚动 + 新照片提示条。
|
||||||
|
// 数据生命周期(分页/去重/轮询/深链 solo)在 ActivityPage 创建的 PhotoSequence
|
||||||
|
// composable(feed/sequence.ts)——feed 与大图查看器共享同一序列,切换大图时
|
||||||
|
// 保持浏览顺序、越过末尾自动扩展。
|
||||||
//
|
//
|
||||||
// 关键决策:
|
// 关键决策(沿用 Task 7):
|
||||||
// · 懒加载 = 仅当单元格接近视口才绑定 <img src>(等价 data-src → src 换绑);
|
// · 懒加载 = 仅当单元格接近视口才绑定 <img src>;shownIds 集合跨刷新保留——
|
||||||
// shownIds 集合跨刷新保留——刷新只换签名 URL,同 id 单元格不必重新判定;
|
// 刷新只换签名 URL,同 id 单元格不必重新判定;IO 不可用(旧环境/jsdom)时
|
||||||
// IO 不可用(旧环境/jsdom)时退化为立即加载,功能不缺失。
|
// 退化为立即加载,功能不缺失。
|
||||||
// · 图片 onerror 重试一次:调单图端点换新签名 URL 再换绑;再失败则收起该图
|
// · 图片 onerror 重试一次:经 sequence.refreshPhotoUrls 换新签名 URL 再换绑;
|
||||||
// (占位底色仍在,布局因高度预留不跳动)。
|
// 再失败(或照片已下线)则收起该图(占位底色仍在,布局因高度预留不跳动)。
|
||||||
// · 轮询(30s,e2e 可用 window.__viewerPollMs 覆盖)只取第 1 页对比 id 集合,
|
// · 单元格点击 emit('open', photoId) → ActivityPage 打开大图(Task 8)。
|
||||||
// 命中新照片只亮提示条——不自动插入(避免滚动位置跳动,需求 1.2);
|
|
||||||
// 点击提示条 = 整表刷新回第 1 页并回顶。标签页隐藏暂停(R26 同精神)。
|
|
||||||
// · 竞态防护:feedSequence 令牌——分类/语言/token 切换或刷新会使在途的
|
|
||||||
// 分页/轮询结果作废;轮询失败静默(网络抖动不应打扰观众)。
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
token: string;
|
sequence: PhotoSequence;
|
||||||
locale: string;
|
}>();
|
||||||
/** 当前分类筛选(null = 全部)。由活动页 chips 持有,切换即重置列表。 */
|
|
||||||
category: string | null;
|
const emit = defineEmits<{
|
||||||
|
open: [photoId: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const photos = ref<PublicPhoto[]>([]);
|
const photos = computed(() => props.sequence.photos);
|
||||||
const nextCursor = ref<string | null>(null);
|
const initialLoading = computed(() => props.sequence.initialLoading);
|
||||||
const hasMore = ref(false);
|
const initialErrorKey = computed(() => props.sequence.initialErrorKey);
|
||||||
const initialLoading = ref(true);
|
const loadingMore = computed(() => props.sequence.loadingMore);
|
||||||
const initialErrorKey = ref<string | null>(null);
|
const moreFailed = computed(() => props.sequence.moreFailed);
|
||||||
const loadingMore = ref(false);
|
const hasMore = computed(() => props.sequence.hasMore);
|
||||||
const moreFailed = ref(false);
|
const hasNew = computed(() => props.sequence.hasNew);
|
||||||
const hasNew = ref(false);
|
|
||||||
|
|
||||||
// 懒加载/重试视图状态(按 photoId,跨刷新保留,见顶部决策注)。
|
// 懒加载/重试视图状态(按 photoId,跨刷新保留,见顶部决策注)。
|
||||||
const shownIds = reactive(new Set<string>());
|
const shownIds = reactive(new Set<string>());
|
||||||
const retriedIds = reactive(new Set<string>());
|
const retriedIds = reactive(new Set<string>());
|
||||||
const failedIds = 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 跟踪容器宽度变化) ——
|
// —— 布局:容器宽 → 纯函数双列放置(ResizeObserver 跟踪容器宽度变化) ——
|
||||||
|
|
||||||
const trackEl = ref<HTMLElement | null>(null);
|
const trackEl = ref<HTMLElement | null>(null);
|
||||||
@@ -191,15 +118,22 @@ function pruneCellElements(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
photos,
|
||||||
|
() => pruneCellElements(),
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
sentinelEl,
|
sentinelEl,
|
||||||
(el) => {
|
(el) => {
|
||||||
sentinelObserver?.disconnect();
|
sentinelObserver?.disconnect();
|
||||||
sentinelObserver = null;
|
sentinelObserver = null;
|
||||||
if (el === null) return;
|
if (el === null) return;
|
||||||
|
// IO 不可用(jsdom/旧环境)时不观察:无限滚动退化为已加载内容(不崩溃)。
|
||||||
|
if (typeof IntersectionObserver === 'undefined') return;
|
||||||
sentinelObserver = new IntersectionObserver(
|
sentinelObserver = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries.some((entry) => entry.isIntersecting)) void loadMore();
|
if (entries.some((entry) => entry.isIntersecting)) void props.sequence.loadMore();
|
||||||
},
|
},
|
||||||
{ rootMargin: '600px 0px' },
|
{ rootMargin: '600px 0px' },
|
||||||
);
|
);
|
||||||
@@ -208,99 +142,28 @@ watch(
|
|||||||
{ flush: 'post' },
|
{ flush: 'post' },
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 缩略图加载失败:重试一次(单图端点换新签名 URL);再失败收起图片。 */
|
/** 缩略图加载失败:重试一次(单图端点换新签名 URL);再失败/已下线则收起图片。 */
|
||||||
async function onImgError(photo: PublicPhoto): Promise<void> {
|
async function onImgError(photo: PublicPhoto): Promise<void> {
|
||||||
const { photoId } = photo;
|
if (failedIds.has(photo.photoId)) return;
|
||||||
if (failedIds.has(photoId)) return;
|
if (retriedIds.has(photo.photoId)) {
|
||||||
if (retriedIds.has(photoId)) {
|
failedIds.add(photo.photoId);
|
||||||
failedIds.add(photoId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
retriedIds.add(photoId);
|
retriedIds.add(photo.photoId);
|
||||||
try {
|
const result = await props.sequence.refreshPhotoUrls(photo.photoId);
|
||||||
const detail = await getPublicPhoto(props.token, photoId);
|
if (result !== 'replaced') failedIds.add(photo.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(() => {
|
onBeforeUnmount(() => {
|
||||||
stopPolling();
|
|
||||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
||||||
lazyObserver?.disconnect();
|
lazyObserver?.disconnect();
|
||||||
sentinelObserver?.disconnect();
|
sentinelObserver?.disconnect();
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 分类/语言/token 变化:整表重置(语言切换保留当前分类——由 props 自带)。
|
|
||||||
watch(
|
|
||||||
[() => props.token, () => props.locale, () => props.category],
|
|
||||||
() => void resetAndLoad(),
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="photo-feed">
|
<div class="photo-feed">
|
||||||
<button v-if="hasNew" type="button" class="new-photo-bar" @click="refreshFromHint">
|
<button v-if="hasNew" type="button" class="new-photo-bar" @click="props.sequence.refreshFromHint()">
|
||||||
{{ t('album.newPhotos') }}
|
{{ t('album.newPhotos') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -308,7 +171,7 @@ watch(
|
|||||||
|
|
||||||
<div v-else-if="initialErrorKey !== null" class="error" role="alert">
|
<div v-else-if="initialErrorKey !== null" class="error" role="alert">
|
||||||
<p>{{ t(initialErrorKey) }}</p>
|
<p>{{ t(initialErrorKey) }}</p>
|
||||||
<button type="button" @click="resetAndLoad">{{ t('album.retry') }}</button>
|
<button type="button" @click="props.sequence.resetAndLoad">{{ t('album.retry') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="photos.length === 0" class="feed">
|
<div v-else-if="photos.length === 0" class="feed">
|
||||||
@@ -328,6 +191,7 @@ watch(
|
|||||||
width: `${layout.columnWidth}px`,
|
width: `${layout.columnWidth}px`,
|
||||||
height: `${placement.height}px`,
|
height: `${placement.height}px`,
|
||||||
}"
|
}"
|
||||||
|
@click="emit('open', photos[placement.index].photoId)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="shownIds.has(photos[placement.index].photoId) && !failedIds.has(photos[placement.index].photoId)"
|
v-if="shownIds.has(photos[placement.index].photoId) && !failedIds.has(photos[placement.index].photoId)"
|
||||||
@@ -343,7 +207,7 @@ watch(
|
|||||||
|
|
||||||
<p v-if="loadingMore" class="status more-status">{{ t('album.loadingMore') }}</p>
|
<p v-if="loadingMore" class="status more-status">{{ t('album.loadingMore') }}</p>
|
||||||
<div v-else-if="moreFailed" class="more-failed">
|
<div v-else-if="moreFailed" class="more-failed">
|
||||||
<button type="button" @click="loadMore">{{ t('album.retry') }}</button>
|
<button type="button" @click="props.sequence.loadMore">{{ t('album.retry') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-else-if="!hasMore" class="end-note">{{ t('album.end') }}</p>
|
<p v-else-if="!hasMore" class="end-note">{{ t('album.end') }}</p>
|
||||||
|
|
||||||
@@ -412,6 +276,7 @@ watch(
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: linear-gradient(160deg, #f3f4f6, #e5e7eb);
|
background: linear-gradient(160deg, #f3f4f6, #e5e7eb);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.cell-img {
|
.cell-img {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { PhotoSequence } from '../feed/sequence';
|
||||||
|
import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom';
|
||||||
|
|
||||||
|
// 大图查看器(Task 8):browse URL 全屏展示 + 左右切换(保持列表浏览序列,越过
|
||||||
|
// 末尾且 hasMore 时取下一页扩展)+ 滚轮/双指缩放与平移(受控 CSS transform,数学
|
||||||
|
// 见 viewer/zoom.ts)+ 签名过期静默重取(复用 sequence.refreshPhotoUrls)。
|
||||||
|
//
|
||||||
|
// 状态归属:序列数据/分页在 ActivityPage 创建的 PhotoSequence(共享 composable),
|
||||||
|
// 本组件只持视图态(缩放/平移、加载失败标记、扩展中标记);当前照片以 photoId
|
||||||
|
// (v-model)为准而非下标——序列追加/solo 归位/条目移除都会移动下标,photoId 键
|
||||||
|
// 控让重排自动保持指向同一张照片。
|
||||||
|
//
|
||||||
|
// 手势(Pointer Events,无第三方依赖):
|
||||||
|
// · 双指:捏合缩放(transform 不动点 = 手势中心)+ 中点漂移平移;
|
||||||
|
// · 单指且 scale>1:拖拽平移;scale=1:横向轻扫切换上一张/下一张;
|
||||||
|
// · 双击(鼠标 dblclick / 触控双 tap):复位缩放;
|
||||||
|
// · 滚轮(无需 ctrl):以光标为不动点缩放;Esc/关闭按钮/背景点击关闭。
|
||||||
|
//
|
||||||
|
// 关闭回位(滚动锚点)与 URL 同步由 ActivityPage 负责(见 ActivityPage 注释)。
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sequence: PhotoSequence;
|
||||||
|
photoId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:photoId': [photoId: string];
|
||||||
|
/** 当前照片经刷新确认已下线(404):neighborIndex 为移除前下标,供回退邻居。 */
|
||||||
|
removed: [photoId: string, neighborIndex: number];
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const photos = computed(() => props.sequence.photos);
|
||||||
|
const index = computed(() => photos.value.findIndex((photo) => photo.photoId === props.photoId));
|
||||||
|
const photo = computed(() => photos.value[index.value] ?? null);
|
||||||
|
const total = computed(() => photos.value.length);
|
||||||
|
const hasPrev = computed(() => index.value > 0);
|
||||||
|
// 末张且仍有下一页 → 允许继续向右(导航触发序列扩展)。
|
||||||
|
const hasNext = computed(() => index.value < total.value - 1 || props.sequence.hasMore);
|
||||||
|
|
||||||
|
// —— 视图状态(按 photoId;切换照片即复位) ——
|
||||||
|
|
||||||
|
/** 图片单次重试的缓存击穿键:重试按钮点击后 +1 强制 <img> 重挂载重触发加载。 */
|
||||||
|
const retryCount = ref(0);
|
||||||
|
const failedIds = reactive(new Set<string>());
|
||||||
|
const retriedIds = reactive(new Set<string>());
|
||||||
|
const extending = ref(false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.photoId,
|
||||||
|
() => {
|
||||||
|
failedIds.clear();
|
||||||
|
retriedIds.clear();
|
||||||
|
retryCount.value = 0;
|
||||||
|
Object.assign(zoom, resetZoom());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// —— 缩放/平移 ——
|
||||||
|
|
||||||
|
const zoom = reactive<ZoomState>({ ...resetZoom() });
|
||||||
|
const rootEl = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const imgStyle = computed(() => ({
|
||||||
|
transform: `translate(${zoom.tx}px, ${zoom.ty}px) scale(${zoom.scale})`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 手势中心(相对图片未变换时中心的坐标):图片经 flex 居中,未变换中心 = 容器中心。 */
|
||||||
|
function center(): { x: number; y: number } {
|
||||||
|
const rect = rootEl.value?.getBoundingClientRect();
|
||||||
|
if (rect === undefined) return { x: 0, y: 0 };
|
||||||
|
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(event: WheelEvent): void {
|
||||||
|
const { x, y } = center();
|
||||||
|
Object.assign(zoom, zoomAt({ ...zoom }, wheelFactor(event.deltaY), event.clientX - x, event.clientY - y));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDblClick(): void {
|
||||||
|
Object.assign(zoom, resetZoom());
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 指针手势(双指捏合 / 单指平移 / 单指轻扫 / 双 tap 复位) ——
|
||||||
|
|
||||||
|
const pointers = new Map<number, { x: number; y: number }>();
|
||||||
|
let pinchDistance = 0;
|
||||||
|
let pinchMid = { x: 0, y: 0 };
|
||||||
|
let swipe: { x: number; y: number; dx: number; dy: number } | null = null;
|
||||||
|
let lastTap = { time: 0, x: 0, y: 0 };
|
||||||
|
/** 手势期间发生过位移:抑制手势收尾时的背景 click 误关闭。 */
|
||||||
|
let dragged = false;
|
||||||
|
|
||||||
|
const SWIPE_MIN_DISTANCE = 50;
|
||||||
|
|
||||||
|
function pointerPair(): { a: { x: number; y: number }; b: { x: number; y: number } } | null {
|
||||||
|
if (pointers.size < 2) return null;
|
||||||
|
const [first, second] = pointers.values();
|
||||||
|
if (first === undefined || second === undefined) return null;
|
||||||
|
return { a: first, b: second };
|
||||||
|
}
|
||||||
|
|
||||||
|
function distance(a: { x: number; y: number }, b: { x: number; y: number }): number {
|
||||||
|
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event: PointerEvent): void {
|
||||||
|
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
dragged = false;
|
||||||
|
|
||||||
|
// 触控双 tap:400ms 内同点两落 → 复位缩放。
|
||||||
|
if (event.pointerType === 'touch' && pointers.size === 1) {
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - lastTap.time < 400 && Math.hypot(event.clientX - lastTap.x, event.clientY - lastTap.y) < 40) {
|
||||||
|
Object.assign(zoom, resetZoom());
|
||||||
|
lastTap = { time: 0, x: 0, y: 0 };
|
||||||
|
} else {
|
||||||
|
lastTap = { time: now, x: event.clientX, y: event.clientY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pair = pointerPair();
|
||||||
|
if (pair !== null) {
|
||||||
|
pinchDistance = distance(pair.a, pair.b);
|
||||||
|
pinchMid = { x: (pair.a.x + pair.b.x) / 2, y: (pair.a.y + pair.b.y) / 2 };
|
||||||
|
swipe = null;
|
||||||
|
} else if (zoom.scale <= MIN_SCALE) {
|
||||||
|
swipe = { x: event.clientX, y: event.clientY, dx: 0, dy: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event: PointerEvent): void {
|
||||||
|
const previous = pointers.get(event.pointerId);
|
||||||
|
if (previous === undefined) return;
|
||||||
|
const current = { x: event.clientX, y: event.clientY };
|
||||||
|
pointers.set(event.pointerId, current);
|
||||||
|
|
||||||
|
const pair = pointerPair();
|
||||||
|
if (pair !== null) {
|
||||||
|
// 双指:距离比 → 缩放因子(不动点 = 当前中点);中点位移 → 平移。
|
||||||
|
const nextDistance = distance(pair.a, pair.b);
|
||||||
|
const mid = { x: (pair.a.x + pair.b.x) / 2, y: (pair.a.y + pair.b.y) / 2 };
|
||||||
|
const { x, y } = center();
|
||||||
|
const factor = pinchDistance > 0 ? nextDistance / pinchDistance : 1;
|
||||||
|
Object.assign(zoom, zoomAt({ ...zoom }, factor, mid.x - x, mid.y - y));
|
||||||
|
Object.assign(zoom, panBy({ ...zoom }, mid.x - pinchMid.x, mid.y - pinchMid.y));
|
||||||
|
pinchDistance = nextDistance;
|
||||||
|
pinchMid = mid;
|
||||||
|
dragged = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zoom.scale > MIN_SCALE) {
|
||||||
|
// 单指平移(scale>1)。
|
||||||
|
Object.assign(zoom, panBy({ ...zoom }, current.x - previous.x, current.y - previous.y));
|
||||||
|
dragged = true;
|
||||||
|
} else if (swipe !== null) {
|
||||||
|
swipe.dx = current.x - swipe.x;
|
||||||
|
swipe.dy = current.y - swipe.y;
|
||||||
|
if (Math.abs(swipe.dx) > 10 || Math.abs(swipe.dy) > 10) dragged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手势结束(抬起/取消/离开全屏层)。不使用 setPointerCapture:捕获会把后续
|
||||||
|
* click 重定向到捕获元素,按钮(关闭/切换)将收不到点击。 */
|
||||||
|
function onPointerEnd(event: PointerEvent): void {
|
||||||
|
pointers.delete(event.pointerId);
|
||||||
|
if (pointers.size < 2) pinchDistance = 0;
|
||||||
|
if (pointers.size === 1) {
|
||||||
|
// 双指 → 单指:剩余指针重新作为平移/轻扫基线。
|
||||||
|
const [remaining] = pointers.values();
|
||||||
|
if (remaining !== undefined) {
|
||||||
|
swipe = zoom.scale <= MIN_SCALE ? { x: remaining.x, y: remaining.y, dx: 0, dy: 0 } : null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pointers.size > 0) return;
|
||||||
|
|
||||||
|
const gesture = swipe;
|
||||||
|
swipe = null;
|
||||||
|
if (gesture === null) return;
|
||||||
|
// 以抬起坐标结算总位移(最后一帧 move 可能早于抬起点)。
|
||||||
|
gesture.dx = event.clientX - gesture.x;
|
||||||
|
gesture.dy = event.clientY - gesture.y;
|
||||||
|
if (Math.abs(gesture.dx) > SWIPE_MIN_DISTANCE && Math.abs(gesture.dx) > Math.abs(gesture.dy) * 1.2) {
|
||||||
|
if (gesture.dx < 0) void goTo(index.value + 1);
|
||||||
|
else void goTo(index.value - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackdropClick(): void {
|
||||||
|
if (dragged) return; // 手势收尾的 click 不关闭
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 键盘 ——
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
emit('close');
|
||||||
|
} else if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault();
|
||||||
|
void goTo(index.value - 1);
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault();
|
||||||
|
void goTo(index.value + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 导航(保持列表浏览序列;越过末尾且 hasMore → 扩展) ——
|
||||||
|
|
||||||
|
async function goTo(targetIndex: number): Promise<void> {
|
||||||
|
if (targetIndex < 0 || targetIndex === index.value) return;
|
||||||
|
if (targetIndex >= total.value) {
|
||||||
|
if (!props.sequence.hasMore) return;
|
||||||
|
extending.value = true;
|
||||||
|
try {
|
||||||
|
await props.sequence.loadMore();
|
||||||
|
} finally {
|
||||||
|
extending.value = false;
|
||||||
|
}
|
||||||
|
if (targetIndex >= photos.value.length) return; // 翻页失败/无新增:留在原处
|
||||||
|
}
|
||||||
|
const target = photos.value[targetIndex];
|
||||||
|
if (target === undefined || target.photoId === props.photoId) return;
|
||||||
|
emit('update:photoId', target.photoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相邻预取(±1 browse URL)+ 临近末尾预拉下一页,让右切更顺滑。
|
||||||
|
watch(
|
||||||
|
() => [props.photoId, photos.value] as const,
|
||||||
|
() => {
|
||||||
|
if (index.value === -1) return;
|
||||||
|
for (const neighbor of [index.value - 1, index.value + 1]) {
|
||||||
|
const target = photos.value[neighbor];
|
||||||
|
if (target !== undefined) void preload(target.browse.url);
|
||||||
|
}
|
||||||
|
if (index.value >= photos.value.length - 2 && props.sequence.hasMore && !props.sequence.loadingMore) {
|
||||||
|
void props.sequence.loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function preload(url: string): void {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 签名过期/下线处理 ——
|
||||||
|
|
||||||
|
/** 大图加载失败:静默重取单图接口换新签名 URL;404 → 通知父级移除;其他失败可重试。 */
|
||||||
|
async function onImgError(): Promise<void> {
|
||||||
|
const current = photo.value;
|
||||||
|
if (current === null) return;
|
||||||
|
const { photoId } = current;
|
||||||
|
if (retriedIds.has(photoId)) {
|
||||||
|
failedIds.add(photoId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
retriedIds.add(photoId);
|
||||||
|
const neighborIndex = index.value;
|
||||||
|
const result = await props.sequence.refreshPhotoUrls(photoId);
|
||||||
|
if (result === 'replaced') return; // 新签名 URL 换绑后 <img> 自动重载
|
||||||
|
if (result === 'removed') {
|
||||||
|
if (props.photoId === photoId) emit('removed', photoId, neighborIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failedIds.add(photoId); // 网络等其他失败:收起图片,展示可重试占位
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryFailed(): void {
|
||||||
|
const current = photo.value;
|
||||||
|
if (current === null) return;
|
||||||
|
failedIds.delete(current.photoId);
|
||||||
|
retriedIds.delete(current.photoId);
|
||||||
|
retryCount.value += 1; // 强制 <img> 重挂载,重新触发加载/错误链
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', onKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', onKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="photo !== null"
|
||||||
|
ref="rootEl"
|
||||||
|
class="lightbox"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="t('viewer.title')"
|
||||||
|
@wheel.prevent="onWheel"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerEnd"
|
||||||
|
@pointercancel="onPointerEnd"
|
||||||
|
@pointerleave="onPointerEnd"
|
||||||
|
@dblclick="onDblClick"
|
||||||
|
>
|
||||||
|
<div class="backdrop" @click="onBackdropClick" />
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-if="!failedIds.has(photo.photoId)"
|
||||||
|
:key="`${photo.photoId}:${retryCount}`"
|
||||||
|
class="photo"
|
||||||
|
:src="photo.browse.url"
|
||||||
|
:alt="t('route.photoLabel')"
|
||||||
|
:style="imgStyle"
|
||||||
|
draggable="false"
|
||||||
|
@error="onImgError"
|
||||||
|
>
|
||||||
|
<div v-else class="img-failed" role="alert">
|
||||||
|
<p>{{ t('viewer.loadFailed') }}</p>
|
||||||
|
<button type="button" @click="retryFailed">{{ t('album.retry') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="bar">
|
||||||
|
<span class="counter">{{ t('viewer.counter', { current: index + 1, total }) }}</span>
|
||||||
|
<span class="likes">{{ t('viewer.likeCount', { count: photo.likeCount }) }}</span>
|
||||||
|
<!-- P4a 预留:下载 / 分享操作位(P1b 不实现)。 -->
|
||||||
|
<button type="button" class="control close" :aria-label="t('viewer.close')" @click="emit('close')">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="control nav prev"
|
||||||
|
:disabled="!hasPrev"
|
||||||
|
:aria-label="t('viewer.previous')"
|
||||||
|
@click="goTo(index - 1)"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="control nav next"
|
||||||
|
:disabled="!hasNext"
|
||||||
|
:aria-label="t('viewer.next')"
|
||||||
|
@click="goTo(index + 1)"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="extending" class="loading">{{ t('album.loading') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 极简基线样式:视觉打磨留待 P4a。 */
|
||||||
|
.lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(17, 24, 39, 0.92);
|
||||||
|
}
|
||||||
|
.photo {
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
transform-origin: center;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.img-failed {
|
||||||
|
position: relative;
|
||||||
|
color: #f3f4f6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.img-failed button {
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid #4b5563;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #e5e7eb;
|
||||||
|
padding: 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: #f9fafb;
|
||||||
|
background: linear-gradient(rgba(0, 0, 0, 0.45), transparent);
|
||||||
|
}
|
||||||
|
.counter {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.likes {
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.control {
|
||||||
|
border: none;
|
||||||
|
background: rgba(17, 24, 39, 0.55);
|
||||||
|
color: #f9fafb;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.control:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.control.close {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
.control.nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 999px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.control.nav.prev {
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
.control.nav.next {
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
margin: 0;
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { onBeforeUnmount, onMounted, reactive, ref, watch, type Ref } from 'vue';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
NETWORK_ERROR_CODE,
|
||||||
|
getPublicPhoto,
|
||||||
|
getPublicPhotos,
|
||||||
|
type PublicPhoto,
|
||||||
|
type PublicPhotoList,
|
||||||
|
} from '../api/client';
|
||||||
|
import { errorMessageKey } from '../api/errors';
|
||||||
|
import { containsNewPhotoId, mergeUniqueByPhotoId } from './layout';
|
||||||
|
|
||||||
|
// apps/viewer/src/feed/sequence.ts —— 照片序列状态(Task 7 数据层 / Task 8 提升为
|
||||||
|
// feed + lightbox 共享 composable):游标分页追加、去重合并、轮询新照片提示、
|
||||||
|
// 深链 solo 条目与单图签名刷新。
|
||||||
|
//
|
||||||
|
// 提升决策(计划 Task 8):大图查看器需要「当前已加载列表序列」做左右切换、越过
|
||||||
|
// 末尾时取下一页扩展、深链照片就位/移除——这些都必须与瀑布流共享同一份数组与
|
||||||
|
// 游标状态。故把原先 PhotoFeed 内的数据生命周期整体上提至本 composable,由
|
||||||
|
// ActivityPage 创建一次,作为参数传给 PhotoFeed(渲染/懒加载/哨兵)与
|
||||||
|
// PhotoLightbox(导航/扩展),避免 props 逐层下钻或事件回传面条。
|
||||||
|
//
|
||||||
|
// solo 机制(深链兜底,文档化折中):目标照片不在已加载序列时以 solo 条目追加在
|
||||||
|
// 序列尾部(indexOf 可见、可导航);后续任何一页(含整表刷新)带回该 id 时,
|
||||||
|
// solo 副本被自然位置副本取代(integratePage),序列顺序自动纠正。ActivityPage
|
||||||
|
// 侧最多再拉 DEEP_LINK_PAGE_CAP 页寻找自然位置(见 ActivityPage)。
|
||||||
|
|
||||||
|
export interface PhotoSequenceInputs {
|
||||||
|
token: Ref<string>;
|
||||||
|
locale: Ref<string>;
|
||||||
|
category: Ref<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单图刷新结果:换绑新签名 URL / 照片已不可公开见(404,已从序列移除)/ 其他失败。 */
|
||||||
|
export type PhotoRefreshResult = 'replaced' | 'removed' | 'failed';
|
||||||
|
|
||||||
|
export function usePhotoSequence(inputs: PhotoSequenceInputs) {
|
||||||
|
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);
|
||||||
|
/** 深链解析的页数上限依据:整表重置成功置 1,每次成功追加 +1。 */
|
||||||
|
const pagesLoaded = ref(0);
|
||||||
|
|
||||||
|
// solo 条目标记(photoId 集合):见顶部「solo 机制」注。
|
||||||
|
const soloIds = reactive(new Set<string>());
|
||||||
|
|
||||||
|
let requestSequence = 0;
|
||||||
|
|
||||||
|
function fetchPage(cursor?: string): Promise<PublicPhotoList> {
|
||||||
|
return getPublicPhotos(inputs.token.value, {
|
||||||
|
locale: inputs.locale.value,
|
||||||
|
category: inputs.category.value ?? undefined,
|
||||||
|
cursor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 页到达后的序列整合:natural(页序)在前、存活 solo 追加在后。
|
||||||
|
* 本页包含某 solo id 时先摘除其标记——该条目由本页副本在自然位置接管。 */
|
||||||
|
function integratePage(pageItems: PublicPhoto[], mode: 'reset' | 'append'): void {
|
||||||
|
// 快照须在摘标记前取:刚摘掉标记的旧 solo 条目不能被当作 natural 参与合并。
|
||||||
|
const previousNatural = photos.value.filter((photo) => !soloIds.has(photo.photoId));
|
||||||
|
for (const item of pageItems) soloIds.delete(item.photoId);
|
||||||
|
const natural =
|
||||||
|
mode === 'reset' ? [...pageItems] : mergeUniqueByPhotoId(previousNatural, pageItems);
|
||||||
|
const survivingSolos = photos.value.filter((photo) => soloIds.has(photo.photoId));
|
||||||
|
photos.value = [...natural, ...survivingSolos];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 整表重置加载(首载/分类切换/语言切换/token 切换/新照片提示刷新共用)。 */
|
||||||
|
async function resetAndLoad(): Promise<void> {
|
||||||
|
const sequence = ++requestSequence;
|
||||||
|
initialLoading.value = true;
|
||||||
|
initialErrorKey.value = null;
|
||||||
|
moreFailed.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
hasNew.value = false;
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMore.value = false;
|
||||||
|
try {
|
||||||
|
const page = await fetchPage();
|
||||||
|
if (sequence !== requestSequence) return;
|
||||||
|
integratePage(page.items, 'reset');
|
||||||
|
nextCursor.value = page.nextCursor;
|
||||||
|
hasMore.value = page.hasMore && page.nextCursor !== null;
|
||||||
|
pagesLoaded.value = 1;
|
||||||
|
} catch (error) {
|
||||||
|
if (sequence !== requestSequence) return;
|
||||||
|
const code = error instanceof ApiError ? error.code : NETWORK_ERROR_CODE;
|
||||||
|
initialErrorKey.value = errorMessageKey(code);
|
||||||
|
} finally {
|
||||||
|
if (sequence === requestSequence) initialLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取下一页追加(哨兵无限滚动 / 大图导航越过末尾 / 深链解析共用);去重防御性
|
||||||
|
* 合并。并发调用(哨兵 + 大图预取/导航同时触发)共用同一在途请求,不重复取数。 */
|
||||||
|
let inflightMore: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function loadMore(): Promise<void> {
|
||||||
|
if (initialLoading.value || initialErrorKey.value !== null) return Promise.resolve();
|
||||||
|
if (!hasMore.value || nextCursor.value === null) return Promise.resolve();
|
||||||
|
if (inflightMore !== null) return inflightMore;
|
||||||
|
|
||||||
|
const sequence = requestSequence;
|
||||||
|
const cursor = nextCursor.value;
|
||||||
|
if (cursor === null) return Promise.resolve();
|
||||||
|
loadingMore.value = true;
|
||||||
|
moreFailed.value = false;
|
||||||
|
inflightMore = (async () => {
|
||||||
|
try {
|
||||||
|
const page = await fetchPage(cursor);
|
||||||
|
if (sequence !== requestSequence) return;
|
||||||
|
integratePage(page.items, 'append');
|
||||||
|
nextCursor.value = page.nextCursor;
|
||||||
|
hasMore.value = page.hasMore && page.nextCursor !== null;
|
||||||
|
pagesLoaded.value += 1;
|
||||||
|
} catch {
|
||||||
|
if (sequence === requestSequence) moreFailed.value = true;
|
||||||
|
} finally {
|
||||||
|
if (sequence === requestSequence) loadingMore.value = false;
|
||||||
|
inflightMore = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflightMore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新照片提示条点击:整表刷新回第 1 页并回顶(提示消失)。 */
|
||||||
|
async function refreshFromHint(): Promise<void> {
|
||||||
|
if (initialLoading.value) return;
|
||||||
|
await resetAndLoad();
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexOf(photoId: string): number {
|
||||||
|
return photos.value.findIndex((photo) => photo.photoId === photoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 深链兜底:目标照片不在序列时以 solo 条目追加尾部;已存在则返回现有下标。 */
|
||||||
|
function ensureInSequence(photo: PublicPhoto): number {
|
||||||
|
const existing = indexOf(photo.photoId);
|
||||||
|
if (existing >= 0) return existing;
|
||||||
|
soloIds.add(photo.photoId);
|
||||||
|
photos.value = [...photos.value, photo];
|
||||||
|
return photos.value.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSolo(photoId: string): boolean {
|
||||||
|
return soloIds.has(photoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从序列移除(照片 404 下线后)。 */
|
||||||
|
function removePhoto(photoId: string): void {
|
||||||
|
soloIds.delete(photoId);
|
||||||
|
photos.value = photos.value.filter((photo) => photo.photoId !== photoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单图刷新(Task 7 缩略图重试 / Task 8 大图签名过期共用):成功原位换绑新
|
||||||
|
* 签名 URL;PHOTO_NOT_PUBLIC → 从序列移除(removed);其他失败不动。 */
|
||||||
|
async function refreshPhotoUrls(photoId: string): Promise<PhotoRefreshResult> {
|
||||||
|
try {
|
||||||
|
const detail = await getPublicPhoto(inputs.token.value, photoId);
|
||||||
|
const photo = photos.value.find((item) => item.photoId === photoId);
|
||||||
|
if (photo !== undefined) {
|
||||||
|
photo.thumb = detail.thumb;
|
||||||
|
photo.browse = detail.browse;
|
||||||
|
}
|
||||||
|
return 'replaced';
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.code === 'PHOTO_NOT_PUBLIC') {
|
||||||
|
removePhoto(photoId);
|
||||||
|
return 'removed';
|
||||||
|
}
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 新照片轮询(30s;e2e 可用 window.__viewerPollMs 覆盖;失败静默) ——
|
||||||
|
|
||||||
|
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 = requestSequence;
|
||||||
|
try {
|
||||||
|
const page = await fetchPage();
|
||||||
|
if (sequence !== requestSequence) 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// token/语言/分类变化:整表重置(语言切换保留当前分类——inputs 自带)。
|
||||||
|
watch([inputs.token, inputs.locale, inputs.category], () => void resetAndLoad());
|
||||||
|
|
||||||
|
// reactive 包装:模板/脚本访问 sequence.photos 等 ref 自动解包(普通对象不会)。
|
||||||
|
return reactive({
|
||||||
|
photos,
|
||||||
|
nextCursor,
|
||||||
|
hasMore,
|
||||||
|
initialLoading,
|
||||||
|
initialErrorKey,
|
||||||
|
loadingMore,
|
||||||
|
moreFailed,
|
||||||
|
hasNew,
|
||||||
|
pagesLoaded,
|
||||||
|
resetAndLoad,
|
||||||
|
loadMore,
|
||||||
|
refreshFromHint,
|
||||||
|
indexOf,
|
||||||
|
ensureInSequence,
|
||||||
|
isSolo,
|
||||||
|
removePhoto,
|
||||||
|
refreshPhotoUrls,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PhotoSequence = ReturnType<typeof usePhotoSequence>;
|
||||||
@@ -14,6 +14,18 @@ export default {
|
|||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
},
|
},
|
||||||
lang: { switch: 'Switch language' },
|
lang: { switch: 'Switch language' },
|
||||||
|
viewer: {
|
||||||
|
title: 'Photo viewer',
|
||||||
|
close: 'Close',
|
||||||
|
previous: 'Previous photo',
|
||||||
|
next: 'Next photo',
|
||||||
|
counter: '{current} / {total}',
|
||||||
|
likeCount: '{count} likes',
|
||||||
|
loadFailed: 'Failed to load image',
|
||||||
|
// PHOTO_NOT_PUBLIC 专用:not found / unpublished / hidden share one code (anti-enumeration).
|
||||||
|
photoUnavailable: 'Photo not found or unavailable',
|
||||||
|
backToAlbum: 'Back to album',
|
||||||
|
},
|
||||||
route: { tokenLabel: 'Album token', photoLabel: 'Photo' },
|
route: { tokenLabel: 'Album token', photoLabel: 'Photo' },
|
||||||
errors: {
|
errors: {
|
||||||
albumNotFound: 'Album not found',
|
albumNotFound: 'Album not found',
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ export default {
|
|||||||
retry: '重试',
|
retry: '重试',
|
||||||
},
|
},
|
||||||
lang: { switch: '切换语言' },
|
lang: { switch: '切换语言' },
|
||||||
|
viewer: {
|
||||||
|
title: '照片查看',
|
||||||
|
close: '关闭',
|
||||||
|
previous: '上一张',
|
||||||
|
next: '下一张',
|
||||||
|
counter: '{current} / {total}',
|
||||||
|
likeCount: '{count} 次点赞',
|
||||||
|
loadFailed: '图片加载失败',
|
||||||
|
photoRemoved: '照片已被移除',
|
||||||
|
// PHOTO_NOT_PUBLIC 专用:不存在/未发布/已隐藏同码(防枚举),文案覆盖两种成因。
|
||||||
|
photoUnavailable: '照片不存在或已下线',
|
||||||
|
backToAlbum: '返回相册',
|
||||||
|
},
|
||||||
route: { tokenLabel: '相册令牌', photoLabel: '照片' },
|
route: { tokenLabel: '相册令牌', photoLabel: '照片' },
|
||||||
errors: {
|
errors: {
|
||||||
albumNotFound: '相册不存在',
|
albumNotFound: '相册不存在',
|
||||||
|
|||||||
@@ -1,23 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { ApiError, NETWORK_ERROR_CODE, getPublicAlbum, type PublicAlbum } from '../api/client';
|
import { ApiError, NETWORK_ERROR_CODE, getPublicAlbum, getPublicPhoto, type PublicAlbum } from '../api/client';
|
||||||
import { errorMessageKey } from '../api/errors';
|
import { errorMessageKey } from '../api/errors';
|
||||||
import PhotoFeed from '../components/PhotoFeed.vue';
|
import PhotoFeed from '../components/PhotoFeed.vue';
|
||||||
|
import PhotoLightbox from '../components/PhotoLightbox.vue';
|
||||||
|
import { usePhotoSequence } from '../feed/sequence';
|
||||||
|
|
||||||
// 观众活动页(P1b Task 6):信息区 + 空态 + 时区展示;瀑布流(Task 7)与大图
|
// 观众活动页(P1b Task 6 信息区 + Task 7 瀑布流 + Task 8 大图查看器)。
|
||||||
// (Task 8,复用本组件于 /a/:token/p/:photoId 深链)随后补充。
|
|
||||||
//
|
//
|
||||||
// 语言模型(文档化决策):
|
// 语言模型(文档化决策):
|
||||||
// · UI 语言 = 用户选择(i18n locale,LocaleSwitcher 切换,localStorage 持久);
|
// · UI 语言 = 用户选择(i18n locale,LocaleSwitcher 切换,localStorage 持久);
|
||||||
// · 内容语言 = 同一选择值作为 ?locale= 查询参数传给公开端点;
|
// · 内容语言 = 同一选择值作为 ?locale= 查询参数传给公开端点;
|
||||||
// · resolvedLocale(响应)仅说明服务端实际返回的翻译语言(请求语言缺翻译时
|
// · resolvedLocale(响应)仅说明服务端实际返回的翻译语言(请求语言缺翻译时
|
||||||
// 回退 defaultLocale)——不反向驱动 UI 语言,避免「切了英文却被拽回中文」。
|
// 回退 defaultLocale)——不反向驱动 UI 语言,避免「切了英文却被拽回中文」。
|
||||||
|
//
|
||||||
|
// 大图查看器编排(Task 8):
|
||||||
|
// · 序列状态(分页/游标/轮询)在本页创建一次(usePhotoSequence),PhotoFeed 与
|
||||||
|
// PhotoLightbox 共享——大图左右切换保持列表浏览序列,越过末尾自动扩展下一页;
|
||||||
|
// · 当前照片以 photoId(v-model)为键,序列重排(深链 solo 归位/条目移除)时
|
||||||
|
// 指向自动保持;
|
||||||
|
// · 深链 /a/:token/p/:photoId:先取单图立即开大图(序列在背后继续加载),
|
||||||
|
// 目标不在已加载序列时以 solo 条目兜底入尾,最多再拉 DEEP_LINK_PAGE_CAP 页
|
||||||
|
// 寻找自然位置(文档化折中:防深链老照片触发全量翻页;超限保留 solo);
|
||||||
|
// · URL 同步:点击打开 = push 深链路由;大图内切换/回退邻居 = replace(不产生
|
||||||
|
// 多余历史条目);关闭 = push 过则 history.back()(浏览器后退同理关闭),
|
||||||
|
// 深链直达(非本会话 push)则 replace 回相册路由;
|
||||||
|
// · 关闭回位:打开时记录 window.scrollY,关闭(含浏览器后退触发)恢复——
|
||||||
|
// 瀑布流为绝对定位布局,列表未被触碰,恢复即精确回位。
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
const token = computed(() => String(route.params.token ?? ''));
|
const token = computed(() => String(route.params.token ?? ''));
|
||||||
|
const routePhotoId = computed(() => (route.params.photoId === undefined ? null : String(route.params.photoId)));
|
||||||
|
|
||||||
const album = ref<PublicAlbum | null>(null);
|
const album = ref<PublicAlbum | null>(null);
|
||||||
const loadErrorKey = ref<string | null>(null);
|
const loadErrorKey = ref<string | null>(null);
|
||||||
@@ -95,12 +113,170 @@ const venueText = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 分类筛选(Task 7):id 级持有(语言切换只换名称,选中态跨语言保留——
|
// 分类筛选(Task 7):id 级持有(语言切换只换名称,选中态跨语言保留——
|
||||||
// PhotoFeed 收到同一 category 值不重置,locale 变化才整表重取)。
|
// 同一 category 值不重置,locale 变化才整表重取)。
|
||||||
const selectedCategory = ref<string | null>(null);
|
const selectedCategory = ref<string | null>(null);
|
||||||
|
|
||||||
function selectCategory(categoryId: string | null): void {
|
function selectCategory(categoryId: string | null): void {
|
||||||
selectedCategory.value = categoryId;
|
selectedCategory.value = categoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// —— 照片序列(Task 8 提升:feed + lightbox 共享) ——
|
||||||
|
|
||||||
|
const sequence = usePhotoSequence({ token, locale, category: selectedCategory });
|
||||||
|
|
||||||
|
// —— 大图查看器(Task 8) ——
|
||||||
|
|
||||||
|
/** 深链解析的翻页上限(含首页):超过后目标照片保留 solo 条目(见文件头注)。 */
|
||||||
|
const DEEP_LINK_PAGE_CAP = 3;
|
||||||
|
|
||||||
|
const lightboxPhotoId = ref<string | null>(null);
|
||||||
|
/** 深链目标 404(PHOTO_NOT_PUBLIC):页内本地化提示 + 返回相册链接。 */
|
||||||
|
const deepLinkNotFound = ref(false);
|
||||||
|
/** 深链单图请求失败(网络等):本地化提示 + 重试。 */
|
||||||
|
const deepLinkErrorKey = ref<string | null>(null);
|
||||||
|
/** 照片经刷新确认下线:轻提示(role=status,数秒后自动消失)。 */
|
||||||
|
const photoRemovedNotice = ref(false);
|
||||||
|
let removedNoticeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
let savedScrollY = 0;
|
||||||
|
/** 本会话内点击 push 过深链路由 → 关闭走 history.back();深链直达则 replace。 */
|
||||||
|
let pushedByUs = false;
|
||||||
|
/** 深链解析竞态令牌:仅最新一次解析可落地。 */
|
||||||
|
let resolveSequence = 0;
|
||||||
|
/** 最近一次大图就位(打开或深链解析)所属 token:识别「路由目标=自己 push 的」。 */
|
||||||
|
let resolvedToken = '';
|
||||||
|
|
||||||
|
const currentPhoto = computed(() => {
|
||||||
|
if (lightboxPhotoId.value === null) return null;
|
||||||
|
return sequence.photos.find((photo) => photo.photoId === lightboxPhotoId.value) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function showRemovedNotice(): void {
|
||||||
|
photoRemovedNotice.value = true;
|
||||||
|
if (removedNoticeTimer !== undefined) clearTimeout(removedNoticeTimer);
|
||||||
|
removedNoticeTimer = setTimeout(() => {
|
||||||
|
photoRemovedNotice.value = false;
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 瀑布流单元格点击 → 打开大图:记录滚动锚点并 push 深链路由。 */
|
||||||
|
async function openFromFeed(photoId: string): Promise<void> {
|
||||||
|
savedScrollY = window.scrollY;
|
||||||
|
pushedByUs = true;
|
||||||
|
resolvedToken = token.value;
|
||||||
|
// 先就位再 push:routePhotoId watcher 看到路由目标与当前照片一致即跳过,
|
||||||
|
// 避免自触发深链重解析(会冲掉 pushedByUs 并造成开-关闪烁)。
|
||||||
|
lightboxPhotoId.value = photoId;
|
||||||
|
await router.push({ name: 'activity-photo', params: { token: token.value, photoId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭大图:恢复滚动锚点;push 过 → back()(历史出栈),深链直达 → replace 回相册。 */
|
||||||
|
function closeLightbox(): void {
|
||||||
|
if (lightboxPhotoId.value === null) return;
|
||||||
|
lightboxPhotoId.value = null;
|
||||||
|
deepLinkNotFound.value = false;
|
||||||
|
deepLinkErrorKey.value = null;
|
||||||
|
window.scrollTo(0, savedScrollY);
|
||||||
|
if (pushedByUs) {
|
||||||
|
pushedByUs = false;
|
||||||
|
router.back();
|
||||||
|
} else if (route.name === 'activity-photo') {
|
||||||
|
void router.replace({ name: 'activity', params: { token: token.value } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 深链/前进导航进入照片路由:先取单图立即开大图,再由相邻页 watcher 构成序列。 */
|
||||||
|
async function openFromRoute(photoId: string): Promise<void> {
|
||||||
|
const run = ++resolveSequence;
|
||||||
|
resolvedToken = token.value;
|
||||||
|
deepLinkNotFound.value = false;
|
||||||
|
deepLinkErrorKey.value = null;
|
||||||
|
savedScrollY = window.scrollY;
|
||||||
|
pushedByUs = false;
|
||||||
|
lightboxPhotoId.value = null;
|
||||||
|
|
||||||
|
let detail: Awaited<ReturnType<typeof getPublicPhoto>>;
|
||||||
|
try {
|
||||||
|
detail = await getPublicPhoto(token.value, photoId);
|
||||||
|
} catch (error) {
|
||||||
|
if (run !== resolveSequence) return;
|
||||||
|
if (error instanceof ApiError && error.code === 'PHOTO_NOT_PUBLIC') {
|
||||||
|
deepLinkNotFound.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deepLinkErrorKey.value = error instanceof ApiError ? errorMessageKey(error.code) : errorMessageKey(NETWORK_ERROR_CODE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (run !== resolveSequence) return;
|
||||||
|
// 序列已含该照片(首页先到)→ 直接就位;否则 solo 兜底入尾(可立即查看/切换)。
|
||||||
|
if (sequence.indexOf(photoId) === -1) sequence.ensureInSequence(detail);
|
||||||
|
lightboxPhotoId.value = photoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深链入口(immediate 覆盖首挂;token 变化时对同 photoId 重新解析)。
|
||||||
|
// 自己 push/replace 造成的路由变化(目标与当前照片一致且同 token)不重解析。
|
||||||
|
watch(
|
||||||
|
[routePhotoId, token],
|
||||||
|
() => {
|
||||||
|
const photoId = routePhotoId.value;
|
||||||
|
if (photoId === null) return;
|
||||||
|
if (photoId === lightboxPhotoId.value && token.value === resolvedToken) return;
|
||||||
|
void openFromRoute(photoId);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 深链相邻页:目标仍是 solo 且未达翻页上限 → 追拉下一页,直到自然位置出现
|
||||||
|
//(integratePage 自动以页内副本取代 solo)或上限/游标耗尽(保留 solo,文档化)。
|
||||||
|
watch(
|
||||||
|
() => [sequence.photos, sequence.initialLoading, sequence.initialErrorKey, sequence.hasMore, lightboxPhotoId.value] as const,
|
||||||
|
() => {
|
||||||
|
const photoId = lightboxPhotoId.value;
|
||||||
|
if (photoId === null) return;
|
||||||
|
if (!sequence.isSolo(photoId)) return;
|
||||||
|
if (sequence.initialLoading || sequence.initialErrorKey !== null) return;
|
||||||
|
if (sequence.pagesLoaded >= DEEP_LINK_PAGE_CAP || !sequence.hasMore || sequence.loadingMore) return;
|
||||||
|
void sequence.loadMore();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 大图内切换/邻居回退 → replace 深链路由(不产生多余历史条目)。
|
||||||
|
watch(lightboxPhotoId, (photoId) => {
|
||||||
|
if (photoId === null) return;
|
||||||
|
if (route.name !== 'activity-photo' || routePhotoId.value === photoId) return;
|
||||||
|
void router.replace({ name: 'activity-photo', params: { token: token.value, photoId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 浏览器后退/前进离开照片路由:同步关闭大图(不再动历史),恢复滚动锚点。
|
||||||
|
watch(
|
||||||
|
() => route.name,
|
||||||
|
(name) => {
|
||||||
|
if (name !== 'activity') return;
|
||||||
|
if (lightboxPhotoId.value !== null) {
|
||||||
|
lightboxPhotoId.value = null;
|
||||||
|
pushedByUs = false;
|
||||||
|
window.scrollTo(0, savedScrollY);
|
||||||
|
}
|
||||||
|
deepLinkNotFound.value = false;
|
||||||
|
deepLinkErrorKey.value = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 大图确认照片下线(404 刷新):从序列移除,回退到原位邻居(或关闭),轻提示。 */
|
||||||
|
function onPhotoRemoved(photoId: string, neighborIndex: number): void {
|
||||||
|
showRemovedNotice();
|
||||||
|
if (lightboxPhotoId.value !== photoId) return;
|
||||||
|
const fallback = sequence.photos[Math.min(neighborIndex, sequence.photos.length - 1)];
|
||||||
|
if (fallback === undefined) {
|
||||||
|
closeLightbox();
|
||||||
|
} else {
|
||||||
|
lightboxPhotoId.value = fallback.photoId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (removedNoticeTimer !== undefined) clearTimeout(removedNoticeTimer);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -119,7 +295,7 @@ function selectCategory(categoryId: string | null): void {
|
|||||||
:src="album.banner.url"
|
:src="album.banner.url"
|
||||||
:alt="album.translation.title"
|
:alt="album.translation.title"
|
||||||
@error="bannerFailed = true"
|
@error="bannerFailed = true"
|
||||||
/>
|
>
|
||||||
|
|
||||||
<header class="info">
|
<header class="info">
|
||||||
<h1 class="title">{{ album.translation.title }}</h1>
|
<h1 class="title">{{ album.translation.title }}</h1>
|
||||||
@@ -136,6 +312,17 @@ function selectCategory(categoryId: string | null): void {
|
|||||||
</dl>
|
</dl>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 深链目标不可见(404 防枚举同码):页内本地化提示 + 返回相册。 -->
|
||||||
|
<div v-if="deepLinkNotFound || deepLinkErrorKey !== null" class="deep-link-notice" role="alert">
|
||||||
|
<p>{{ deepLinkNotFound ? t('viewer.photoUnavailable') : t(deepLinkErrorKey ?? 'errors.unknown') }}</p>
|
||||||
|
<div class="notice-actions">
|
||||||
|
<RouterLink :to="{ name: 'activity', params: { token } }">{{ t('viewer.backToAlbum') }}</RouterLink>
|
||||||
|
<button v-if="deepLinkErrorKey !== null" type="button" @click="routePhotoId !== null && openFromRoute(routePhotoId)">
|
||||||
|
{{ t('album.retry') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 分类筛选 chips:全部 + 各分类;选中态高亮(aria-pressed)。 -->
|
<!-- 分类筛选 chips:全部 + 各分类;选中态高亮(aria-pressed)。 -->
|
||||||
<nav v-if="album.categories.length > 0" class="categories" :aria-label="t('album.categories')">
|
<nav v-if="album.categories.length > 0" class="categories" :aria-label="t('album.categories')">
|
||||||
<button
|
<button
|
||||||
@@ -160,10 +347,22 @@ function selectCategory(categoryId: string | null): void {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- 瀑布流(Task 7):懒加载/无限滚动/轮询提示在 PhotoFeed 内实现。 -->
|
<!-- 瀑布流(Task 7):序列数据在 PhotoSequence(Task 8 共享给大图)。 -->
|
||||||
<PhotoFeed :token="token" :locale="locale" :category="selectedCategory" />
|
<PhotoFeed :sequence="sequence" @open="openFromFeed" />
|
||||||
<!-- viewCount 契约占位 null:不渲染任何浏览计数节点。 -->
|
<!-- viewCount 契约占位 null:不渲染任何浏览计数节点。 -->
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 大图查看器(Task 8):当前照片以 photoId 键控(v-model)。 -->
|
||||||
|
<PhotoLightbox
|
||||||
|
v-if="lightboxPhotoId !== null && currentPhoto !== null"
|
||||||
|
v-model:photo-id="lightboxPhotoId"
|
||||||
|
:sequence="sequence"
|
||||||
|
@removed="onPhotoRemoved"
|
||||||
|
@close="closeLightbox"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 照片下线轻提示(大图内 404 刷新确认移除时)。 -->
|
||||||
|
<p v-if="photoRemovedNotice" class="removed-notice" role="status">{{ t('viewer.photoRemoved') }}</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -238,4 +437,42 @@ function selectCategory(categoryId: string | null): void {
|
|||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
color: #1d4ed8;
|
color: #1d4ed8;
|
||||||
}
|
}
|
||||||
|
.deep-link-notice {
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fffbeb;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
.deep-link-notice p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.notice-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.notice-actions a {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.notice-actions button {
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.removed-notice {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
margin: 0;
|
||||||
|
z-index: 110;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(17, 24, 39, 0.9);
|
||||||
|
color: #f9fafb;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// apps/viewer/src/viewer/zoom.ts —— 大图查看器(Task 8)缩放/平移纯状态机:
|
||||||
|
// 无 DOM 依赖的数学层,供 PhotoLightbox 以 PointerEvent/wheel 驱动、vitest 直接覆盖。
|
||||||
|
//
|
||||||
|
// 模型:图片经 CSS `translate(tx,ty) scale(s)`(transform-origin 居中)渲染,
|
||||||
|
// 图片上距中心 (px,py) 的点落在屏幕 (px*s+tx, py*s+ty)(相对中心)。据此:
|
||||||
|
// · zoomAt:以 (cx,cy)(相对图片中心)为不动点缩放——该点缩放前后屏幕位置不变,
|
||||||
|
// 反解 t' = t + c*(s-s');
|
||||||
|
// · scale 钳制 [MIN_SCALE, MAX_SCALE];回到 1 时平移归零(复位对齐居中);
|
||||||
|
// · panBy 仅在 scale>1 生效(scale=1 无可平移的溢出边)。
|
||||||
|
|
||||||
|
export interface ZoomState {
|
||||||
|
scale: number;
|
||||||
|
tx: number;
|
||||||
|
ty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MIN_SCALE = 1;
|
||||||
|
export const MAX_SCALE = 4;
|
||||||
|
|
||||||
|
export const INITIAL_ZOOM: ZoomState = { scale: MIN_SCALE, tx: 0, ty: 0 };
|
||||||
|
|
||||||
|
export function clampScale(scale: number): number {
|
||||||
|
return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复位(双击/双触/切换照片时)。 */
|
||||||
|
export function resetZoom(): ZoomState {
|
||||||
|
return { ...INITIAL_ZOOM };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 以 (cx,cy) 为不动点缩放 factor 倍(>1 放大);钳制后返回新状态(不修改入参)。 */
|
||||||
|
export function zoomAt(state: ZoomState, factor: number, cx: number, cy: number): ZoomState {
|
||||||
|
const scale = clampScale(state.scale * factor);
|
||||||
|
if (scale === state.scale) return state;
|
||||||
|
if (scale === MIN_SCALE) return resetZoom();
|
||||||
|
return {
|
||||||
|
scale,
|
||||||
|
tx: state.tx + cx * (state.scale - scale),
|
||||||
|
ty: state.ty + cy * (state.scale - scale),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按指针位移平移;scale=1 时为无操作(无可平移空间)。 */
|
||||||
|
export function panBy(state: ZoomState, dx: number, dy: number): ZoomState {
|
||||||
|
if (state.scale <= MIN_SCALE) return state;
|
||||||
|
return { scale: state.scale, tx: state.tx + dx, ty: state.ty + dy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** wheel 事件 → 缩放因子:deltaY<0(上滚/外张)放大;指数映射让手感均匀。 */
|
||||||
|
export function wheelFactor(deltaY: number): number {
|
||||||
|
const WHEEL_SENSITIVITY = 0.0022;
|
||||||
|
return Math.exp(-deltaY * WHEEL_SENSITIVITY);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user