feat(viewer): likes, bubbles, visit reporting and hot sorting

Task 10 viewer 互动化:

- 点赞:useLikes 批次状态机(500ms 防抖 / 5 次满额即冲 / 失败同 batchId
  退避重试 3 次后回滚丢弃),乐观计数 + 服务端 total 权威校准
  (countVersion 单调),大图与格子双入口;
- WS 实时:RealtimeClient 纯状态机(半频心跳、指数退避重连、三败降级、
  重连成功 resync 第 1 页重取),消息路由器做 likes.burst 自身过滤
  (originBatchIds 求交)、隐藏页暂停与减少动画下仅校准不出气泡;
  photo.hidden/deleted 即时移出序列并回退邻居大图;降级后 8s 轮询接管;
- 浏览上报:visitorId 跨标签 BroadcastChannel first-writer-wins 协调,
  可见时上报、requestId 持久幂等、网络失败指数重试;
- 排序:最新/热门 chips(热门走 ov 冷快照),大图打开期间禁用切换;
  顶栏「减少动画」偏好开关;
- 契约包新增 ./realtime 子路径出口(浏览器面不可引桶出口——其连带
  internal-sync 的 node:crypto 会被 Vite 外置化致整页崩溃);
- e2e 注入钩子 __viewerWsUrl/__viewerWsRetryBaseMs/__viewerRealtimePollMs/
  __viewerBubbleMs 与 .activity[data-connection] 连接态锚。

