feat(viewer): photo viewer with zoom, navigation and deep link
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { computed, defineComponent } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApiError,
|
||||
@@ -9,10 +10,13 @@ import {
|
||||
} from '../api/client';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import PhotoFeed from '../components/PhotoFeed.vue';
|
||||
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
|
||||
|
||||
// PhotoFeed(Task 7)组件测试:api client 以真实模块为底、替身 getPublicPhotos /
|
||||
// getPublicPhoto;IntersectionObserver / ResizeObserver 以可控假件替换(jsdom 无实现),
|
||||
// clientWidth 以原型 getter mock(jsdom 恒 0)。布局纯函数已在 feed-layout.spec 覆盖。
|
||||
// PhotoFeed(Task 7;Task 8 起数据层在 PhotoSequence)组件测试:api client 以真实
|
||||
// 模块为底、替身 getPublicPhotos / getPublicPhoto;IntersectionObserver /
|
||||
// ResizeObserver 以可控假件替换(jsdom 无实现),clientWidth 以原型 getter mock
|
||||
// (jsdom 恒 0)。序列数据/轮询已在 sequence.spec.ts 覆盖;布局纯函数在
|
||||
// feed-layout.spec。宿主组件持 sequence(对齐 ActivityPage 的真实组装方式)。
|
||||
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
@@ -109,20 +113,32 @@ function makeList(items: PublicPhoto[], nextCursor: string | null = null): Publi
|
||||
let i18n: ViewerI18n;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
function mountFeed(props: { token?: string; locale?: string; category?: string | null } = {}): VueWrapper {
|
||||
const wrapper = mount(PhotoFeed, {
|
||||
global: { plugins: [i18n] },
|
||||
props: {
|
||||
token: props.token ?? 'tok-feed',
|
||||
locale: props.locale ?? 'zh-CN',
|
||||
category: props.category ?? null,
|
||||
/** 宿主:创建 sequence 并渲染 PhotoFeed(镜像 ActivityPage 组装);捕获 open 事件。 */
|
||||
function mountFeed(props: { token?: string; locale?: string; category?: string | null } = {}): {
|
||||
wrapper: VueWrapper;
|
||||
sequence: PhotoSequence;
|
||||
opened: string[];
|
||||
} {
|
||||
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);
|
||||
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(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
@@ -144,11 +160,10 @@ describe('photo feed (Task 7)', () => {
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders first page as absolute-positioned cells with reserved heights', async () => {
|
||||
const wrapper = mountFeed();
|
||||
const { wrapper } = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const wrapper = mountFeed();
|
||||
const { wrapper } = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
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 () => {
|
||||
const wrapper = mountFeed();
|
||||
const { wrapper } = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
const sentinel = wrapper.find('.sentinel');
|
||||
@@ -199,59 +222,9 @@ describe('photo feed (Task 7)', () => {
|
||||
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not fire a second page fetch while one is in flight', async () => {
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockClear();
|
||||
|
||||
let resolvePage!: (value: PublicPhotoList) => void;
|
||||
mockedGetPublicPhotos.mockReturnValueOnce(
|
||||
new Promise<PublicPhotoList>((resolve) => {
|
||||
resolvePage = resolve;
|
||||
}),
|
||||
);
|
||||
const sentinel = wrapper.find('.sentinel');
|
||||
sentinelObserver().trigger([sentinel.element], true);
|
||||
sentinelObserver().trigger([sentinel.element], true);
|
||||
await flushPromises();
|
||||
expect(mockedGetPublicPhotos).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolvePage(makeList([makePhoto('p4')], null));
|
||||
await flushPromises();
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('switching category resets the list and passes the filter through', async () => {
|
||||
const wrapper = mountFeed();
|
||||
await flushPromises();
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('c1p1'), makePhoto('c1p2')], null));
|
||||
|
||||
await wrapper.setProps({ category: 'cat-1' });
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
|
||||
'tok-feed',
|
||||
expect.objectContaining({ category: 'cat-1', locale: 'zh-CN' }),
|
||||
);
|
||||
expect(wrapper.findAll('.cell')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('locale switch re-fetches the current category (filter preserved)', async () => {
|
||||
const wrapper = mountFeed({ category: 'cat-1' });
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.setProps({ locale: 'en' });
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
|
||||
'tok-feed',
|
||||
expect.objectContaining({ locale: 'en', category: 'cat-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the localized empty state when the album has no photos', async () => {
|
||||
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([]));
|
||||
const wrapper = mountFeed();
|
||||
const { wrapper } = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('.empty-notice').text()).toBe('照片即将上线');
|
||||
@@ -262,7 +235,7 @@ describe('photo feed (Task 7)', () => {
|
||||
mockedGetPublicPhotos
|
||||
.mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network down', 0, null))
|
||||
.mockResolvedValueOnce(makeList([makePhoto('p1')], null));
|
||||
const wrapper = mountFeed();
|
||||
const { wrapper } = mountFeed();
|
||||
await flushPromises();
|
||||
|
||||
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 () => {
|
||||
const wrapper = mountFeed();
|
||||
const { wrapper } = mountFeed();
|
||||
await flushPromises();
|
||||
lazyObserver().trigger(wrapper.findAll('.cell').map((cell) => cell.element), true);
|
||||
await vi.waitFor(() => expect(wrapper.findAll('img.cell-img')).toHaveLength(3));
|
||||
@@ -298,83 +271,3 @@ describe('photo feed (Task 7)', () => {
|
||||
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../App.vue';
|
||||
import { getPublicAlbum } from '../api/client';
|
||||
import { getPublicAlbum, getPublicPhoto } from '../api/client';
|
||||
import { createViewerI18n } from '../i18n';
|
||||
import { router } from '../router';
|
||||
import { makeAlbum } from './fixtures';
|
||||
|
||||
// 路由解析(Task 5 脚手架测试对齐 Task 6 真实活动页):token/photoId 参数经
|
||||
// api mock 断言——活动页以 token 取数,photoId 供 Task 8 深链定位(不影响取数)。
|
||||
// getPublicPhotos 一并替身为空页(Task 7 起活动页内嵌 PhotoFeed)。
|
||||
// api mock 断言——活动页以 token 取数,photoId 驱动 Task 8 深链(单图先行 + 序列
|
||||
// 兜底)。getPublicPhotos / getPublicPhoto 一并替身(Task 7 起活动页内嵌 PhotoFeed,
|
||||
// Task 8 起深链取单图)。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return {
|
||||
...actual,
|
||||
getPublicAlbum: vi.fn(),
|
||||
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
|
||||
getPublicPhoto: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
|
||||
|
||||
// 逐挂载独立 i18n + 逐用例卸载(背景见 activity.spec.ts 头注)。
|
||||
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 () => {
|
||||
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.isReady();
|
||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
@@ -56,8 +69,10 @@ describe('viewer routing', () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(mockedGetPublicAlbum).toHaveBeenCalledWith('tok-abc', 'zh-CN');
|
||||
expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-abc', 'photo-42');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
ApiError,
|
||||
NETWORK_ERROR_CODE,
|
||||
getPublicPhoto,
|
||||
getPublicPhotos,
|
||||
type PublicPhoto,
|
||||
type PublicPhotoList,
|
||||
} from '../api/client';
|
||||
import { errorMessageKey } from '../api/errors';
|
||||
import { containsNewPhotoId, layoutWaterfall, mergeUniqueByPhotoId } from '../feed/layout';
|
||||
import type { PublicPhoto } from '../api/client';
|
||||
import type { PhotoSequence } from '../feed/sequence';
|
||||
import { layoutWaterfall } from '../feed/layout';
|
||||
|
||||
// 观众瀑布流(Task 7):双列绝对定位(短列优先,条目高按 browse 宽高比例预留)
|
||||
// + IntersectionObserver 懒加载 + 游标无限滚动 + 分类筛选 + 新照片轮询提示。
|
||||
// 观众瀑布流(Task 7;Task 8 起数据层上提):双列绝对定位(短列优先,条目高按
|
||||
// browse 宽高比例预留)+ IntersectionObserver 懒加载 + 哨兵无限滚动 + 新照片提示条。
|
||||
// 数据生命周期(分页/去重/轮询/深链 solo)在 ActivityPage 创建的 PhotoSequence
|
||||
// composable(feed/sequence.ts)——feed 与大图查看器共享同一序列,切换大图时
|
||||
// 保持浏览顺序、越过末尾自动扩展。
|
||||
//
|
||||
// 关键决策:
|
||||
// · 懒加载 = 仅当单元格接近视口才绑定 <img src>(等价 data-src → src 换绑);
|
||||
// shownIds 集合跨刷新保留——刷新只换签名 URL,同 id 单元格不必重新判定;
|
||||
// IO 不可用(旧环境/jsdom)时退化为立即加载,功能不缺失。
|
||||
// · 图片 onerror 重试一次:调单图端点换新签名 URL 再换绑;再失败则收起该图
|
||||
// (占位底色仍在,布局因高度预留不跳动)。
|
||||
// · 轮询(30s,e2e 可用 window.__viewerPollMs 覆盖)只取第 1 页对比 id 集合,
|
||||
// 命中新照片只亮提示条——不自动插入(避免滚动位置跳动,需求 1.2);
|
||||
// 点击提示条 = 整表刷新回第 1 页并回顶。标签页隐藏暂停(R26 同精神)。
|
||||
// · 竞态防护:feedSequence 令牌——分类/语言/token 切换或刷新会使在途的
|
||||
// 分页/轮询结果作废;轮询失败静默(网络抖动不应打扰观众)。
|
||||
// 关键决策(沿用 Task 7):
|
||||
// · 懒加载 = 仅当单元格接近视口才绑定 <img src>;shownIds 集合跨刷新保留——
|
||||
// 刷新只换签名 URL,同 id 单元格不必重新判定;IO 不可用(旧环境/jsdom)时
|
||||
// 退化为立即加载,功能不缺失。
|
||||
// · 图片 onerror 重试一次:经 sequence.refreshPhotoUrls 换新签名 URL 再换绑;
|
||||
// 再失败(或照片已下线)则收起该图(占位底色仍在,布局因高度预留不跳动)。
|
||||
// · 单元格点击 emit('open', photoId) → ActivityPage 打开大图(Task 8)。
|
||||
|
||||
const props = defineProps<{
|
||||
token: string;
|
||||
locale: string;
|
||||
/** 当前分类筛选(null = 全部)。由活动页 chips 持有,切换即重置列表。 */
|
||||
category: string | null;
|
||||
sequence: PhotoSequence;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [photoId: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const photos = ref<PublicPhoto[]>([]);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMore = ref(false);
|
||||
const initialLoading = ref(true);
|
||||
const initialErrorKey = ref<string | null>(null);
|
||||
const loadingMore = ref(false);
|
||||
const moreFailed = ref(false);
|
||||
const hasNew = ref(false);
|
||||
const photos = computed(() => props.sequence.photos);
|
||||
const initialLoading = computed(() => props.sequence.initialLoading);
|
||||
const initialErrorKey = computed(() => props.sequence.initialErrorKey);
|
||||
const loadingMore = computed(() => props.sequence.loadingMore);
|
||||
const moreFailed = computed(() => props.sequence.moreFailed);
|
||||
const hasMore = computed(() => props.sequence.hasMore);
|
||||
const hasNew = computed(() => props.sequence.hasNew);
|
||||
|
||||
// 懒加载/重试视图状态(按 photoId,跨刷新保留,见顶部决策注)。
|
||||
const shownIds = reactive(new Set<string>());
|
||||
const retriedIds = reactive(new Set<string>());
|
||||
const failedIds = reactive(new Set<string>());
|
||||
|
||||
let feedSequence = 0;
|
||||
|
||||
function fetchPage(cursor?: string): Promise<PublicPhotoList> {
|
||||
return getPublicPhotos(props.token, {
|
||||
locale: props.locale,
|
||||
category: props.category ?? undefined,
|
||||
cursor,
|
||||
});
|
||||
}
|
||||
|
||||
/** 整表重置加载(首载/分类切换/语言切换/token 切换/新照片刷新共用)。 */
|
||||
async function resetAndLoad(): Promise<void> {
|
||||
const sequence = ++feedSequence;
|
||||
initialLoading.value = true;
|
||||
initialErrorKey.value = null;
|
||||
moreFailed.value = false;
|
||||
loadingMore.value = false;
|
||||
hasNew.value = false;
|
||||
photos.value = [];
|
||||
nextCursor.value = null;
|
||||
hasMore.value = false;
|
||||
pruneCellElements();
|
||||
try {
|
||||
const page = await fetchPage();
|
||||
if (sequence !== feedSequence) return;
|
||||
photos.value = [...page.items];
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMore.value = page.hasMore && page.nextCursor !== null;
|
||||
} catch (error) {
|
||||
if (sequence !== feedSequence) return;
|
||||
const code = error instanceof ApiError ? error.code : NETWORK_ERROR_CODE;
|
||||
initialErrorKey.value = errorMessageKey(code);
|
||||
} finally {
|
||||
if (sequence === feedSequence) initialLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 无限滚动:哨兵进入视口(600px 预载)时取下一页;去重防御性合并。 */
|
||||
async function loadMore(): Promise<void> {
|
||||
if (initialLoading.value || initialErrorKey.value !== null) return;
|
||||
if (loadingMore.value || !hasMore.value || nextCursor.value === null) return;
|
||||
const sequence = feedSequence;
|
||||
loadingMore.value = true;
|
||||
moreFailed.value = false;
|
||||
try {
|
||||
const page = await fetchPage(nextCursor.value);
|
||||
if (sequence !== feedSequence) return;
|
||||
photos.value = mergeUniqueByPhotoId(photos.value, page.items);
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMore.value = page.hasMore && page.nextCursor !== null;
|
||||
pruneCellElements();
|
||||
} catch {
|
||||
if (sequence === feedSequence) moreFailed.value = true;
|
||||
} finally {
|
||||
if (sequence === feedSequence) loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 新照片提示条点击:整表刷新回第 1 页并回顶(提示消失)。 */
|
||||
async function refreshFromHint(): Promise<void> {
|
||||
if (initialLoading.value) return;
|
||||
await resetAndLoad();
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
// —— 布局:容器宽 → 纯函数双列放置(ResizeObserver 跟踪容器宽度变化) ——
|
||||
|
||||
const trackEl = ref<HTMLElement | null>(null);
|
||||
@@ -191,15 +118,22 @@ function pruneCellElements(): void {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
photos,
|
||||
() => pruneCellElements(),
|
||||
);
|
||||
|
||||
watch(
|
||||
sentinelEl,
|
||||
(el) => {
|
||||
sentinelObserver?.disconnect();
|
||||
sentinelObserver = null;
|
||||
if (el === null) return;
|
||||
// IO 不可用(jsdom/旧环境)时不观察:无限滚动退化为已加载内容(不崩溃)。
|
||||
if (typeof IntersectionObserver === 'undefined') return;
|
||||
sentinelObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) void loadMore();
|
||||
if (entries.some((entry) => entry.isIntersecting)) void props.sequence.loadMore();
|
||||
},
|
||||
{ rootMargin: '600px 0px' },
|
||||
);
|
||||
@@ -208,99 +142,28 @@ watch(
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
/** 缩略图加载失败:重试一次(单图端点换新签名 URL);再失败收起图片。 */
|
||||
/** 缩略图加载失败:重试一次(单图端点换新签名 URL);再失败/已下线则收起图片。 */
|
||||
async function onImgError(photo: PublicPhoto): Promise<void> {
|
||||
const { photoId } = photo;
|
||||
if (failedIds.has(photoId)) return;
|
||||
if (retriedIds.has(photoId)) {
|
||||
failedIds.add(photoId);
|
||||
if (failedIds.has(photo.photoId)) return;
|
||||
if (retriedIds.has(photo.photoId)) {
|
||||
failedIds.add(photo.photoId);
|
||||
return;
|
||||
}
|
||||
retriedIds.add(photoId);
|
||||
try {
|
||||
const detail = await getPublicPhoto(props.token, photoId);
|
||||
photo.thumb = detail.thumb;
|
||||
photo.browse = detail.browse;
|
||||
} catch {
|
||||
failedIds.add(photoId);
|
||||
}
|
||||
retriedIds.add(photo.photoId);
|
||||
const result = await props.sequence.refreshPhotoUrls(photo.photoId);
|
||||
if (result !== 'replaced') failedIds.add(photo.photoId);
|
||||
}
|
||||
|
||||
// —— 新照片轮询 ——
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
/** e2e/开发注入点:window.__viewerPollMs 覆盖轮询间隔(仅测试加速用)。 */
|
||||
function pollIntervalMs(): number {
|
||||
const override = (globalThis as { __viewerPollMs?: unknown }).__viewerPollMs;
|
||||
return typeof override === 'number' && override > 0 ? override : DEFAULT_POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer !== undefined) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
stopPolling();
|
||||
pollTimer = setInterval(() => void pollOnce(), pollIntervalMs());
|
||||
}
|
||||
|
||||
/** 取当前筛选的第 1 页对比 id 集合;发现新照片只亮提示条(不自动插入)。失败静默。 */
|
||||
async function pollOnce(): Promise<void> {
|
||||
if (
|
||||
document.hidden ||
|
||||
initialLoading.value ||
|
||||
initialErrorKey.value !== null ||
|
||||
hasNew.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const sequence = feedSequence;
|
||||
try {
|
||||
const page = await fetchPage();
|
||||
if (sequence !== feedSequence) return;
|
||||
if (containsNewPhotoId(photos.value.map((photo) => photo.photoId), page.items)) {
|
||||
hasNew.value = true;
|
||||
}
|
||||
} catch {
|
||||
// 轮询失败静默:网络抖动不打扰观众(下一次间隔重试)。
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
if (document.hidden) stopPolling();
|
||||
else startPolling();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void resetAndLoad();
|
||||
startPolling();
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling();
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
lazyObserver?.disconnect();
|
||||
sentinelObserver?.disconnect();
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
|
||||
// 分类/语言/token 变化:整表重置(语言切换保留当前分类——由 props 自带)。
|
||||
watch(
|
||||
[() => props.token, () => props.locale, () => props.category],
|
||||
() => void resetAndLoad(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="photo-feed">
|
||||
<button v-if="hasNew" type="button" class="new-photo-bar" @click="refreshFromHint">
|
||||
<button v-if="hasNew" type="button" class="new-photo-bar" @click="props.sequence.refreshFromHint()">
|
||||
{{ t('album.newPhotos') }}
|
||||
</button>
|
||||
|
||||
@@ -308,7 +171,7 @@ watch(
|
||||
|
||||
<div v-else-if="initialErrorKey !== null" class="error" role="alert">
|
||||
<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 v-else-if="photos.length === 0" class="feed">
|
||||
@@ -328,6 +191,7 @@ watch(
|
||||
width: `${layout.columnWidth}px`,
|
||||
height: `${placement.height}px`,
|
||||
}"
|
||||
@click="emit('open', photos[placement.index].photoId)"
|
||||
>
|
||||
<img
|
||||
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>
|
||||
<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>
|
||||
<p v-else-if="!hasMore" class="end-note">{{ t('album.end') }}</p>
|
||||
|
||||
@@ -412,6 +276,7 @@ watch(
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(160deg, #f3f4f6, #e5e7eb);
|
||||
cursor: pointer;
|
||||
}
|
||||
.cell-img {
|
||||
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',
|
||||
},
|
||||
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' },
|
||||
errors: {
|
||||
albumNotFound: 'Album not found',
|
||||
|
||||
@@ -14,6 +14,19 @@ export default {
|
||||
retry: '重试',
|
||||
},
|
||||
lang: { switch: '切换语言' },
|
||||
viewer: {
|
||||
title: '照片查看',
|
||||
close: '关闭',
|
||||
previous: '上一张',
|
||||
next: '下一张',
|
||||
counter: '{current} / {total}',
|
||||
likeCount: '{count} 次点赞',
|
||||
loadFailed: '图片加载失败',
|
||||
photoRemoved: '照片已被移除',
|
||||
// PHOTO_NOT_PUBLIC 专用:不存在/未发布/已隐藏同码(防枚举),文案覆盖两种成因。
|
||||
photoUnavailable: '照片不存在或已下线',
|
||||
backToAlbum: '返回相册',
|
||||
},
|
||||
route: { tokenLabel: '相册令牌', photoLabel: '照片' },
|
||||
errors: {
|
||||
albumNotFound: '相册不存在',
|
||||
|
||||
@@ -1,23 +1,41 @@
|
||||
<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 { useRoute } from 'vue-router';
|
||||
import { ApiError, NETWORK_ERROR_CODE, getPublicAlbum, type PublicAlbum } from '../api/client';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ApiError, NETWORK_ERROR_CODE, getPublicAlbum, getPublicPhoto, type PublicAlbum } from '../api/client';
|
||||
import { errorMessageKey } from '../api/errors';
|
||||
import PhotoFeed from '../components/PhotoFeed.vue';
|
||||
import PhotoLightbox from '../components/PhotoLightbox.vue';
|
||||
import { usePhotoSequence } from '../feed/sequence';
|
||||
|
||||
// 观众活动页(P1b Task 6):信息区 + 空态 + 时区展示;瀑布流(Task 7)与大图
|
||||
// (Task 8,复用本组件于 /a/:token/p/:photoId 深链)随后补充。
|
||||
// 观众活动页(P1b Task 6 信息区 + Task 7 瀑布流 + Task 8 大图查看器)。
|
||||
//
|
||||
// 语言模型(文档化决策):
|
||||
// · UI 语言 = 用户选择(i18n locale,LocaleSwitcher 切换,localStorage 持久);
|
||||
// · 内容语言 = 同一选择值作为 ?locale= 查询参数传给公开端点;
|
||||
// · resolvedLocale(响应)仅说明服务端实际返回的翻译语言(请求语言缺翻译时
|
||||
// 回退 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 router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
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 loadErrorKey = ref<string | null>(null);
|
||||
@@ -95,12 +113,170 @@ const venueText = computed(() => {
|
||||
});
|
||||
|
||||
// 分类筛选(Task 7):id 级持有(语言切换只换名称,选中态跨语言保留——
|
||||
// PhotoFeed 收到同一 category 值不重置,locale 变化才整表重取)。
|
||||
// 同一 category 值不重置,locale 变化才整表重取)。
|
||||
const selectedCategory = ref<string | null>(null);
|
||||
|
||||
function selectCategory(categoryId: string | null): void {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -119,7 +295,7 @@ function selectCategory(categoryId: string | null): void {
|
||||
:src="album.banner.url"
|
||||
:alt="album.translation.title"
|
||||
@error="bannerFailed = true"
|
||||
/>
|
||||
>
|
||||
|
||||
<header class="info">
|
||||
<h1 class="title">{{ album.translation.title }}</h1>
|
||||
@@ -136,6 +312,17 @@ function selectCategory(categoryId: string | null): void {
|
||||
</dl>
|
||||
</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)。 -->
|
||||
<nav v-if="album.categories.length > 0" class="categories" :aria-label="t('album.categories')">
|
||||
<button
|
||||
@@ -160,10 +347,22 @@ function selectCategory(categoryId: string | null): void {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 瀑布流(Task 7):懒加载/无限滚动/轮询提示在 PhotoFeed 内实现。 -->
|
||||
<PhotoFeed :token="token" :locale="locale" :category="selectedCategory" />
|
||||
<!-- 瀑布流(Task 7):序列数据在 PhotoSequence(Task 8 共享给大图)。 -->
|
||||
<PhotoFeed :sequence="sequence" @open="openFromFeed" />
|
||||
<!-- viewCount 契约占位 null:不渲染任何浏览计数节点。 -->
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -238,4 +437,42 @@ function selectCategory(categoryId: string | null): void {
|
||||
background: #eff6ff;
|
||||
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>
|
||||
|
||||
@@ -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