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:
@@ -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 资产 → 不渲染图。
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,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,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 专用:不存在/未发布/已隐藏同码(防枚举),文案覆盖两种成因。
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
@@ -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 {
|
||||
// 已在关闭中
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user