vitest:viewer 105(新增 36:likes 批次 8、visitor 协调 5、上报门控 4、
路由 8、WS 状态机 7、排序 4);Playwright:viewer 21(新增 likes.spec 7、
journey 补互动腿断言)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 19:52:27 +08:00
parent 955bea49b5
commit cdead49838
29 changed files with 2581 additions and 29 deletions
+3 -1
View File
@@ -50,10 +50,12 @@ test('renders album info in zh with timezone-adjusted time, chips and empty stat
await expect(time).not.toContainText('9月30');
await expect(page.locator('.meta')).toContainText('大宴会厅 · 上海市浦东新区世纪大道 100 号');
const chips = page.locator('.chip');
// Task 10 起排序 chips(最新/热门)与分类 chips 并存——断言收窄到分类栏。
const chips = page.locator('.categories .chip');
await expect(chips).toHaveCount(3); // Task 7 起:全部 + 各分类(可点选)
await expect(chips.first()).toBeEnabled();
await expect(chips).toHaveText(['全部', '签到墙', '开场']);
await expect(page.locator('.sort .chip')).toHaveText(['最新', '热门']);
await expect(page.locator('.empty-notice')).toHaveText('照片即将上线');
// 该相册无 banner 资产 → 不渲染图。
+8 -1
View File
@@ -382,7 +382,7 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
.poll(async () => banner.evaluate((img) => (img as HTMLImageElement).naturalWidth))
.toBeGreaterThan(0);
await expect(page.locator('h1.title')).toHaveText(zhTitle);
await expect(page.locator('.chip')).toHaveCount(2); // 全部 + 主舞台
await expect(page.locator('.categories .chip')).toHaveCount(2); // 全部 + 主舞台
await page.locator('button.locale-switcher').click();
await expect(page.locator('h1.title')).toHaveText(enTitle);
@@ -396,6 +396,10 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
await page.getByRole('button', { name: '全部' }).click();
await expect(page.locator('.cell')).toHaveCount(3);
// Task 10 互动化:浏览计数行(首访上报后权威回填)+ 排序 chips 在位。
await expect(page.locator('.view-count')).toHaveText('1', { timeout: 15_000 });
await expect(page.locator('.sort .chip')).toHaveText(['最新', '热门']);
// 大图:点击开图 → 真实字节 → 滚轮缩放 → 双击复位 → 关闭回列表。
const lightboxPhoto = photoIds[1]!; // 800×500(后续 admin 腿按尺寸锚定隐藏)
await page.locator('.cell').nth(1).click();
@@ -403,6 +407,9 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
await expect(
page.locator('img.photo'),
).toHaveAttribute('src', new RegExp(lightboxPhoto));
// Task 10:大图内真实点赞 1 次 → 防抖冲批 → 权威计数 1。
await page.locator('.control.like-button').click();
await expect(page.locator('.like-count')).toHaveText('1');
await expect
.poll(async () =>
page.locator('img.photo').evaluate((img) => (img as HTMLImageElement).naturalWidth),
+263
View File
@@ -0,0 +1,263 @@
import { randomUUID } from 'node:crypto';
import { expect, test, type Page } from '@playwright/test';
import {
cleanupSeededAlbums,
deleteSeededPhoto,
flushSync,
seedAlbum,
seedPhoto,
} from './helpers';
// Task 10 互动化 Playwright(真实后端 + 真实 WS/点赞/浏览管线):
// · 大图真实点赞 ×3 → 计数权威 3 → 刷新持久;
// · 气泡自身过滤(双真实浏览器上下文:他赞出气泡、己赞不出);
// · WS 不可达(__viewerWsUrl 死端口注入;setOffline 不切断已建 WS,见用例注)
// → 三败降级轮询校准计数 → 翻回真实 URL 重连 open;
// · 照片下线 WS 推送 → 序列即时移除 + 大图回退邻居(deleteSeededPhoto+flushSync);
// · 排序切换(最新/热门):API 直推 B 点赞 → 热门置顶;大图开时 chips 禁用;
// · 浏览上报:全新上下文计数 +1,刷新不重复,第二上下文再 +1;
// · 减少动画:他赞无气泡、计数照常校准。
// e2e 注入点:__viewerRealtimePollMs / __viewerWsRetryBaseMs / __viewerBubbleMs /
// __viewerWsUrl(沿用 __viewerPollMs 约定);连接态锚 .activity[data-connection]。
const OV_BASE = 'http://127.0.0.1:8081/v1';
test.afterAll(async () => {
await cleanupSeededAlbums();
});
/** 直推一个点赞批次(他者会话面,绕开 UI 时序):返回权威 total。 */
async function apiLike(token: string, photoId: string, sessionId: string, delta: number): Promise<number> {
const res = await fetch(`${OV_BASE}/public/albums/${token}/photos/${photoId}/likes`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId, batchId: randomUUID(), delta }),
});
expect(res.status).toBe(200);
return ((await res.json()) as { total: number }).total;
}
/** 种「互动相册」:n 张已发布照片(publishedAt 逐张递减,最新序确定)。 */
async function seedInteractiveAlbum(
alias: string,
photos: number,
): Promise<{ albumId: string; token: string; photoIds: string[] }> {
const album = await seedAlbum({
alias,
translations: [{ locale: 'zh-CN', title: `互动测试相册 ${alias}` }],
});
const base = Date.now();
const photoIds: string[] = [];
for (let i = 0; i < photos; i++) {
photoIds.push(
await seedPhoto(album.id, `${alias}-p${i}`, {
width: 480,
height: 300,
publishedAt: new Date(base - (i + 1) * 60_000),
}),
);
}
await flushSync(album.id);
return { albumId: album.id, token: album.publicToken, photoIds };
}
async function openAlbum(page: Page, token: string, init?: () => void): Promise<void> {
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
if (init) await page.addInitScript(init);
await page.goto(`/a/${token}`);
await expect(page.locator('.cell').first()).toBeVisible();
}
test('lightbox likes accumulate to 3 and persist across reload', async ({ page }) => {
test.setTimeout(90_000);
const { token } = await seedInteractiveAlbum('likebox', 1);
await openAlbum(page, token);
await page.locator('.cell').first().click();
await expect(page.locator('.lightbox')).toBeVisible();
for (let i = 0; i < 3; i++) {
await page.locator('.control.like-button').click();
}
// 防抖 500ms 冲批 → 服务端权威校准 3(响应/WS burst 先后到达均为 3)。
await expect(page.locator('.like-count')).toHaveText('3');
// 等批次真正落服务端(乐观显示先于冲批——刷新前必须确认已持久)。
await expect
.poll(async () => {
const res = await fetch(`${OV_BASE}/public/albums/${token}/photos`);
const json = (await res.json()) as { items: Array<{ likeCount: number }> };
return json.items[0]?.likeCount ?? 0;
})
.toBe(3);
// 刷新后从服务端列表回归:深链自动重开大图(URL 仍停在该照片),计数 3;
// 关闭后格子计数亦 3(sessionId 会话内持久,幂等面不变)。
await page.reload();
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('.like-count')).toHaveText('3');
await page.locator('.control.close').click();
await expect(page.locator('.lightbox')).toHaveCount(0);
await expect(page.locator('.cell-like .count').first()).toHaveText('3');
});
test('others burst bubbles on my page; my own like does not', async ({ browser, page }) => {
test.setTimeout(120_000);
const { token } = await seedInteractiveAlbum('bubbles', 1);
const ctx2 = await browser.newContext();
const page2 = await ctx2.newPage();
try {
await openAlbum(page, token, () => {
(window as unknown as { __viewerBubbleMs?: number }).__viewerBubbleMs = 8000;
});
await expect(page.locator('.activity[data-connection="open"]')).toBeVisible();
await openAlbum(page2, token);
await expect(page2.locator('.activity[data-connection="open"]')).toBeVisible();
// 己赞:burst 的 originBatchIds 全属本会话 → 过滤,不出气泡。
await page.locator('.cell-like').first().click();
await page.waitForTimeout(2500); // 冲批(500ms)+广播合并窗(500ms)+裕量
await expect(page.locator('.like-bubble')).toHaveCount(0);
await expect(page.locator('.cell-like .count').first()).toHaveText('1');
// 他赞(page2 真实点击):page1 应出 +1 气泡,点击直达该照片大图。
await page2.locator('.cell-like').first().click();
await expect(page.locator('.like-bubble')).toHaveCount(1);
await expect(page.locator('.like-bubble .bubble-delta')).toHaveText('+1');
await expect(page.locator('.cell-like .count').first()).toHaveText('2');
await page.locator('.like-bubble').click();
await expect(page.locator('.lightbox')).toBeVisible();
} finally {
await ctx2.close();
}
});
test('WS loss degrades to polling and recovers to open with calibrated counts', async ({ page }) => {
test.setTimeout(150_000);
const { token, photoIds } = await seedInteractiveAlbum('degrade', 1);
const photoId = photoIds[0]!;
// 注:Chromium 的 setOffline 不切断已建立的 WS(实测保持 open),中途改 URL 也
// 只影响后续连接——改为「首连即不可达」驱动降级:__viewerWsUrl 指向死端口
// (error+close 3ms、无 open),HTTP 面保持可用,恰好可断言降级轮询的校准职责;
// 运行中翻回真实 URL 验证后台重连恢复。
await openAlbum(page, token, () => {
(window as unknown as Record<string, string>).__viewerWsUrl = 'ws://127.0.0.1:9/dead';
(window as unknown as Record<string, number>).__viewerWsRetryBaseMs = 300;
(window as unknown as Record<string, number>).__viewerRealtimePollMs = 1000;
});
// 三次连接失败(3ms 内 close,退避 300/600/1200ms)→ degraded(轮询接管,此处 1s)。
await expect(page.locator('.activity[data-connection="degraded"]')).toBeVisible({
timeout: 30_000,
});
// 降级期间他者经 API 点赞 2:WS 已死,靠轮询第 1 页重取把计数对齐到 2。
await apiLike(token, photoId, 'e2e-degrade-other', 2);
await expect(page.locator('.cell-like .count').first()).toHaveText('2', { timeout: 15_000 });
await expect(page.locator('.activity[data-connection="degraded"]')).toBeVisible(); // 仍降级(无气泡补播面)
// 翻回真实 URL:后台重连成功 → open,计数维持权威 2。
await page.evaluate(() => {
(window as unknown as Record<string, string>).__viewerWsUrl = '';
});
await expect(page.locator('.activity[data-connection="open"]')).toBeVisible({
timeout: 30_000,
});
await expect(page.locator('.cell-like .count').first()).toHaveText('2');
});
test('hidden photo disappears from the feed and closes the lightbox via WS push', async ({ page }) => {
test.setTimeout(120_000);
const { albumId, token, photoIds } = await seedInteractiveAlbum('remove', 3);
await openAlbum(page, token);
await expect(page.locator('.activity[data-connection="open"]')).toBeVisible();
await expect(page.locator('.cell')).toHaveCount(3);
// 大图停在第 2 张:下线推送应回退邻居并收口(closeLightbox 路径)。
await page.locator('.cell').nth(1).click();
await expect(page.locator('.lightbox')).toBeVisible();
await deleteSeededPhoto(photoIds[1]!);
await flushSync(albumId); // photo.deleted 走真实管线 → ov 撤下 + WS 房间广播
// 推送到达:列表无刷新即时 2 张 + 下线提示;大图回退到后邻(第 3 张)仍可看。
await expect(page.locator('.cell')).toHaveCount(2, { timeout: 20_000 });
await expect(page.locator('.removed-notice')).toBeVisible();
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[2]!));
});
test('sort toggle switches latest/hot snapshots and is disabled in the lightbox', async ({ page }) => {
test.setTimeout(120_000);
const { token, photoIds } = await seedInteractiveAlbum('sort', 2);
const [newer, older] = photoIds as [string, string];
await openAlbum(page, token);
// 最新序:publishedAt 新者(第 1 张种)在前。
await expect(page.locator('.cell-img').first()).toHaveAttribute('src', new RegExp(newer));
// 他者会话直推 older ×10 → 热门序首取即新鲜快照:older 置顶。
await apiLike(token, older, 'e2e-hot-session', 10);
await page.getByRole('button', { name: '热门' }).click();
await expect(page.locator('.cell-img').first()).toHaveAttribute('src', new RegExp(older));
await expect(page.locator('.sort .chip').nth(1)).toHaveClass(/active/);
// 切回最新:原顺序恢复(冷快照语义,各自独立)。
await page.getByRole('button', { name: '最新' }).click();
await expect(page.locator('.cell-img').first()).toHaveAttribute('src', new RegExp(newer));
// 大图打开期间排序切换禁用(快照冻结,避免半开序列换序)。
await page.locator('.cell').first().click();
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('.sort .chip').first()).toBeDisabled();
await expect(page.locator('.sort .chip').nth(1)).toBeDisabled();
});
test('visit counting: fresh context counts once, reload is idempotent, second context counts', async ({
browser,
}) => {
test.setTimeout(120_000);
const { token } = await seedInteractiveAlbum('visits', 1);
const ctx1 = await browser.newContext();
const page1 = await ctx1.newPage();
try {
await openAlbum(page1, token);
// 首访计数(上报成功后以权威 viewCount 回填)。
await expect(page1.locator('.view-count')).toHaveText('1', { timeout: 15_000 });
// 刷新:visitorId 持久 + 窗口去重 → 不再计数。
await page1.reload();
await expect(page1.locator('.cell').first()).toBeVisible();
await expect(page1.locator('.view-count')).toHaveText('1');
// 第二个全新上下文:新 visitorId → 再计数。
const ctx2 = await browser.newContext();
try {
const page2 = await ctx2.newPage();
await openAlbum(page2, token);
await expect(page2.locator('.view-count')).toHaveText('2', { timeout: 15_000 });
} finally {
await ctx2.close();
}
} finally {
await ctx1.close();
}
});
test('reduced motion suppresses bubbles while counts still calibrate', async ({ page }) => {
test.setTimeout(120_000);
const { token, photoIds } = await seedInteractiveAlbum('motion', 1);
await openAlbum(page, token, () => {
(window as unknown as Record<string, number>).__viewerBubbleMs = 8000;
});
await expect(page.locator('.activity[data-connection="open"]')).toBeVisible();
// 顶栏开关减少动画(localStorage 偏好持久)。
await page.locator('.motion-toggle').click();
await expect(page.locator('.motion-toggle')).toHaveAttribute('aria-pressed', 'true');
// 他者点赞:无气泡(动画停用),计数照常被 burst 校准。
await apiLike(token, photoIds[0]!, 'e2e-motion-other', 1);
await page.waitForTimeout(2500); // 广播合并窗 + 裕量:给「本该出气泡」的窗口
await expect(page.locator('.like-bubble')).toHaveCount(0);
await expect(page.locator('.cell-like .count').first()).toHaveText('1');
});
+32 -1
View File
@@ -2,6 +2,7 @@
import { watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import LocaleSwitcher from './components/LocaleSwitcher.vue';
import { reducedMotion, toggleReducedMotion } from './ui/prefs';
const { t, locale } = useI18n();
@@ -16,7 +17,18 @@ watchEffect(() => {
<div class="app">
<header class="topbar">
<span class="brand">{{ t('app.title') }}</span>
<LocaleSwitcher />
<div class="topbar-actions">
<!-- 减少动画(Task 10):隐藏点赞气泡/心跳动画,计数照常更新。 -->
<button
type="button"
class="motion-toggle"
:aria-pressed="reducedMotion"
@click="toggleReducedMotion"
>
{{ t('prefs.reduceMotion') }}
</button>
<LocaleSwitcher />
</div>
</header>
<main class="content">
<RouterView />
@@ -49,6 +61,25 @@ body {
.brand {
font-weight: 600;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.motion-toggle {
border: 1px solid #d1d5db;
border-radius: 6px;
background: #f9fafb;
color: #374151;
padding: 2px 10px;
font-size: 0.875rem;
cursor: pointer;
}
.motion-toggle[aria-pressed='true'] {
border-color: #1d4ed8;
background: #eff6ff;
color: #1d4ed8;
}
.content {
flex: 1;
padding: 16px;
+13 -2
View File
@@ -10,16 +10,27 @@ import { makeAlbum, makeEnAlbum } from './fixtures';
// ActivityPage(Task 6)组件测试:api client 以真实模块为底、仅替身 getPublicAlbum /
// getPublicPhotos(Task 7 起活动页内嵌 PhotoFeed,需喂空页避免真实 fetch),
// 保证 ApiError/errors 映射等真实逻辑参与断言。
// 保证 ApiError/errors 映射等真实逻辑参与断言。Task 10 起活动页组装 WS/浏览
// 上报:RealtimeClient 以无操作桩替换(jsdom WebSocket 会发起真实连接)、
// postAlbumVisit 替身防真实 fetch。
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 }),
postAlbumVisit: vi.fn().mockResolvedValue({ viewCount: 0, counted: false }),
};
});
vi.mock('../realtime/ws', () => {
class RealtimeClientStub {
start(): void {}
stop(): void {}
}
return { RealtimeClient: RealtimeClientStub, wsUrlFromApiBase: () => 'ws://test/v1/realtime' };
});
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
// 逐挂载独立 i18n 实例 + 逐用例卸载:vue-i18n 在 app.unmount 时 dispose 共享实例
@@ -67,7 +78,7 @@ describe('activity page (Task 6)', () => {
// Asia/Shanghai:UTC 10-01 02:00/12:00 → 本地 10-01 10:00/20:00。
expect(wrapper.find('.meta').text()).toContain('10月1日');
const chips = wrapper.findAll('.chip');
const chips = wrapper.findAll('.categories .chip');
expect(chips.map((chip) => chip.text())).toEqual(['全部', '签到墙', '开场']);
expect(chips.every((chip) => chip.attributes('disabled') === undefined)).toBe(true);
+169
View File
@@ -0,0 +1,169 @@
import { mount, type VueWrapper } from '@vue/test-utils';
import { defineComponent, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { postLikeBatch, type LikeBatchResponse } from '../api/client';
import { useLikes, type LikesController } from '../likes/useLikes';
// useLikes(Task 10 点赞状态机):批次防抖冲刷 / 5 次满额即冲 / 失败同 batchId
// 退避重试 / 3 次丢弃回滚 / 权威校准(含他人点赞跳变)/ 自身批次登记(气泡
// 过滤数据面)/ 列表单调重校准。假时钟驱动全部定时路径。
vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>();
return { ...actual, postLikeBatch: vi.fn() };
});
const mockedPostLikeBatch = vi.mocked(postLikeBatch);
function ok(total: number, countVersion = 1): LikeBatchResponse {
return { total, countVersion, batchAccepted: true };
}
const mounted: VueWrapper[] = [];
function mountLikes(): LikesController {
let likes!: LikesController;
const Host = defineComponent({
setup() {
likes = useLikes({ token: ref('tok-likes'), photos: () => [] });
return () => null;
},
});
mounted.push(mount(Host));
return likes;
}
describe('likes batching (Task 10)', () => {
beforeEach(() => {
sessionStorage.clear();
mockedPostLikeBatch.mockReset().mockResolvedValue(ok(0));
vi.useFakeTimers();
});
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('accumulates rapid clicks into one batch flushed on the 500ms debounce', async () => {
mockedPostLikeBatch.mockResolvedValue(ok(3));
const likes = mountLikes();
likes.like('p1');
likes.like('p1');
likes.like('p1');
// 乐观计数立即可见(未冲批)。
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(3);
expect(mockedPostLikeBatch).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(500);
expect(mockedPostLikeBatch).toHaveBeenCalledTimes(1);
const [token, photoId, body] = mockedPostLikeBatch.mock.calls[0]!;
expect(token).toBe('tok-likes');
expect(photoId).toBe('p1');
expect(body.delta).toBe(3);
expect(body.batchId).toMatch(/^[0-9a-f-]{8,}$/);
// 校准后 base=权威 3、pending 清零。
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(3);
});
it('flushes immediately when 5 clicks accumulate (cap path)', async () => {
mockedPostLikeBatch.mockResolvedValue(ok(5));
const likes = mountLikes();
for (let i = 0; i < 5; i++) likes.like('p1');
expect(mockedPostLikeBatch).toHaveBeenCalledTimes(1);
expect(mockedPostLikeBatch.mock.calls[0]![2].delta).toBe(5);
await vi.advanceTimersByTimeAsync(500);
expect(mockedPostLikeBatch).toHaveBeenCalledTimes(1); // 不重复冲
});
it('retries a failed batch with the SAME batchId and calibrates on success', async () => {
mockedPostLikeBatch.mockRejectedValueOnce(new Error('network down')).mockResolvedValue(ok(2));
const likes = mountLikes();
likes.like('p1');
likes.like('p1');
await vi.advanceTimersByTimeAsync(500); // 首发(网络失败)
expect(mockedPostLikeBatch).toHaveBeenCalledTimes(1);
const batchId = mockedPostLikeBatch.mock.calls[0]![2].batchId;
await vi.advanceTimersByTimeAsync(1000); // 退避 1s → 同 batchId 重试成功
expect(mockedPostLikeBatch).toHaveBeenCalledTimes(2);
expect(mockedPostLikeBatch.mock.calls[1]![2].batchId).toBe(batchId);
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(2);
});
it('drops the batch after 3 attempts, rolls back the optimistic delta and notes it', async () => {
const info = vi.spyOn(console, 'info').mockImplementation(() => {});
mockedPostLikeBatch.mockRejectedValue(new Error('network down'));
const likes = mountLikes();
likes.like('p1');
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(1);
await vi.advanceTimersByTimeAsync(500); // 首发(尝试 1)
await vi.advanceTimersByTimeAsync(1000); // 退避 1s(尝试 2)
await vi.advanceTimersByTimeAsync(2000); // 退避 2s(尝试 3 → 丢弃)
expect(mockedPostLikeBatch).toHaveBeenCalledTimes(3);
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(0);
expect(info).toHaveBeenCalledWith(expect.stringContaining('like batch dropped'));
});
it('calibrates to the authoritative total including other viewers likes', async () => {
// 本会话 1 击,他人已累计 7 → 响应 total=8 权威校准。
mockedPostLikeBatch.mockResolvedValue(ok(8));
const likes = mountLikes();
likes.like('p1');
await vi.advanceTimersByTimeAsync(500);
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(8);
});
it('registers accepted batches for burst self-filtering (ownDeltaIn)', async () => {
mockedPostLikeBatch.mockResolvedValueOnce(ok(2)).mockResolvedValueOnce(ok(5));
const likes = mountLikes();
likes.like('p1');
await vi.advanceTimersByTimeAsync(500);
likes.like('p1');
await vi.advanceTimersByTimeAsync(500);
const first = mockedPostLikeBatch.mock.calls[0]![2].batchId;
const second = mockedPostLikeBatch.mock.calls[1]![2].batchId;
expect(likes.ownDeltaIn([first])).toBe(1);
expect(likes.ownDeltaIn([first, second])).toBe(2);
expect(likes.ownDeltaIn(['batch-from-another-session'])).toBe(0);
});
it('recalibrates from list monotonically and keeps in-flight pending', async () => {
mockedPostLikeBatch.mockResolvedValue(ok(0));
const likes = mountLikes();
likes.like('p1');
// 服务端列表显示他人已推到 7(本批在途)→ base 抬升、乐观量保留。
likes.recalibrateFromList([{ photoId: 'p1', likeCount: 7 } as never]);
expect(likes.displayFor({ photoId: 'p1', likeCount: 7 })).toBe(8);
// 滞后快照(4 < 7)不回退 base。
likes.recalibrateFromList([{ photoId: 'p1', likeCount: 4 } as never]);
expect(likes.displayFor({ photoId: 'p1', likeCount: 4 })).toBe(8);
// 未接触过的照片以列表计数为 base。
likes.recalibrateFromList([{ photoId: 'p2', likeCount: 9 } as never]);
expect(likes.displayFor({ photoId: 'p2', likeCount: 9 })).toBe(9);
});
it('ignores stale countVersion calibrations', async () => {
mockedPostLikeBatch.mockResolvedValue(ok(10, 5));
const likes = mountLikes();
likes.like('p1');
await vi.advanceTimersByTimeAsync(500);
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(10);
// 乱序旧版本 burst(version 3 < 5):不回退。
likes.calibrate('p1', 4, 3);
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(10);
// 新版本照常生效。
likes.calibrate('p1', 12, 6);
expect(likes.displayFor({ photoId: 'p1', likeCount: 0 })).toBe(12);
});
});
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from 'vitest';
import type { LikesBurstMessage, ServerMessage } from '@photo/contracts';
import { handleServerMessage, type RealtimeRouterDeps } from '../realtime/router';
// realtime 消息路由(Task 10):likes.burst 自身过滤(originBatchIds 求交)/
// 隐藏页暂停(校准照常)/ 减少动画(校准照常)/ photo.* 移除 / stats.updated /
// photo.published 提示。纯依赖注入,直测语义。
function burst(overrides: Partial<LikesBurstMessage> = {}): LikesBurstMessage {
return {
type: 'likes.burst',
photoId: 'p1',
albumId: 'a1',
delta: 3,
total: 10,
countVersion: 4,
burstId: 'b-1',
originBatchIds: ['batch-other'],
...overrides,
};
}
interface DepCalls {
bubbles: Array<{ photoId: string; delta: number }>;
calibrations: Array<{ photoId: string; total: number; countVersion: number }>;
removed: string[];
newPhotoHints: number;
viewCounts: number[];
}
function makeDeps(
overrides: Partial<RealtimeRouterDeps> & { own?: number; hidden?: boolean; reduced?: boolean } = {},
): { deps: RealtimeRouterDeps; calls: DepCalls } {
const calls: DepCalls = { bubbles: [], calibrations: [], removed: [], newPhotoHints: 0, viewCounts: [] };
const deps: RealtimeRouterDeps = {
isHidden: () => overrides.hidden ?? false,
reducedMotion: () => overrides.reduced ?? false,
ownDeltaIn: () => overrides.own ?? 0,
calibrateLike: (photoId, total, countVersion) => calls.calibrations.push({ photoId, total, countVersion }),
showBubble: (photoId, delta) => calls.bubbles.push({ photoId, delta }),
notifyNewPhotos: () => {
calls.newPhotoHints += 1;
},
removePhoto: (photoId) => calls.removed.push(photoId),
setViewCount: (viewCount) => calls.viewCounts.push(viewCount),
};
return { deps, calls };
}
describe('realtime message router (Task 10)', () => {
it("others' burst bubbles and calibrates to the authoritative total", () => {
const { deps, calls } = makeDeps({ own: 0 });
handleServerMessage(burst({ delta: 3, total: 10 }), deps);
expect(calls.bubbles).toEqual([{ photoId: 'p1', delta: 3 }]);
expect(calls.calibrations).toEqual([{ photoId: 'p1', total: 10, countVersion: 4 }]);
});
it('suppresses the bubble for own-only bursts but still calibrates', () => {
const { deps, calls } = makeDeps({ own: 3 });
handleServerMessage(burst({ delta: 3, total: 10, originBatchIds: ['mine-1'] }), deps);
expect(calls.bubbles).toEqual([]);
expect(calls.calibrations).toEqual([{ photoId: 'p1', total: 10, countVersion: 4 }]);
});
it('bubbles only the others share of a merged burst containing own batches', () => {
const { deps, calls } = makeDeps({ own: 2 });
handleServerMessage(burst({ delta: 5, total: 12, originBatchIds: ['mine-1', 'other-9'] }), deps);
expect(calls.bubbles).toEqual([{ photoId: 'p1', delta: 3 }]);
expect(calls.calibrations).toEqual([{ photoId: 'p1', total: 12, countVersion: 4 }]);
});
it('skips the bubble while the page is hidden; counts still calibrate', () => {
const { deps, calls } = makeDeps({ hidden: true });
handleServerMessage(burst(), deps);
expect(calls.bubbles).toEqual([]);
expect(calls.calibrations).toHaveLength(1);
});
it('skips the bubble under reduced motion; counts still calibrate', () => {
const { deps, calls } = makeDeps({ reduced: true });
handleServerMessage(burst(), deps);
expect(calls.bubbles).toEqual([]);
expect(calls.calibrations).toHaveLength(1);
});
it('routes photo.hidden and photo.deleted to removal', () => {
const { deps, calls } = makeDeps();
handleServerMessage({ type: 'photo.hidden', photoId: 'p2', albumId: 'a1', entityVersion: 5 }, deps);
handleServerMessage({ type: 'photo.deleted', photoId: 'p3', albumId: 'a1', entityVersion: 6 }, deps);
expect(calls.removed).toEqual(['p2', 'p3']);
});
it('routes photo.published to the new-photo hint and stats.updated to view count', () => {
const { deps, calls } = makeDeps();
handleServerMessage(
{ type: 'photo.published', photoId: 'p4', albumId: 'a1', publishedAt: '2026-10-01T00:00:00.000Z', entityVersion: 2 },
deps,
);
handleServerMessage({ type: 'stats.updated', albumId: 'a1', viewCount: 41, countVersion: 9 }, deps);
handleServerMessage({ type: 'pong', ts: 1 } as ServerMessage, deps);
expect(calls.newPhotoHints).toBe(1);
expect(calls.viewCounts).toEqual([41]);
expect(calls.bubbles).toEqual([]);
expect(calls.removed).toEqual([]);
});
it('logs error frames without throwing', () => {
const info = vi.spyOn(console, 'info').mockImplementation(() => {});
const { deps } = makeDeps();
expect(() =>
handleServerMessage({ type: 'error', code: 'ALBUM_NOT_PUBLIC', message: 'nope' }, deps),
).not.toThrow();
expect(info).toHaveBeenCalledWith(expect.stringContaining('ALBUM_NOT_PUBLIC'));
});
});
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HEARTBEAT_MS } from '@photo/contracts';
import { RealtimeClient, wsUrlFromApiBase, type RealtimeState, type WebSocketLike } from '../realtime/ws';
// RealtimeClient(Task 10 WS 状态机):URL 推导 / open 订阅 + 半频心跳 /
// 断线指数退避重连 / 连续 3 败降级 / 重连成功 resync / stop 幂等收尾。
// 假 socket 工厂注入,假时钟驱动。
class FakeSocket {
static instances: FakeSocket[] = [];
readonly sent: string[] = [];
closed = false;
private readonly listeners = new Map<string, Array<(event: unknown) => void>>();
constructor(public readonly url: string) {
FakeSocket.instances.push(this);
}
send(data: string): void {
this.sent.push(data);
}
close(): void {
this.closed = true;
}
addEventListener(type: string, listener: (event: unknown) => void): void {
const list = this.listeners.get(type) ?? [];
list.push(listener);
this.listeners.set(type, list);
}
emit(type: 'open' | 'close' | 'error'): void {
for (const listener of this.listeners.get(type) ?? []) listener({});
}
message(frame: unknown): void {
for (const listener of this.listeners.get('message') ?? []) listener({ data: JSON.stringify(frame) });
}
}
function makeClient(overrides: Partial<ConstructorParameters<typeof RealtimeClient>[0]> = {}): {
client: RealtimeClient;
states: RealtimeState[];
getResyncs: () => number;
messages: unknown[];
} {
const states: RealtimeState[] = [];
const messages: unknown[] = [];
let resyncs = 0;
const client = new RealtimeClient({
url: 'ws://test/v1/realtime',
albumToken: () => 'tok-ws',
onMessage: (message) => messages.push(message),
onStateChange: (state) => states.push(state),
onResync: () => {
resyncs += 1;
},
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocketLike,
retryBaseMs: 1000,
...overrides,
});
// getter:resyncs 是闭包计数(返回原语会是创建时的快照)。
return { client, states, getResyncs: () => resyncs, messages };
}
function lastSocket(): FakeSocket {
return FakeSocket.instances[FakeSocket.instances.length - 1]!;
}
describe('ws url derivation', () => {
it('maps http/https api bases onto the realtime path', () => {
expect(wsUrlFromApiBase('http://127.0.0.1:8081')).toBe('ws://127.0.0.1:8081/v1/realtime');
expect(wsUrlFromApiBase('https://ov.example.com/')).toBe('wss://ov.example.com/v1/realtime');
});
it('derives from the page origin for same-origin deployments', () => {
const url = wsUrlFromApiBase('');
expect(url).toBe('ws://localhost:3000/v1/realtime'); // jsdom 默认 origin
});
});
describe('realtime client state machine (Task 10)', () => {
beforeEach(() => {
FakeSocket.instances = [];
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('subscribes on open and pings at half the heartbeat interval', () => {
const { client, states, messages } = makeClient();
client.start();
expect(states).toEqual([]); // 初始即 connecting,去重不重复发
lastSocket().emit('open');
const subscribe = JSON.parse(lastSocket().sent[0]!) as { type: string; albumToken: string };
expect(subscribe).toEqual({ type: 'subscribe', albumToken: 'tok-ws' });
expect(states).toEqual(['open']);
// HEARTBEAT_MS/2 周期应用层 ping;服务端帧透传 onMessage。
vi.advanceTimersByTime(Math.ceil(HEARTBEAT_MS / 2));
const ping = JSON.parse(lastSocket().sent[1]!) as { type: string; ts: number };
expect(ping.type).toBe('ping');
expect(typeof ping.ts).toBe('number');
lastSocket().message({ type: 'stats.updated', albumId: 'a1', viewCount: 3, countVersion: 1 });
expect(messages).toHaveLength(1);
client.stop();
});
it('reconnects with exponential backoff after a drop', () => {
const { client } = makeClient();
client.start();
lastSocket().emit('open');
const first = lastSocket();
first.emit('close'); // 掉线
vi.advanceTimersByTime(1000); // 退避 1s → 重连
expect(FakeSocket.instances).toHaveLength(2);
vi.advanceTimersByTime(2000 - 1);
expect(FakeSocket.instances).toHaveLength(2); // 未到 2s 不再连
lastSocket().emit('close');
vi.advanceTimersByTime(2000);
expect(FakeSocket.instances).toHaveLength(3);
client.stop();
});
it('degrades after 3 consecutive failures and stays degraded while retrying', () => {
const { client, states } = makeClient();
client.start();
lastSocket().emit('close'); // 失败 1
vi.advanceTimersByTime(1000);
lastSocket().emit('close'); // 失败 2
vi.advanceTimersByTime(2000);
lastSocket().emit('close'); // 失败 3 → degraded(此前一直 connecting,去重不发)
expect(states).toEqual(['degraded']);
// 退避封顶 4s(base 1s)继续后台重试,仍 degraded。
vi.advanceTimersByTime(4000);
expect(FakeSocket.instances).toHaveLength(4);
lastSocket().emit('open'); // 恢复 → open
expect(states).toEqual(['degraded', 'open']);
client.stop();
});
it('fires onResync and re-subscribes when reconnecting after a drop', () => {
const { client, getResyncs } = makeClient();
client.start();
lastSocket().emit('open');
expect(getResyncs()).toBe(0); // 首连不算 resync
lastSocket().emit('close');
vi.advanceTimersByTime(1000);
lastSocket().emit('open');
expect(getResyncs()).toBe(1);
const subscribe = JSON.parse(lastSocket().sent[0]!) as { type: string };
expect(subscribe.type).toBe('subscribe');
client.stop();
});
it('stop() tears down and prevents any further reconnection', () => {
const { client } = makeClient();
client.start();
lastSocket().emit('open');
client.stop();
expect(lastSocket().closed).toBe(true);
lastSocket().emit('close'); // stop 后的 close 不再排重连
vi.advanceTimersByTime(60_000);
expect(FakeSocket.instances).toHaveLength(1);
});
});
+11 -1
View File
@@ -9,7 +9,8 @@ import { makeAlbum } from './fixtures';
// 路由解析(Task 5 脚手架测试对齐 Task 6 真实活动页):token/photoId 参数经
// api mock 断言——活动页以 token 取数,photoId 驱动 Task 8 深链(单图先行 + 序列
// 兜底)。getPublicPhotos / getPublicPhoto 一并替身(Task 7 起活动页内嵌 PhotoFeed,
// Task 8 起深链取单图)。
// Task 8 起深链取单图)。Task 10 起活动页组装 WS/浏览上报:RealtimeClient 以
// 无操作桩替换(jsdom WebSocket 会发起真实连接)、postAlbumVisit 替身防真实 fetch。
vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>();
return {
@@ -17,9 +18,18 @@ vi.mock('../api/client', async (importOriginal) => {
getPublicAlbum: vi.fn(),
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
getPublicPhoto: vi.fn(),
postAlbumVisit: vi.fn().mockResolvedValue({ viewCount: 0, counted: false }),
};
});
vi.mock('../realtime/ws', () => {
class RealtimeClientStub {
start(): void {}
stop(): void {}
}
return { RealtimeClient: RealtimeClientStub, wsUrlFromApiBase: () => 'ws://test/v1/realtime' };
});
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
+109
View File
@@ -0,0 +1,109 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { defineComponent, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PublicPhoto, PublicPhotoList, SortMode } from '../api/client';
import { getPublicPhotos } from '../api/client';
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
// 排序切换状态机(Task 10):sort ref 变更 → 重置重取(携带 sort 参数);
// 分页游标请求继承当前排序;切回 latest 重新取。冷快照语义在 e2e 覆盖。
vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>();
return { ...actual, getPublicPhotos: vi.fn() };
});
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
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 };
}
const mounted: VueWrapper[] = [];
function mountSequence(): { sequence: PhotoSequence; sort: { value: SortMode } } {
let sequence!: PhotoSequence;
const sort = ref<SortMode>('latest');
const Host = defineComponent({
setup() {
sequence = usePhotoSequence({ token: ref('tok-sort'), locale: ref('zh-CN'), category: ref(null), sort });
return () => null;
},
});
mounted.push(mount(Host));
return { sequence, sort };
}
describe('sort toggle state machine (Task 10)', () => {
beforeEach(() => {
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')], 'cur-1'));
});
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount();
});
it('loads page 1 with the default latest sort', async () => {
const { sequence } = mountSequence();
await flushPromises();
expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-sort', { locale: 'zh-CN', sort: 'latest' });
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2']);
});
it('resets and refetches with sort=hot when the mode switches', async () => {
const { sequence, sort } = mountSequence();
await flushPromises();
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p2'), makePhoto('p1')], null));
sort.value = 'hot';
await flushPromises();
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-sort', { locale: 'zh-CN', sort: 'hot' });
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p2', 'p1']); // 快照整体替换,不追加
expect(sequence.pagesLoaded).toBe(1);
});
it('keeps the hot sort on cursor pagination', async () => {
const { sequence, sort } = mountSequence();
await flushPromises();
sort.value = 'hot';
await flushPromises();
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p3')], null));
await sequence.loadMore();
await flushPromises();
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith(
'tok-sort',
expect.objectContaining({ sort: 'hot', cursor: 'cur-1' }),
);
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p1', 'p2', 'p3']);
});
it('switches back to latest with a fresh reset', async () => {
const { sequence, sort } = mountSequence();
await flushPromises();
sort.value = 'hot';
await flushPromises();
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p1')], null));
sort.value = 'latest';
await flushPromises();
expect(mockedGetPublicPhotos).toHaveBeenLastCalledWith('tok-sort', { locale: 'zh-CN', sort: 'latest' });
expect(sequence.photos).toHaveLength(1);
expect(sequence.hasMore).toBe(false);
});
});
+20 -2
View File
@@ -1,7 +1,7 @@
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 { ApiError, getPublicAlbum, getPublicPhoto, getPublicPhotos, postAlbumVisit, type PublicPhoto, type PublicPhotoList } from '../api/client';
import { createViewerI18n } from '../i18n';
import { router } from '../router';
import { makeAlbum } from './fixtures';
@@ -9,7 +9,8 @@ import { makeAlbum } from './fixtures';
// 大图查看器路由编排(Task 8):URL 同步(打开 push / 切换 replace / 关闭 back 或
// replace)、滚动锚点恢复、深链解析顺序(单图先行 → 相邻页构成序列)、深链 404
// 本地化提示与返回链接。使用真实 router 单例(jsdom 提供_history),对齐
// routing.spec.ts 的既定模式;api 三端点替身。
// routing.spec.ts 的既定模式;api 三端点替身。Task 10 起活动页组装 WS/浏览
// 上报:RealtimeClient 以无操作桩替换、postAlbumVisit 替身防真实 fetch。
vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>();
@@ -18,12 +19,22 @@ vi.mock('../api/client', async (importOriginal) => {
getPublicAlbum: vi.fn(),
getPublicPhotos: vi.fn(),
getPublicPhoto: vi.fn(),
postAlbumVisit: vi.fn().mockResolvedValue({ viewCount: 0, counted: false }),
};
});
vi.mock('../realtime/ws', () => {
class RealtimeClientStub {
start(): void {}
stop(): void {}
}
return { RealtimeClient: RealtimeClientStub, wsUrlFromApiBase: () => 'ws://test/v1/realtime' };
});
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
const mockedPostAlbumVisit = vi.mocked(postAlbumVisit);
function makePhoto(id: string): PublicPhoto {
return {
@@ -64,6 +75,9 @@ describe('viewer url sync and deep link (Task 8)', () => {
...makePhoto(photoId),
categoryId: null,
}));
// afterEach 的 restoreAllMocks 会清工厂期实现:逐用例重置兜底值
//(活动页加载成功即触发浏览上报,见 visitor.ts)。
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 0, counted: false });
});
afterEach(() => {
@@ -72,6 +86,10 @@ describe('viewer url sync and deep link (Task 8)', () => {
}
vi.restoreAllMocks();
vi.unstubAllGlobals();
// restoreAllMocks 将工厂期实现清成返回 undefined 的裸 vi.fn:用例收尾后
// 仍可能有在途的浏览上报微任务落到 postAlbumVisit(.then 于 undefined 即
// 未处理拒绝)——恢复后立即补挂兜底实现。
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 0, counted: false });
});
it('open pushes the photo route, navigate replaces, close restores scroll and pops back', async () => {
@@ -0,0 +1,126 @@
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, postAlbumVisit } from '../api/client';
import { useVisitReport } from '../stats/visitor';
import { VISITOR_STORAGE_KEY } from '../stats/visitor';
// useVisitReport(Task 10 浏览上报编排):加载成功且页面可见才报(隐藏时等
// 首次可见)/ 单相册一次 / 网络失败同 requestId 退避重试 / 4xx 静默放弃 /
// requestId 持久 sessionStorage 直到成功响应。visitorId 预置 localStorage
// 走快路径(跨标签协调单独在 visitor-id.spec 覆盖)。
vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>();
return { ...actual, postAlbumVisit: vi.fn() };
});
const mockedPostAlbumVisit = vi.mocked(postAlbumVisit);
const mounted: VueWrapper[] = [];
function mountReport(token: string): ReturnType<typeof useVisitReport> {
let report!: ReturnType<typeof useVisitReport>;
const Host = defineComponent({
setup() {
report = useVisitReport({
token: ref(token),
onCounted: (count) => counted.push(count),
});
return () => null;
},
});
mounted.push(mount(Host));
return report;
}
const counted: number[] = [];
/** jsdom document.hidden 只读:defineProperty 覆写(用例后还原)。 */
function setHidden(hidden: boolean): void {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });
}
describe('visit report gating (Task 10)', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
localStorage.setItem(VISITOR_STORAGE_KEY, 'v'.repeat(32));
counted.length = 0;
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 1, counted: true });
setHidden(false);
});
afterEach(() => {
while (mounted.length > 0) mounted.pop()?.unmount();
vi.useRealTimers();
vi.restoreAllMocks();
// 收尾后在途上报微任务可能仍落到已还原的替身(返回 undefined → .then 崩):
// 恢复后立即补挂兜底实现。
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 0, counted: false });
setHidden(false);
});
it('reports once after album load while visible, then clears the persisted requestId', async () => {
const report = mountReport('tok-visit');
report.notifyAlbumLoaded();
await flushPromises();
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
const [token, body] = mockedPostAlbumVisit.mock.calls[0]!;
expect(token).toBe('tok-visit');
expect(body.visitorId).toBe('v'.repeat(32));
expect(body.requestId).toMatch(/^[0-9a-f-]{8,}$/);
expect(counted).toEqual([1]);
expect(sessionStorage.getItem('viewer.visitRequest:tok-visit')).toBeNull(); // 确认后清除
report.notifyAlbumLoaded(); // 幂等:同相册只报一次
await flushPromises();
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
});
it('defers the report until the page becomes visible', async () => {
setHidden(true);
const report = mountReport('tok-hidden');
report.notifyAlbumLoaded();
await flushPromises();
expect(mockedPostAlbumVisit).not.toHaveBeenCalled();
setHidden(false);
document.dispatchEvent(new Event('visibilitychange'));
await flushPromises();
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
});
it('retries network failure with the same persisted requestId and succeeds', async () => {
mockedPostAlbumVisit
.mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network request failed', 0, null))
.mockResolvedValue({ viewCount: 6, counted: true });
vi.useFakeTimers();
const report = mountReport('tok-retry');
report.notifyAlbumLoaded();
await vi.advanceTimersByTimeAsync(0);
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
const requestId = mockedPostAlbumVisit.mock.calls[0]![1].requestId;
// 失败期间 requestId 保持持久(刷新/重试幂等键不换)。
expect(sessionStorage.getItem('viewer.visitRequest:tok-retry')).toBe(requestId);
await vi.advanceTimersByTimeAsync(1000);
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(2);
expect(mockedPostAlbumVisit.mock.calls[1]![1].requestId).toBe(requestId);
expect(counted).toEqual([6]);
expect(sessionStorage.getItem('viewer.visitRequest:tok-retry')).toBeNull();
});
it('gives up silently on 4xx without retrying', async () => {
mockedPostAlbumVisit.mockRejectedValue(new ApiError('RATE_LIMITED', 'too many', 429, null));
vi.useFakeTimers();
const report = mountReport('tok-4xx');
report.notifyAlbumLoaded();
await vi.advanceTimersByTimeAsync(0);
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(10_000);
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1); // 无重试
});
});
@@ -0,0 +1,121 @@
import { describe, expect, it, vi } from 'vitest';
import {
resolveVisitorId,
VISITOR_STORAGE_KEY,
type BroadcastChannelLike,
} from '../stats/visitor';
// resolveVisitorId(Task 10 跨标签协调):localStorage 快路径 / 冷启动
// query→claim 探询采纳 / 自 claim 写读回校验 + 宣告 / 存储不可用页面级随机。
// BroadcastChannel 与 storage 全注入(jsdom 无 BroadcastChannel)。
/** 内存存储假件(可编程失败)。 */
function memoryStorage(initial: Record<string, string> = {}): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> & { data: Map<string, string> } {
const data = new Map(Object.entries(initial));
return {
data,
getItem: (key: string) => data.get(key) ?? null,
setItem(key: string, value: string) {
data.set(key, value);
},
removeItem(key: string) {
data.delete(key);
},
};
}
interface FakeChannel extends BroadcastChannelLike {
posted: unknown[];
deliver(data: unknown): void;
}
function fakeChannel(): { create: () => FakeChannel; instances: FakeChannel[] } {
const instances: FakeChannel[] = [];
return {
instances,
create: () => {
const channel: FakeChannel = {
posted: [],
onmessage: null,
postMessage(data: unknown) {
channel.posted.push(data);
},
close() {},
deliver(data: unknown) {
channel.onmessage?.({ data });
},
};
instances.push(channel);
return channel;
},
};
}
describe('visitor id cross-tab coordination (Task 10)', () => {
it('returns the stored id immediately without channel traffic', async () => {
const storage = memoryStorage({ [VISITOR_STORAGE_KEY]: 'feedface'.padEnd(32, '0') });
const factory = vi.fn(() => null);
const id = await resolveVisitorId({ storage, channelFactory: factory, queryWindowMs: 5 });
expect(id).toBe('feedface'.padEnd(32, '0'));
expect(factory).not.toHaveBeenCalled();
});
it('cold start claims, verifies read-back and announces the claim', async () => {
const storage = memoryStorage();
const { create, instances } = fakeChannel();
const id = await resolveVisitorId({ storage, channelFactory: create, queryWindowMs: 5 });
expect(id).toMatch(/^[0-9a-f]{32}$/);
expect(storage.data.get(VISITOR_STORAGE_KEY)).toBe(id); // 持久 + 读回一致
const posted = instances[0]!.posted;
expect(posted[0]).toEqual({ type: 'query' }); // 先探询
const claim = posted[1] as { type: string; visitorId: string };
expect(claim.type).toBe('claim'); // 后宣告
expect(claim.visitorId).toBe(id);
});
it('adopts an existing tab claim answer to the query', async () => {
const storage = memoryStorage();
const { create, instances } = fakeChannel();
// 模拟既有标签页:收到 query 后回 claim。
const factory = () => {
const channel = create();
const original = channel.postMessage.bind(channel);
channel.postMessage = (data: unknown) => {
original(data);
if ((data as { type?: string }).type === 'query') {
setTimeout(() => channel.deliver({ type: 'claim', visitorId: 'a'.repeat(32), claimedAt: Date.now() }), 2);
}
};
return channel;
};
const id = await resolveVisitorId({ storage, channelFactory: factory, queryWindowMs: 20 });
expect(id).toBe('a'.repeat(32));
expect(storage.data.get(VISITOR_STORAGE_KEY)).toBe('a'.repeat(32));
expect(instances[0]!.posted.some((frame) => (frame as { type?: string }).type === 'claim')).toBe(false);
});
it('degrades to a per-page random id when storage writes fail', async () => {
// 注入 undefined 会回落 jsdom 真实 localStorage——以写失败存储模拟不可用。
const throwing = {
getItem: () => null,
setItem: () => {
throw new Error('quota exceeded');
},
removeItem: () => {},
};
const first = await resolveVisitorId({ storage: throwing, channelFactory: () => null, queryWindowMs: 1 });
const second = await resolveVisitorId({ storage: throwing, channelFactory: () => null, queryWindowMs: 1 });
expect(first).toMatch(/^[0-9a-f]{32}$/);
expect(second).toMatch(/^[0-9a-f]{32}$/);
expect(first).not.toBe(second); // 页面级随机(不持久)
});
it('keeps a working plain-localStorage path when BroadcastChannel is unavailable', async () => {
const storage = memoryStorage();
const id = await resolveVisitorId({ storage, channelFactory: () => null, queryWindowMs: 5 });
expect(id).toMatch(/^[0-9a-f]{32}$/);
expect(storage.data.get(VISITOR_STORAGE_KEY)).toBe(id);
});
});
+27
View File
@@ -6,6 +6,15 @@ export type PublicPhoto = components['schemas']['PublicPhoto'];
export type PublicPhotoList = components['schemas']['PublicPhotoList'];
export type PublicPhotoDetail = components['schemas']['PublicPhotoDetail'];
// P2 Task 10 互动端点类型(likes/visits,ov 域部署;路径同形、域名区分)。
export type LikeBatchRequest = components['schemas']['LikeBatchRequest'];
export type LikeBatchResponse = components['schemas']['LikeBatchResponse'];
export type VisitReportRequest = components['schemas']['VisitReportRequest'];
export type VisitReportResponse = components['schemas']['VisitReportResponse'];
/** 公开照片排序模式(sort=hot 仅 ov 实现:likeTotal 倒序短期快照,§7)。 */
export type SortMode = 'latest' | 'hot';
/** 客户端合成码:fetch 本身失败(离线/DNS),非服务端错误信封。 */
export const NETWORK_ERROR_CODE = 'NETWORK_ERROR';
@@ -106,6 +115,7 @@ export type PublicPhotosQuery = {
cursor?: string;
limit?: number;
locale?: string;
sort?: SortMode;
};
/** 公开照片列表(游标分页;Task 7 瀑布流数据源)。 */
@@ -119,3 +129,20 @@ export function getPublicPhoto(token: string, photoId: string, locale?: string):
withQuery(`/public/albums/${encodeURIComponent(token)}/photos/${encodeURIComponent(photoId)}`, { locale }),
);
}
/** 点赞批次上报(Task 10):失败重试必须复用同一 batchId(服务端幂等键)。 */
export function postLikeBatch(token: string, photoId: string, body: LikeBatchRequest): Promise<LikeBatchResponse> {
return apiRequest<LikeBatchResponse>(
`/public/albums/${encodeURIComponent(token)}/photos/${encodeURIComponent(photoId)}/likes`,
{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) },
);
}
/** 浏览上报(Task 10):requestId 为幂等键,counted 确认前跨重试持久复用。 */
export function postAlbumVisit(token: string, body: VisitReportRequest): Promise<VisitReportResponse> {
return apiRequest<VisitReportResponse>(`/public/albums/${encodeURIComponent(token)}/visits`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { LikeBubble } from '../realtime/useAlbumRealtime';
// 他人点赞气泡(Task 10):右下角浮层,含被赞照片缩略图 + ♥ +delta;点击打开
// 该照片大图(定位)。生成/消散/清空(页面隐藏)由 useAlbumRealtime 管理,
// 本组件纯展示。减少动画/不可见时根本不会收到气泡(router 门禁)。
defineProps<{
bubbles: LikeBubble[];
}>();
const emit = defineEmits<{
open: [photoId: string];
}>();
const { t } = useI18n();
</script>
<template>
<div class="like-bubbles" aria-live="polite">
<button
v-for="bubble in bubbles"
:key="bubble.key"
type="button"
class="like-bubble"
:aria-label="t('viewer.bubbleOpen')"
@click="emit('open', bubble.photoId)"
>
<img v-if="bubble.thumbUrl !== null" class="bubble-thumb" :src="bubble.thumbUrl" alt="" loading="lazy">
<span class="bubble-heart" aria-hidden="true">♥</span>
<span class="bubble-delta" aria-hidden="true">+{{ bubble.delta }}</span>
</button>
</div>
</template>
<style scoped>
/* 极简基线样式:视觉打磨留待 P4a。 */
.like-bubbles {
position: fixed;
right: 16px;
bottom: 24px;
z-index: 105;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.like-bubble {
pointer-events: auto;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 10px;
background: rgba(17, 24, 39, 0.88);
color: #f9fafb;
padding: 6px 12px 6px 6px;
cursor: pointer;
animation: bubble-rise 300ms ease-out;
}
.bubble-thumb {
width: 36px;
height: 36px;
border-radius: 6px;
object-fit: cover;
display: block;
}
.bubble-heart {
color: #ef4444;
}
.bubble-delta {
font-variant-numeric: tabular-nums;
}
@keyframes bubble-rise {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.viewer-reduced-motion .like-bubble {
animation: none;
}
</style>
+55
View File
@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PublicPhoto } from '../api/client';
import type { PhotoSequence } from '../feed/sequence';
import type { LikesController } from '../likes/useLikes';
import { layoutWaterfall } from '../feed/layout';
// 观众瀑布流(Task 7;Task 8 起数据层上提):双列绝对定位(短列优先,条目高按
@@ -21,6 +22,8 @@ import { layoutWaterfall } from '../feed/layout';
const props = defineProps<{
sequence: PhotoSequence;
/** 点赞控制器(Task 10):缺省不渲染单元格点赞位(组件单测组装)。 */
likes?: LikesController;
}>();
const emit = defineEmits<{
@@ -154,6 +157,11 @@ async function onImgError(photo: PublicPhoto): Promise<void> {
if (result !== 'replaced') failedIds.add(photo.photoId);
}
/** 单元格点赞(Task 10):乐观 +1,不冒泡打开大图。 */
function likeCell(photo: PublicPhoto): void {
props.likes?.like(photo.photoId);
}
onBeforeUnmount(() => {
lazyObserver?.disconnect();
sentinelObserver?.disconnect();
@@ -202,6 +210,16 @@ onBeforeUnmount(() => {
decoding="async"
@error="onImgError(photos[placement.index])"
>
<button
v-if="likes !== undefined"
type="button"
class="cell-like"
:aria-label="t('viewer.like')"
@click.stop="likeCell(photos[placement.index])"
>
<span :key="likes.popNonce(photos[placement.index].photoId)" class="heart" aria-hidden="true">♥</span>
<span class="count">{{ likes.displayFor(photos[placement.index]) }}</span>
</button>
</div>
</div>
@@ -284,6 +302,43 @@ onBeforeUnmount(() => {
height: 100%;
object-fit: cover;
}
.cell-like {
position: absolute;
left: 6px;
bottom: 6px;
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
border-radius: 999px;
background: rgba(17, 24, 39, 0.65);
color: #f9fafb;
padding: 2px 8px;
font-size: 0.75rem;
cursor: pointer;
}
.cell-like .heart {
color: #ef4444;
display: inline-block;
animation: heart-pop 400ms ease-out;
}
.cell-like .count {
font-variant-numeric: tabular-nums;
}
@keyframes heart-pop {
0% {
transform: scale(1);
}
40% {
transform: scale(1.6);
}
100% {
transform: scale(1);
}
}
.viewer-reduced-motion .cell-like .heart {
animation: none;
}
.end-note {
text-align: center;
color: #9ca3af;
+55 -1
View File
@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PhotoSequence } from '../feed/sequence';
import type { LikesController } from '../likes/useLikes';
import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom';
// 大图查看器(Task 8):browse URL 全屏展示 + 左右切换(保持列表浏览序列,越过
@@ -24,6 +25,8 @@ import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from
const props = defineProps<{
sequence: PhotoSequence;
photoId: string;
/** 点赞控制器(Task 10):缺省不渲染点赞位(组件单测/降级组装)。 */
likes?: LikesController;
}>();
const emit = defineEmits<{
@@ -253,6 +256,13 @@ function preload(url: string): void {
img.src = url;
}
// —— 点赞(Task 10):乐观 +1 与心跳动画由 LikesController 承担 ——
function likeCurrent(): void {
if (photo.value === null || props.likes === undefined) return;
props.likes.like(photo.value.photoId);
}
// —— 签名过期/下线处理 ——
/** 大图加载失败:静默重取单图接口换新签名 URL;404 → 通知父级移除;其他失败可重试。 */
@@ -327,7 +337,17 @@ onBeforeUnmount(() => {
<header class="bar">
<span class="counter">{{ t('viewer.counter', { current: index + 1, total }) }}</span>
<span class="likes">{{ t('viewer.likeCount', { count: photo.likeCount }) }}</span>
<button
v-if="likes !== undefined"
type="button"
class="control like-button"
:aria-label="t('viewer.like')"
@click="likeCurrent"
>
<span :key="likes.popNonce(photo.photoId)" class="heart" aria-hidden="true">♥</span>
<span class="like-count">{{ likes.displayFor(photo) }}</span>
</button>
<span v-else 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')">
✕
@@ -415,6 +435,40 @@ onBeforeUnmount(() => {
color: #d1d5db;
font-size: 0.875rem;
}
.like-button {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 4px 12px;
font-size: 0.9rem;
border: 1px solid rgba(249, 250, 251, 0.35);
}
.like-button .heart {
color: #ef4444;
display: inline-block;
}
/* :key 重挂触发心跳放大;「减少动画」时禁用(数字仍即时跳动)。 */
.like-button .heart {
animation: heart-pop 400ms ease-out;
}
.like-count {
font-variant-numeric: tabular-nums;
}
@keyframes heart-pop {
0% {
transform: scale(1);
}
40% {
transform: scale(1.6);
}
100% {
transform: scale(1);
}
}
.viewer-reduced-motion .like-button .heart {
animation: none;
}
.control {
border: none;
background: rgba(17, 24, 39, 0.55);
+6 -2
View File
@@ -6,6 +6,7 @@ import {
getPublicPhotos,
type PublicPhoto,
type PublicPhotoList,
type SortMode,
} from '../api/client';
import { errorMessageKey } from '../api/errors';
import { containsNewPhotoId, mergeUniqueByPhotoId } from './layout';
@@ -29,6 +30,8 @@ export interface PhotoSequenceInputs {
token: Ref<string>;
locale: Ref<string>;
category: Ref<string | null>;
/** 排序模式(Task 10 热门/最新):切换=快照重取(整表重置,游标重走)。 */
sort?: Ref<SortMode>;
}
/** 单图刷新结果:换绑新签名 URL / 照片已不可公开见(404,已从序列移除)/ 其他失败。 */
@@ -55,6 +58,7 @@ export function usePhotoSequence(inputs: PhotoSequenceInputs) {
return getPublicPhotos(inputs.token.value, {
locale: inputs.locale.value,
category: inputs.category.value ?? undefined,
sort: inputs.sort?.value,
cursor,
});
}
@@ -236,8 +240,8 @@ export function usePhotoSequence(inputs: PhotoSequenceInputs) {
document.removeEventListener('visibilitychange', onVisibilityChange);
});
// token/语言/分类变化:整表重置(语言切换保留当前分类——inputs 自带)。
watch([inputs.token, inputs.locale, inputs.category], () => void resetAndLoad());
// token/语言/分类/排序变化:整表重置(语言切换保留当前分类——inputs 自带)。
watch([inputs.token, inputs.locale, inputs.category, () => inputs.sort?.value], () => void resetAndLoad());
// reactive 包装:模板/脚本访问 sequence.photos 等 ref 自动解包(普通对象不会)。
return reactive({
+7
View File
@@ -7,20 +7,27 @@ export default {
emptyNotice: 'Photos coming soon',
time: 'Time',
venue: 'Venue',
views: 'Views',
categories: 'Categories',
all: 'All',
sortLabel: 'Sort',
sortLatest: 'Latest',
sortHot: 'Popular',
end: 'You have reached the end',
newPhotos: 'New photos — tap to refresh',
retry: 'Retry',
},
lang: { switch: 'Switch language' },
prefs: { reduceMotion: 'Reduce motion' },
viewer: {
title: 'Photo viewer',
close: 'Close',
previous: 'Previous photo',
next: 'Next photo',
counter: '{current} / {total}',
like: 'Like',
likeCount: '{count} likes',
bubbleOpen: 'Open the liked photo',
loadFailed: 'Failed to load image',
// PHOTO_NOT_PUBLIC 专用:not found / unpublished / hidden share one code (anti-enumeration).
photoUnavailable: 'Photo not found or unavailable',
+7
View File
@@ -7,20 +7,27 @@ export default {
emptyNotice: '照片即将上线',
time: '时间',
venue: '地点',
views: '浏览次数',
categories: '分类',
all: '全部',
sortLabel: '排序',
sortLatest: '最新',
sortHot: '热门',
end: '已经到底了',
newPhotos: '有新照片,点击查看',
retry: '重试',
},
lang: { switch: '切换语言' },
prefs: { reduceMotion: '减少动画' },
viewer: {
title: '照片查看',
close: '关闭',
previous: '上一张',
next: '下一张',
counter: '{current} / {total}',
like: '点赞',
likeCount: '{count} 次点赞',
bubbleOpen: '查看被点赞的照片',
loadFailed: '图片加载失败',
photoRemoved: '照片已被移除',
// PHOTO_NOT_PUBLIC 专用:不存在/未发布/已隐藏同码(防枚举),文案覆盖两种成因。
+231
View File
@@ -0,0 +1,231 @@
// apps/viewer/src/likes/useLikes.ts —— 点赞互动状态机(P2 Task 10)。
//
// 批次生命周期(技术方案 §7「批次收集」):
// 点击 → 乐观 +1(display = base + pending,含心跳动画 nonce)→ 计入待冲
// 队列;500ms 防抖到期或单照片累计 5 次 → 冲一批(batchId=UUID,delta=累计)
// → POST likes。响应 {total, countVersion} 权威校准:base=total(可能因他人
// 点赞跳变)、pending-=delta;countVersion 单调防乱序回退。
// 失败 → 同 batchId 退避重试(1s/2s/4s,3 次后丢弃:pending-=delta + console
// 备注——批次可能已落服务端(响应丢失),计数在下次交互/校准自然对齐)。
//
// 自身气泡过滤的数据面:每次成功提交登记 myBatches: batchId → delta(容量
// 上限裁剪);WS likes.burst.originBatchIds 与之求交得「己方贡献」,供 router
// 决定抑制动画但仍校准 total(见 realtime/router.ts)。
//
// 校准入口(三源殊途同归):
// · 批次响应 calibrate(total, countVersion)(版本化,可回退修正);
// · burst/stats 推送 calibrate(total, countVersion)(版本化);
// · 列表重取 recalibrateFromList(items)(无版本号 → base 单调不减,防快照
// 滞后让 display 回跳)。
import { onBeforeUnmount, reactive, watch, type Ref } from 'vue';
import {
postLikeBatch,
type PublicPhoto,
type PublicPhotoList,
} from '../api/client';
import { randomUuid } from '../support/ids';
/** 冲批防抖窗(毫秒)。 */
export const LIKE_FLUSH_DEBOUNCE_MS = 500;
/** 单照片累计满额即冲(不等防抖窗)。 */
export const LIKE_FLUSH_CAP = 5;
/** 失败重试退避基值(毫秒),指数 1s/2s/4s。 */
export const LIKE_RETRY_BASE_MS = 1000;
/** 同 batchId 最大尝试次数(含首发),超出丢弃。 */
export const LIKE_RETRY_MAX_ATTEMPTS = 3;
/** 自身批次登记容量(burst 回显在其后到达,超限裁最旧)。 */
const MY_BATCHES_CAP = 100;
/** sessionStorage 键:会话标识(每标签页会话一枚,跨刷新复用)。 */
const SESSION_KEY = 'viewer.likeSessionId';
/** 单照片状态(base=权威累计,pending=未落定乐观量,pop=动画 nonce)。 */
interface PhotoLikeState {
base: number;
pending: number;
version: number;
pop: number;
}
/** 在途/重试批次。 */
interface InflightBatch {
photoId: string;
batchId: string;
delta: number;
attempts: number;
}
export interface LikesInputs {
token: Ref<string>;
/** 当前已加载照片序列(首页到达/重取时同步权威 base)。 */
photos: () => PublicPhoto[];
}
function readSessionId(): string {
try {
const existing = sessionStorage.getItem(SESSION_KEY);
if (existing !== null && existing !== '') return existing;
const created = randomUuid();
sessionStorage.setItem(SESSION_KEY, created);
return created;
} catch {
// sessionStorage 不可用(隐私模式极端策略):每页内存随机即可——幂等性
// 依赖 batchId 而非 sessionId,仅损失「会话期间复用」的限速聚合粒度。
return randomUuid();
}
}
export function useLikes(inputs: LikesInputs) {
const sessionId = readSessionId();
/** photoId → 状态(响应式:display/pop 供模板直读)。 */
const states = reactive(new Map<string, PhotoLikeState>());
/** 待冲队列:photoId → 未提交点击数。 */
const queued = new Map<string, number>();
/** 成功批次登记:batchId → delta(自身气泡过滤)。 */
const myBatches = new Map<string, number>();
const inflight = new Set<InflightBatch>();
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const retryTimers = new Set<ReturnType<typeof setTimeout>>();
function stateOf(photoId: string, seedCount?: number): PhotoLikeState {
let state = states.get(photoId);
if (state === undefined) {
state = { base: seedCount ?? 0, pending: 0, version: 0, pop: 0 };
states.set(photoId, state);
}
return state;
}
/** 乐观展示计数(模板用):base + pending。 */
function displayFor(photo: Pick<PublicPhoto, 'photoId' | 'likeCount'>): number {
const state = stateOf(photo.photoId, photo.likeCount);
return state.base + state.pending;
}
/** 点击:立即 +1(乐观)并入队;首个点击启动防抖窗。 */
function like(photoId: string): void {
const state = stateOf(photoId);
state.pending += 1;
state.pop += 1;
queued.set(photoId, (queued.get(photoId) ?? 0) + 1);
if ((queued.get(photoId) ?? 0) >= LIKE_FLUSH_CAP) {
flushQueue();
return;
}
if (flushTimer === null) {
flushTimer = setTimeout(flushQueue, LIKE_FLUSH_DEBOUNCE_MS);
}
}
/** 冲队:每照片一批(新 batchId);未满防抖窗的部分留给下一窗。 */
function flushQueue(): void {
if (flushTimer !== null) {
clearTimeout(flushTimer);
flushTimer = null;
}
for (const [photoId, delta] of queued) {
queued.delete(photoId);
sendBatch({ photoId, batchId: randomUuid(), delta, attempts: 0 });
}
}
function sendBatch(batch: InflightBatch): void {
inflight.add(batch);
postLikeBatch(inputs.token.value, batch.photoId, {
sessionId,
batchId: batch.batchId,
delta: batch.delta,
})
.then((response) => settleBatch(batch, response))
.catch(() => retryBatch(batch));
}
function settleBatch(
batch: InflightBatch,
response: { total: number; countVersion: number },
): void {
inflight.delete(batch);
myBatches.set(batch.batchId, batch.delta);
if (myBatches.size > MY_BATCHES_CAP) {
const oldest = myBatches.keys().next().value;
if (oldest !== undefined) myBatches.delete(oldest);
}
calibrate(batch.photoId, response.total, response.countVersion);
const state = states.get(batch.photoId);
if (state !== undefined) state.pending -= batch.delta;
}
function retryBatch(batch: InflightBatch): void {
inflight.delete(batch);
batch.attempts += 1;
if (batch.attempts >= LIKE_RETRY_MAX_ATTEMPTS) {
// 丢弃:撤销乐观量(服务端可能已计入——响应丢失情形由下次校准收敛)。
const state = states.get(batch.photoId);
if (state !== undefined) state.pending -= batch.delta;
console.info(
`[viewer] like batch dropped after ${batch.attempts} attempts (batchId=${batch.batchId}); count recalibrates on next interaction`,
);
return;
}
const delay = LIKE_RETRY_BASE_MS * 2 ** (batch.attempts - 1);
const timer = setTimeout(() => {
retryTimers.delete(timer);
sendBatch(batch);
}, delay);
retryTimers.add(timer);
}
/** 权威校准(批次响应/burst/stats 推送):版本单调,旧版本忽略。 */
function calibrate(photoId: string, total: number, countVersion: number): void {
const state = stateOf(photoId);
if (countVersion < state.version) return;
state.base = total;
state.version = countVersion;
}
/** 己方批次在 burst.originBatchIds 中的贡献总量(自身气泡过滤)。 */
function ownDeltaIn(batchIds: string[]): number {
let own = 0;
for (const id of batchIds) {
const delta = myBatches.get(id);
if (delta !== undefined) own += delta;
}
return own;
}
/** 列表重取校准(无版本号):base 单调不减(防滞后快照回跳);在途乐观量保留。 */
function recalibrateFromList(items: PublicPhoto[] | PublicPhotoList['items']): void {
for (const photo of items) {
const state = stateOf(photo.photoId, photo.likeCount);
if (photo.likeCount > state.base) state.base = photo.likeCount;
}
}
// 序列到达/重取(首页、排序切换、刷新)→ 同步服务端计数。
watch(
() => inputs.photos(),
(photos) => recalibrateFromList(photos),
);
onBeforeUnmount(() => {
if (flushTimer !== null) clearTimeout(flushTimer);
for (const timer of retryTimers) clearTimeout(timer);
retryTimers.clear();
});
return reactive({
/** 乐观展示计数。 */
displayFor,
/** 点击动画 nonce(每次点击 +1,模板 :key 重挂触发动画)。 */
popNonce: (photoId: string) => states.get(photoId)?.pop ?? 0,
like,
calibrate,
ownDeltaIn,
recalibrateFromList,
states,
});
}
export type LikesController = ReturnType<typeof useLikes>;
+117 -18
View File
@@ -2,11 +2,23 @@
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { ApiError, NETWORK_ERROR_CODE, getPublicAlbum, getPublicPhoto, type PublicAlbum } from '../api/client';
import {
ApiError,
NETWORK_ERROR_CODE,
getPublicAlbum,
getPublicPhoto,
getPublicPhotos,
type PublicAlbum,
type SortMode,
} from '../api/client';
import { errorMessageKey } from '../api/errors';
import LikeBubbles from '../components/LikeBubbles.vue';
import PhotoFeed from '../components/PhotoFeed.vue';
import PhotoLightbox from '../components/PhotoLightbox.vue';
import { usePhotoSequence } from '../feed/sequence';
import { useLikes } from '../likes/useLikes';
import { useAlbumRealtime } from '../realtime/useAlbumRealtime';
import { useVisitReport } from '../stats/visitor';
// 观众活动页(P1b Task 6 信息区 + Task 7 瀑布流 + Task 8 大图查看器)。
//
@@ -42,6 +54,8 @@ const loadErrorKey = ref<string | null>(null);
const loading = ref(true);
// banner 签名 URL 加载失败(onerror)后隐藏,布局自适应(无残破图占位)。
const bannerFailed = ref(false);
/** 相册浏览数(Task 10):album 加载初值 + 上报确认/ stats.updated 校准。 */
const viewCount = ref<number | null>(null);
// 快速连续切换语言/路由时的竞态防护:仅最新请求的结果可落地。
let loadSequence = 0;
@@ -55,9 +69,14 @@ async function load(): Promise<void> {
if (sequence !== loadSequence) return;
album.value = data;
loadErrorKey.value = null;
viewCount.value = data.viewCount;
// 浏览上报(Task 10):加载成功后(可见时)一次性上报——幂等键由 reporter
// 持久,窗口内刷新不重复计数(A14)。
visitReport.notifyAlbumLoaded();
} catch (error) {
if (sequence !== loadSequence) return;
album.value = null;
viewCount.value = null;
const code = error instanceof ApiError ? error.code : NETWORK_ERROR_CODE;
loadErrorKey.value = errorMessageKey(code);
} finally {
@@ -122,7 +141,59 @@ function selectCategory(categoryId: string | null): void {
// —— 照片序列(Task 8 提升:feed + lightbox 共享) ——
const sequence = usePhotoSequence({ token, locale, category: selectedCategory });
/** 排序模式(Task 10):latest=发布倒序(缺省)/ hot=点赞倒序短期快照。
* 切换=快照重取整表;**大图打开期间锁定**(当前浏览序列不被中途换序)。 */
const sortMode = ref<SortMode>('latest');
function selectSort(mode: SortMode): void {
if (lightboxPhotoId.value !== null || sortMode.value === mode) return;
sortMode.value = mode;
}
const sequence = usePhotoSequence({ token, locale, category: selectedCategory, sort: sortMode });
// —— 互动(Task 10):点赞批次 + WS 实时(气泡/校准/降级轮询)+ 浏览上报 ——
const likes = useLikes({ token, photos: () => sequence.photos });
/** 浏览上报编排:viewCount 权威值回调(album 初值可能随后被服务端计数刷新)。 */
const visitReport = useVisitReport({
token,
onCounted: (count) => {
if (count > (viewCount.value ?? 0)) viewCount.value = count;
},
});
// token 变化(相册间跳转):上报状态复位(新相册可再报)。
watch(token, () => visitReport.resetForToken());
/** 大图确认照片下线(404 刷新 / WS hidden/deleted 推送共用):轻提示 + 回退。 */
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;
}
}
const realtime = useAlbumRealtime({
token,
sequence,
likes,
fetchFirstPage: () =>
getPublicPhotos(token.value, {
locale: locale.value,
category: selectedCategory.value ?? undefined,
sort: sortMode.value,
}),
filtersKey: () => `${token.value}|${locale.value}|${selectedCategory.value ?? ''}|${sortMode.value}`,
onPhotoRemoved,
setViewCount: (count) => {
viewCount.value = count;
},
});
// —— 大图查看器(Task 8) ——
@@ -262,25 +333,13 @@ watch(
},
);
/** 大图确认照片下线(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>
<section class="activity">
<section class="activity" :data-connection="realtime.connection">
<p v-if="loading" class="status">{{ t('album.loading') }}</p>
<div v-else-if="loadErrorKey !== null" class="error" role="alert">
@@ -309,6 +368,10 @@ onBeforeUnmount(() => {
<dt>{{ t('album.venue') }}</dt>
<dd>{{ venueText }}</dd>
</div>
<div v-if="viewCount !== null" class="meta-row">
<dt>{{ t('album.views') }}</dt>
<dd class="view-count">{{ viewCount }}</dd>
</div>
</dl>
</header>
@@ -347,9 +410,32 @@ onBeforeUnmount(() => {
</button>
</nav>
<!-- 排序切换(Task 10):latest/hot 快照重取;大图打开期间锁定(序列不变序)。 -->
<nav class="sort" :aria-label="t('album.sortLabel')">
<button
type="button"
class="chip"
:class="{ active: sortMode === 'latest' }"
:aria-pressed="sortMode === 'latest'"
:disabled="lightboxPhotoId !== null"
@click="selectSort('latest')"
>
{{ t('album.sortLatest') }}
</button>
<button
type="button"
class="chip"
:class="{ active: sortMode === 'hot' }"
:aria-pressed="sortMode === 'hot'"
:disabled="lightboxPhotoId !== null"
@click="selectSort('hot')"
>
{{ t('album.sortHot') }}
</button>
</nav>
<!-- 瀑布流(Task 7):序列数据在 PhotoSequence(Task 8 共享给大图)。 -->
<PhotoFeed :sequence="sequence" @open="openFromFeed" />
<!-- viewCount 契约占位 null:不渲染任何浏览计数节点。 -->
<PhotoFeed :sequence="sequence" :likes="likes" @open="openFromFeed" />
</template>
<!-- 大图查看器(Task 8):当前照片以 photoId 键控(v-model)。 -->
@@ -357,11 +443,15 @@ onBeforeUnmount(() => {
v-if="lightboxPhotoId !== null && currentPhoto !== null"
v-model:photo-id="lightboxPhotoId"
:sequence="sequence"
:likes="likes"
@removed="onPhotoRemoved"
@close="closeLightbox"
/>
<!-- 照片下线轻提示(大图内 404 刷新确认移除时)。 -->
<!-- 他人点赞气泡(Task 10):点击定位打开该照片大图。 -->
<LikeBubbles :bubbles="realtime.bubbles" @open="openFromFeed" />
<!-- 照片下线轻提示(大图 404 刷新 / WS hidden/deleted 推送移除时)。 -->
<p v-if="photoRemovedNotice" class="removed-notice" role="status">{{ t('viewer.photoRemoved') }}</p>
</section>
</template>
@@ -437,6 +527,15 @@ onBeforeUnmount(() => {
background: #eff6ff;
color: #1d4ed8;
}
.sort {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.sort .chip:disabled {
opacity: 0.55;
cursor: default;
}
.deep-link-notice {
border: 1px solid #fde68a;
border-radius: 8px;
+58
View File
@@ -0,0 +1,58 @@
// apps/viewer/src/realtime/router.ts —— 服务端消息路由(P2 Task 10)。
// 纯函数 + 依赖注入:帧语义集中一处,单测直测(自身过滤/隐藏暂停/减少动画)。
//
// likes.burst 自身过滤(A21 客户端侧):originBatchIds 与本会话已提交批次求交
// 得己方 delta——己方贡献不播气泡(点击时已见过动画),他人贡献照常;total
// 恒为权威值,无论过滤与否都校准。页面不可见(visibilitychange)或「减少动画」
// 开启时跳过气泡(队列即弃——回台只看得到校准后的数字),计数校准不受影响。
import type { ServerMessage } from '@photo/contracts';
export interface RealtimeRouterDeps {
/** 页面当前是否不可见(document.hidden)。 */
isHidden(): boolean;
/** 「减少动画」偏好是否开启。 */
reducedMotion(): boolean;
/** originBatchIds 中属于本会话已提交批次的 delta 合计。 */
ownDeltaIn(batchIds: string[]): number;
/** 点赞计数权威校准(版本化)。 */
calibrateLike(photoId: string, total: number, countVersion: number): void;
/** 他人点赞气泡(photoId + 他人贡献 delta)。 */
showBubble(photoId: string, delta: number): void;
/** 新照片到达:亮提示条(不自动插入)。 */
notifyNewPhotos(): void;
/** 照片隐藏/删除:移出序列(调用方负责大图收口与提示)。 */
removePhoto(photoId: string): void;
/** 相册浏览数更新(stats.updated)。 */
setViewCount(viewCount: number): void;
}
export function handleServerMessage(message: ServerMessage, deps: RealtimeRouterDeps): void {
switch (message.type) {
case 'pong':
return; // 心跳应答:无动作
case 'error':
// subscribe 校验失败等:连接保持,服务端已说明——本地备注即可。
console.info(`[viewer] realtime error frame: ${message.code}`);
return;
case 'photo.published':
deps.notifyNewPhotos();
return;
case 'photo.hidden':
case 'photo.deleted':
deps.removePhoto(message.photoId);
return;
case 'likes.burst': {
const own = deps.ownDeltaIn(message.originBatchIds);
// 校准无条件执行(含纯己方 burst——批次响应丢失时这是唯一权威回执)。
deps.calibrateLike(message.photoId, message.total, message.countVersion);
const others = message.delta - own;
if (others > 0 && !deps.isHidden() && !deps.reducedMotion()) {
deps.showBubble(message.photoId, others);
}
return;
}
case 'stats.updated':
deps.setViewCount(message.viewCount);
return;
}
}
@@ -0,0 +1,223 @@
// apps/viewer/src/realtime/useAlbumRealtime.ts —— 相册页实时编排(P2 Task 10)。
// 组装 RealtimeClient + router + 降级轮询 + 气泡生命周期:
//
// · 挂载即连 WS(URL 由 API 基址推导),subscribe 当前 token;
// · 重连成功(onResync)→ 第 1 页重取校准:点赞计数(likes.recalibrate-
// FromList)+ 列表变化(新照片亮提示条;**筛选未变时**缺失照片移出序列
// ——排序/分类切换本身会换第 1 页集合,不视为移除);
// · 连续 3 次失败 → degraded:8s 轮询同套校准(无气泡补播——历史气泡不
// 重放,只对齐数字与列表);恢复 open 即停轮询;
// · 气泡:他人 likes.burst(router 已滤己方)→ 底部浮层(缩略图 + delta),
// 点击打开该照片大图;约 4s 自动消散,至多 3 枚;页面隐藏即清空并暂停
// (回台只看得到校准后的计数)。
//
// e2e 注入点(沿用 sequence __viewerPollMs 约定):__viewerRealtimePollMs /
// __viewerWsRetryBaseMs / __viewerBubbleMs。
import { onBeforeUnmount, onMounted, reactive, ref, watch, type Ref } from 'vue';
import { apiBase, type PublicPhotoList } from '../api/client';
import type { LikesController } from '../likes/useLikes';
import { containsNewPhotoId } from '../feed/layout';
import type { PhotoSequence } from '../feed/sequence';
import { reducedMotion } from '../ui/prefs';
import { handleServerMessage } from './router';
import { RealtimeClient, wsUrlFromApiBase, type RealtimeState } from './ws';
/** 降级轮询间隔(毫秒)。 */
export const DEGRADED_POLL_MS = 8000;
/** 气泡驻留时长(毫秒)。 */
export const BUBBLE_MS = 4000;
/** 同屏气泡上限(超出弃最旧)。 */
export const BUBBLE_CAP = 3;
export interface LikeBubble {
key: number;
photoId: string;
delta: number;
thumbUrl: string | null;
}
export interface AlbumRealtimeInputs {
token: Ref<string>;
sequence: PhotoSequence;
likes: LikesController;
/** 当前筛选下的第 1 页取数(token/locale/category/sort 由调用方组装)。 */
fetchFirstPage: () => Promise<PublicPhotoList>;
/** 筛选指纹(token+locale+category+sort):变化时跳过移除检测(换集合非下线)。 */
filtersKey: () => string;
/** 照片被推送移除(hidden/deleted):neighborIndex 为移除前下标(大图回退邻居)。 */
onPhotoRemoved: (photoId: string, neighborIndex: number) => void;
setViewCount: (viewCount: number) => void;
}
function overrideNumber(key: string): number | null {
const value = (globalThis as unknown as Record<string, unknown>)[key];
return typeof value === 'number' && value > 0 ? value : null;
}
/** e2e 注入 WS 目标(空串/未置 → 真实推导 URL);逐次连接求值以便运行中切换。 */
function overrideWsUrl(): string | null {
const value = (globalThis as unknown as Record<string, unknown>).__viewerWsUrl;
return typeof value === 'string' && value !== '' ? value : null;
}
export function useAlbumRealtime(inputs: AlbumRealtimeInputs) {
const connection = ref<RealtimeState>('connecting');
const bubbles = ref<LikeBubble[]>([]);
let client: RealtimeClient | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
const bubbleTimers = new Set<ReturnType<typeof setTimeout>>();
let bubbleKey = 0;
/** 校准竞态令牌:仅最新一次重取可落地。 */
let calibrationSequence = 0;
/** 上轮第 1 页集合(移除检测基准)。 */
let lastFiltersKey = '';
let lastFirstPageIds = new Set<string>();
function pollIntervalMs(): number {
return overrideNumber('__viewerRealtimePollMs') ?? DEGRADED_POLL_MS;
}
// —— 气泡 ——
function showBubble(photoId: string, delta: number): void {
const photo = inputs.sequence.photos.find((item) => item.photoId === photoId);
const bubble: LikeBubble = {
key: ++bubbleKey,
photoId,
delta,
thumbUrl: photo?.thumb.url ?? null,
};
bubbles.value = [...bubbles.value.slice(-(BUBBLE_CAP - 1)), bubble];
const timer = setTimeout(() => {
bubbleTimers.delete(timer);
bubbles.value = bubbles.value.filter((item) => item.key !== bubble.key);
}, overrideNumber('__viewerBubbleMs') ?? BUBBLE_MS);
bubbleTimers.add(timer);
}
function dismissBubble(key: number): void {
bubbles.value = bubbles.value.filter((item) => item.key !== key);
}
// —— 校准(重连 resync 与降级轮询共用) ——
async function recalibrate(): Promise<void> {
const run = ++calibrationSequence;
try {
const page = await inputs.fetchFirstPage();
if (run !== calibrationSequence) return;
const pageIds = new Set(page.items.map((item) => item.photoId));
inputs.likes.recalibrateFromList(page.items);
if (containsNewPhotoId(inputs.sequence.photos.map((photo) => photo.photoId), page.items)) {
inputs.sequence.hasNew = true;
}
const key = inputs.filtersKey();
if (key === lastFiltersKey) {
// 筛选未变而第 1 页缺席 → 已下线:移出序列并通知(大图收口在调用方)。
for (const id of lastFirstPageIds) {
if (!pageIds.has(id) && inputs.sequence.indexOf(id) !== -1) {
const neighborIndex = inputs.sequence.indexOf(id);
inputs.sequence.removePhoto(id);
inputs.onPhotoRemoved(id, neighborIndex);
}
}
}
lastFiltersKey = key;
lastFirstPageIds = pageIds;
} catch {
// 校准取数失败静默:下一轮(重连/轮询)再试。
}
}
// —— 降级轮询 ——
function stopPolling(): void {
if (pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function startPolling(): void {
if (pollTimer !== null) return;
void recalibrate();
pollTimer = setInterval(() => void recalibrate(), pollIntervalMs());
}
// —— WS 客户端 ——
function stopClient(): void {
client?.stop();
client = null;
stopPolling();
connection.value = 'connecting';
}
function startClient(): void {
stopClient();
client = new RealtimeClient({
url: () => overrideWsUrl() ?? wsUrlFromApiBase(apiBase()),
albumToken: () => inputs.token.value,
retryBaseMs: overrideNumber('__viewerWsRetryBaseMs') ?? undefined,
onMessage: (message) =>
handleServerMessage(message, {
isHidden: () => typeof document !== 'undefined' && document.hidden,
reducedMotion: () => reducedMotion.value,
ownDeltaIn: (batchIds) => inputs.likes.ownDeltaIn(batchIds),
calibrateLike: (photoId, total, countVersion) =>
inputs.likes.calibrate(photoId, total, countVersion),
showBubble,
notifyNewPhotos: () => {
inputs.sequence.hasNew = true;
},
removePhoto: (photoId) => {
const neighborIndex = inputs.sequence.indexOf(photoId);
inputs.sequence.removePhoto(photoId);
inputs.onPhotoRemoved(photoId, neighborIndex);
},
setViewCount: inputs.setViewCount,
}),
onStateChange: (state) => {
connection.value = state;
if (state === 'degraded') startPolling();
else stopPolling();
},
onResync: () => void recalibrate(),
});
client.start();
}
function onVisibilityChange(): void {
if (document.hidden) {
// 不可见:暂停动画——在屏气泡清空(不排队补播),计数校准照常。
bubbles.value = [];
}
}
onMounted(() => {
startClient();
document.addEventListener('visibilitychange', onVisibilityChange);
});
onBeforeUnmount(() => {
stopClient();
document.removeEventListener('visibilitychange', onVisibilityChange);
for (const timer of bubbleTimers) clearTimeout(timer);
bubbleTimers.clear();
bubbles.value = [];
});
// token 变化(相册间跳转):换新连接(退订旧房间由断链完成)+ 状态复位。
watch(inputs.token, () => {
lastFiltersKey = '';
lastFirstPageIds = new Set();
bubbles.value = [];
startClient();
});
// reactive 包装:模板/脚本访问 realtime.bubbles 等 ref 自动解包(同 sequence 约定)。
return reactive({ connection, bubbles, dismissBubble });
}
export type AlbumRealtime = ReturnType<typeof useAlbumRealtime>;
+209
View File
@@ -0,0 +1,209 @@
// apps/viewer/src/realtime/ws.ts —— WS 客户端(P2 Task 10,契约帧见
// packages/contracts/src/realtime.ts)。与 Vue 解耦的纯状态机,便于单测:
//
// start → connecting(连 ov /v1/realtime,URL 由 API 基址 http→ws 推导)
// open → 发 subscribe{albumToken} + 周期 ping(HEARTBEAT_MS/2,服务端
// 20s 无心跳踢链,半频留裕量)
// close/error → 指数退避重连(retryBaseMs 1s→2s→4s,8s 封顶);**重连成功
// 即 onResync()**(订阅可能错过断线期事件——调用方做第 1 页重取
// 校准);连续 3 次失败 → degraded(调用方启动轮询降级;本客户端
// 仍按封顶间隔后台重试,恢复后回 open)。
// stop → 幂等收尾(关连接、清全部定时器)。
//
// 工厂注入 socketFactory:单测用假 WebSocket;运行时缺省浏览器原生。
// 浏览器面必须走 ./realtime 子路径:桶出口 index 会连带 internal-sync
// (node:crypto createHmac),进浏览器包即被 Vite 外置化而整页崩溃。
import {
HEARTBEAT_MS,
REALTIME_WS_PATH,
type ServerMessage,
} from '@photo/contracts/realtime';
/** 连接状态:open=正常;degraded=连续失败进入降级(轮询接管)。 */
export type RealtimeState = 'connecting' | 'open' | 'degraded';
/** 重连退避封顶(毫秒)。 */
export const RECONNECT_CAP_MS = 8000;
/** 连续失败多少次后进入 degraded(轮询降级阈值)。 */
export const DEGRADE_AFTER_FAILURES = 3;
/** 浏览器 WebSocket 的最小使用面(测试假件只需实现这些)。 */
export interface WebSocketLike {
send(data: string): void;
close(): void;
addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: unknown) => void): void;
}
export type WebSocketFactory = (url: string) => WebSocketLike;
export interface RealtimeClientOptions {
/** 目标 URL:静态串或逐次连接时求值的 getter(e2e 注入切换存活性)。 */
url: string | (() => string);
/** 相册 token 延迟求值:重连时重新读取(相册间跳转后重挂新客户端)。 */
albumToken: () => string;
onMessage: (message: ServerMessage) => void;
onStateChange: (state: RealtimeState) => void;
/** 断线后重连成功(服务端已重订):调用方做第 1 页校准。 */
onResync: () => void;
socketFactory?: WebSocketFactory;
/** 重连退避基值(毫秒,缺省 1000;e2e 可注入调短)。 */
retryBaseMs?: number;
/** 应用层 ping 间隔(毫秒,缺省 HEARTBEAT_MS/2)。 */
pingMs?: number;
}
/** API 基址 → WS URL:http→ws / https→wss,拼契约路径;空基址(同源部署)
* 按当前页面 origin 推导。 */
export function wsUrlFromApiBase(apiBase: string): string {
let origin = apiBase.replace(/\/+$/, '');
if (origin === '') {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
origin = `${proto}//${window.location.host}`;
} else {
origin = origin.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:');
}
return `${origin}${REALTIME_WS_PATH}`;
}
export class RealtimeClient {
private readonly options: RealtimeClientOptions;
private socket: WebSocketLike | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private failures = 0;
private hadOpenSession = false;
private stopped = false;
private state: RealtimeState = 'connecting';
constructor(options: RealtimeClientOptions) {
this.options = options;
}
private get retryBaseMs(): number {
return this.options.retryBaseMs ?? 1000;
}
private get pingMs(): number {
return this.options.pingMs ?? Math.ceil(HEARTBEAT_MS / 2);
}
private setState(state: RealtimeState): void {
if (this.state === state) return;
this.state = state;
this.options.onStateChange(state);
}
start(): void {
this.stopped = false;
this.setState('connecting');
this.connect();
}
private connect(): void {
if (this.stopped) return;
const url = typeof this.options.url === 'function' ? this.options.url() : this.options.url;
const factory = this.options.socketFactory ?? ((target: string) => new WebSocket(target) as unknown as WebSocketLike);
let socket: WebSocketLike;
try {
socket = factory(url);
} catch {
this.scheduleReconnect();
return;
}
this.socket = socket;
socket.addEventListener('open', () => this.onOpen());
socket.addEventListener('error', () => {
/* 握手/链路错误随后必有 close——统一在 onClosed 处理重连 */
});
socket.addEventListener('message', (event: unknown) => this.onMessage(event));
socket.addEventListener('close', () => this.onClosed());
}
private onOpen(): void {
if (this.stopped || this.socket === null) return;
const resync = this.hadOpenSession;
this.hadOpenSession = true;
this.failures = 0;
this.setState('open');
this.send({ type: 'subscribe', albumToken: this.options.albumToken() });
if (resync) this.options.onResync();
if (this.pingTimer === null) {
this.pingTimer = setInterval(() => {
this.send({ type: 'ping', ts: Date.now() });
}, this.pingMs);
}
}
private onMessage(event: unknown): void {
let data: unknown;
if (typeof MessageEvent !== 'undefined' && event instanceof MessageEvent) {
data = event.data;
} else {
// 假件直传字符串(测试环境无 MessageEvent 构造)。
data = (event as { data?: unknown }).data;
}
if (typeof data !== 'string') return;
let message: ServerMessage;
try {
message = JSON.parse(data) as ServerMessage;
} catch {
return; // 非 JSON 帧静默丢弃
}
if (typeof message !== 'object' || message === null || typeof message.type !== 'string') return;
this.options.onMessage(message);
}
private onClosed(): void {
this.stopPing();
this.socket = null;
if (this.stopped) return;
this.failures += 1;
if (this.failures >= DEGRADE_AFTER_FAILURES) this.setState('degraded');
else this.setState('connecting');
this.scheduleReconnect();
}
private scheduleReconnect(): void {
if (this.stopped || this.reconnectTimer !== null) return;
const exp = Math.min(this.failures, 3) - 1; // 1s→2s→4s,其后封顶
const delay = Math.min(this.retryBaseMs * 2 ** Math.max(exp, 0), RECONNECT_CAP_MS);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
private send(frame: unknown): void {
if (this.socket === null) return;
try {
this.socket.send(JSON.stringify(frame));
} catch {
// 发送缓冲异常(CLOSING 态):等 close 事件走重连。
}
}
private stopPing(): void {
if (this.pingTimer !== null) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
stop(): void {
this.stopped = true;
this.stopPing();
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
const socket = this.socket;
this.socket = null;
if (socket !== null) {
try {
socket.close();
} catch {
// 已在关闭中
}
}
}
}
+272
View File
@@ -0,0 +1,272 @@
// apps/viewer/src/stats/visitor.ts —— 访客标识与浏览上报(P2 Task 10,A14)。
//
// visitorId 跨标签共享(localStorage 'viewer.visitorId',32 hex):
// · 快路径:已存储 → 直接复用;
// · 冷启动:经 BroadcastChannel 'viewer-visitor' 广播 query 探询既有标签页
// → 有应答 claim 则采纳(写回本地);无应答 → 自生成、写后**读回校验**、
// 广播 claim;短暂窗口内听到更早的竞争 claim(claimedAt 小者胜,平局比
// id)则让位采纳——first-writer-wins;
// · BroadcastChannel 不可用(旧环境/jsdom)→ 纯 localStorage;
// · 存储整体不可用(读回失败/隐私模式极端)→ 页面级随机(不持久,跨刷新
// 变化——浏览计数退化为按页去重,登记为可接受降级)。
//
// 上报时机:相册信息加载成功 **且** 页面可见(隐藏时挂一次性 visibilitychange
// 等首次可见)。requestId(UUID)持久 sessionStorage 直到成功响应——窗口内
// 刷新/重试幂等;网络失败同 requestId 指数退避重试(最多 3 次),确认后以
// viewCount 权威值回调展示。
import { onBeforeUnmount, ref, type Ref } from 'vue';
import {
ApiError,
NETWORK_ERROR_CODE,
postAlbumVisit,
type VisitReportResponse,
} from '../api/client';
import { randomHex, randomUuid } from '../support/ids';
export const VISITOR_STORAGE_KEY = 'viewer.visitorId';
export const VISITOR_BROADCAST_CHANNEL = 'viewer-visitor';
/** 冷启动 query 探询窗(毫秒):等待既有标签页应答 claim。 */
export const VISITOR_QUERY_WINDOW_MS = 150;
/** 上报网络失败重试退避基值(毫秒),指数 1s/2s/4s。 */
export const VISIT_RETRY_BASE_MS = 1000;
export const VISIT_RETRY_MAX_ATTEMPTS = 3;
/** 测试注入面(缺省绑浏览器全局)。 */
export interface VisitorDeps {
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
channelFactory?: ((name: string) => BroadcastChannelLike | null) | null;
queryWindowMs?: number;
}
/** BroadcastChannel 最小使用面(jsdom 无实现,测试注入假件)。 */
export interface BroadcastChannelLike {
postMessage(data: unknown): void;
close(): void;
onmessage: ((event: { data: unknown }) => void) | null;
}
interface ClaimFrame {
type: 'claim';
visitorId: string;
claimedAt: number;
}
function isClaim(data: unknown): data is ClaimFrame {
if (typeof data !== 'object' || data === null) return false;
const frame = data as Record<string, unknown>;
return frame.type === 'claim' && typeof frame.visitorId === 'string' && typeof frame.claimedAt === 'number';
}
/** 解析/生成访客标识(异步:冷启动含跨标签探询窗)。 */
export async function resolveVisitorId(deps: VisitorDeps = {}): Promise<string> {
const storage = deps.storage ?? (typeof localStorage !== 'undefined' ? localStorage : undefined);
const queryWindowMs = deps.queryWindowMs ?? VISITOR_QUERY_WINDOW_MS;
if (storage !== undefined) {
try {
const existing = storage.getItem(VISITOR_STORAGE_KEY);
if (existing !== null && existing !== '') return existing;
} catch {
// 读失败视同无存储
}
}
const channel =
deps.channelFactory === undefined
? defaultChannel()
: deps.channelFactory === null
? null
: (deps.channelFactory(VISITOR_BROADCAST_CHANNEL) ?? null);
// 探询既有标签页(有 claim 应答即采纳)。
if (channel !== null) {
const ch = channel;
const answered = await new Promise<string | null>((resolve) => {
const timer = setTimeout(() => {
resolve(null);
}, queryWindowMs);
ch.onmessage = (event) => {
if (!isClaim(event.data)) return;
clearTimeout(timer);
resolve(event.data.visitorId);
};
ch.postMessage({ type: 'query' });
});
channel.onmessage = null;
if (answered !== null) {
channel.close();
if (storage !== undefined) {
try {
storage.setItem(VISITOR_STORAGE_KEY, answered);
} catch {
// 采纳成功但写回失败:内存态照用(本页一致即可)
}
}
return answered;
}
}
// 无应答:自生成 + 写读回校验 + 宣告(含短暂竞争窗,first-writer-wins)。
const claimedAt = Date.now();
const mine = randomHex(16);
if (storage !== undefined) {
try {
storage.setItem(VISITOR_STORAGE_KEY, mine);
const readBack = storage.getItem(VISITOR_STORAGE_KEY);
if (readBack !== mine) throw new Error('visitor id read-back mismatch');
} catch {
channel?.close();
return randomHex(16); // 存储不可用 → 页面级随机(不持久)
}
}
if (channel !== null) {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, queryWindowMs);
channel.onmessage = (event) => {
// 竞争 claim:更早者胜(claimedAt 平局比 id)——让位改写本地。
if (isClaim(event.data) && event.data.visitorId !== mine) {
const theirs: ClaimFrame = event.data;
const theirsWins =
theirs.claimedAt < claimedAt || (theirs.claimedAt === claimedAt && theirs.visitorId < mine);
if (theirsWins) {
clearTimeout(timer);
try {
storage?.setItem(VISITOR_STORAGE_KEY, theirs.visitorId);
} catch {
// 写失败忽略
}
// 本页仍用本轮 mine(跨页让位自下轮加载生效)——登记简化。
resolve();
}
}
};
channel.postMessage({ type: 'claim', visitorId: mine, claimedAt } satisfies ClaimFrame);
});
channel.onmessage = null;
channel.close();
}
return mine;
}
function defaultChannel(): BroadcastChannelLike | null {
if (typeof BroadcastChannel === 'undefined') return null;
// DOM BroadcastChannel 的 onmessage 事件类型比本模块最小使用面窄——收窄
// 仅为注入面统一(事件实际形状运行时一致)。
return new BroadcastChannel(VISITOR_BROADCAST_CHANNEL) as unknown as BroadcastChannelLike;
}
export interface VisitReportInputs {
token: Ref<string>;
/** 上报成功(任意 counted 结局):以权威 viewCount 更新展示。 */
onCounted: (viewCount: number) => void;
}
/**
* 浏览上报编排(ActivityPage 在相册加载成功后调用 notifyAlbumLoaded)。
* 单相册一次;token 变化(相册间跳转)重置可再报。
*/
export function useVisitReport(inputs: VisitReportInputs) {
const reported = ref(false);
let reporting = false;
let visibleListener: (() => void) | null = null;
const retryTimers = new Set<ReturnType<typeof setTimeout>>();
function resetForToken(): void {
reported.value = false;
reporting = false;
if (visibleListener !== null) {
document.removeEventListener('visibilitychange', visibleListener);
visibleListener = null;
}
for (const timer of retryTimers) clearTimeout(timer);
retryTimers.clear();
}
/** 相册加载成功:可见即报,不可见挂首次可见。幂等(同 token 只报一次)。 */
function notifyAlbumLoaded(): void {
if (reported.value || reporting) return;
if (typeof document !== 'undefined' && document.hidden) {
if (visibleListener !== null) return;
visibleListener = () => {
if (document.hidden) return;
document.removeEventListener('visibilitychange', visibleListener!);
visibleListener = null;
notifyAlbumLoaded();
};
document.addEventListener('visibilitychange', visibleListener);
return;
}
reporting = true;
void resolveVisitorId().then(
(visitorId) => runReport(visitorId),
() => {
reporting = false; // 标识解析失败(理论不可达):放弃本次
},
);
}
/** requestId 持久 sessionStorage(键随 token),成功响应后清除。 */
function requestKey(): string {
return `viewer.visitRequest:${inputs.token.value}`;
}
function takeRequestId(): string {
try {
const existing = sessionStorage.getItem(requestKey());
if (existing !== null && existing !== '') return existing;
const created = randomUuid();
sessionStorage.setItem(requestKey(), created);
return created;
} catch {
return randomUuid(); // 存储不可用:单次内存幂等键
}
}
function clearRequestId(): void {
try {
sessionStorage.removeItem(requestKey());
} catch {
// 忽略
}
}
function runReport(visitorId: string, attempt = 0): void {
const requestId = takeRequestId();
postAlbumVisit(inputs.token.value, { visitorId, requestId })
.then((response: VisitReportResponse) => {
clearRequestId();
reported.value = true;
reporting = false;
inputs.onCounted(response.viewCount);
})
.catch((error: unknown) => {
// 可重试面:网络失败/5xx(服务端可能仍处理了——同 requestId 重试幂等)。
const retryable =
error instanceof ApiError && (error.code === NETWORK_ERROR_CODE || error.status >= 500);
if (!retryable || attempt + 1 >= VISIT_RETRY_MAX_ATTEMPTS) {
reported.value = true; // 4xx/限速/重试耗尽:静默放弃(不打扰观展)
reporting = false;
if (retryable) {
console.info('[viewer] visit report dropped after retries; viewCount stays as loaded');
}
return;
}
const timer = setTimeout(() => {
retryTimers.delete(timer);
runReport(visitorId, attempt + 1);
}, VISIT_RETRY_BASE_MS * 2 ** attempt);
retryTimers.add(timer);
});
}
onBeforeUnmount(() => {
if (visibleListener !== null) {
document.removeEventListener('visibilitychange', visibleListener);
visibleListener = null;
}
for (const timer of retryTimers) clearTimeout(timer);
retryTimers.clear();
});
return { reported, notifyAlbumLoaded, resetForToken };
}
+20
View File
@@ -0,0 +1,20 @@
// apps/viewer/src/support/ids.ts —— 随机标识生成(P2 Task 10)。
// 现代浏览器/Node≥19 均有 crypto.randomUUID;getRandomValues 覆盖旧环境;
// 两者皆缺(极端隐私代理)退 Math.random——仅影响匿名标识随机性,不致失败。
/** 随机 UUID(点赞 batchId / 浏览 requestId 幂等键)。 */
export function randomUuid(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
return randomHex(16);
}
/** 随机十六进制串(bytes 字节 → 2×bytes 字符;visitorId=32 hex 用 bytes=16)。 */
export function randomHex(bytes: number): string {
const buf = new Uint8Array(bytes);
if (typeof crypto?.getRandomValues === 'function') {
crypto.getRandomValues(buf);
} else {
for (let i = 0; i < buf.length; i++) buf[i] = Math.floor(Math.random() * 256);
}
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
}
+38
View File
@@ -0,0 +1,38 @@
// apps/viewer/src/ui/prefs.ts —— 「减少动画」偏好(P2 Task 10)。
// localStorage 'viewer.reducedMotion'('1' 开 / 缺省关),模块级单例 ref:
// App.vue 顶栏开关与 ActivityPage 气泡门禁共享同一状态。开启时根节点挂
// viewer-reduced-motion 类,CSS 一并禁用心跳等小动画(仅保数字跳动)。
import { ref, watch } from 'vue';
export const REDUCED_MOTION_KEY = 'viewer.reducedMotion';
function readStored(): boolean {
try {
return localStorage.getItem(REDUCED_MOTION_KEY) === '1';
} catch {
return false;
}
}
export const reducedMotion = ref(readStored());
export function setReducedMotion(enabled: boolean): void {
reducedMotion.value = enabled;
try {
if (enabled) localStorage.setItem(REDUCED_MOTION_KEY, '1');
else localStorage.removeItem(REDUCED_MOTION_KEY);
} catch {
// 存储不可用:仅会话内生效
}
}
export function toggleReducedMotion(): void {
setReducedMotion(!reducedMotion.value);
}
// 根节点类联动(模块加载即生效,无需组件挂载;SSR 无此环境)。
if (typeof document !== 'undefined') {
watch(reducedMotion, (enabled) => {
document.documentElement.classList.toggle('viewer-reduced-motion', enabled);
}, { immediate: true });
}
+4
View File
@@ -12,6 +12,10 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./realtime": {
"types": "./dist/realtime.d.ts",
"default": "./dist/realtime.js"
},
"./openapi.yaml": "./openapi.yaml",
"./package.json": "./package.json"
},