docs: UI-02 acceptance record and visual verification

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 23:10:58 +08:00
parent 160e7e5f1a
commit 2a489aa16a
23 changed files with 297 additions and 75 deletions
+6 -3
View File
@@ -50,12 +50,13 @@ 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 号');
// Task 10 起排序 chips(最新/热门)与分类 chips 并存——断言收窄到分类栏。
// Task 10 起排序(最新/热门)与分类 chips 并存——UI-02 V02 起排序为标签页
// (.sort-tabs .sort-tab),断言收窄到分类栏。
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('.sort-tabs .sort-tab')).toHaveText(['最新', '热门']);
await expect(page.locator('.empty-notice')).toHaveText('照片即将上线');
// 该相册无 banner 资产 → 不渲染图。
@@ -77,7 +78,9 @@ test('switching locale re-fetches and shows the English translation', async ({ p
await page.goto(`/a/${album.publicToken}`);
await expect(page.locator('h1.title')).toHaveText('双语测试相册');
await page.locator('button.locale-switcher').click();
// UI-02 V01 紧凑语言控件:展开菜单后选择 English(两步)。
await page.locator('.locale-trigger').click();
await page.locator('.locale-option', { hasText: 'English' }).click();
await expect(page.locator('h1.title')).toHaveText('Bilingual Test Album');
await expect(page.locator('.description')).toHaveText('English intro');
await expect(page.locator('.meta')).toContainText('Main Hall');
+10 -10
View File
@@ -39,7 +39,7 @@ async function openLightbox(page: Page, token: string, init?: () => void): Promi
await page.goto(`/a/${token}`);
await expect(page.locator('.cell').first()).toBeVisible();
await page.locator('.cell').first().click();
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('.photo-viewer')).toBeVisible();
}
test.afterAll(async () => {
@@ -56,18 +56,18 @@ test('download entry visibility follows album two-tier switches', async ({ page
await openLightbox(page, offOff);
await expect(page.locator('.download-group')).toHaveCount(0);
// on/off:单按钮(无 caret/菜单)。
// on/off:单按钮(无 caret/菜单)。UI-02 V05:下载位在底部操作栏(.download-main)。
const onOff = await seedDownloadAlbum('dl-onoff', {});
await openLightbox(page, onOff);
await expect(page.locator('.control.download')).toBeVisible();
await expect(page.locator('.control.download-caret')).toHaveCount(0);
await expect(page.locator('.download-main')).toBeVisible();
await expect(page.locator('.download-caret')).toHaveCount(0);
// on/on:分体按钮,菜单两选项(下载图片 / 下载原图)。
// on/on:分体按钮,菜单两选项(浏览图 / 原图)。
const onOn = await seedDownloadAlbum('dl-onon', { originalDownloadEnabled: true });
await openLightbox(page, onOn);
await expect(page.locator('.control.download-caret')).toBeVisible();
await page.locator('.control.download-caret').click();
await expect(page.locator('.download-menu button')).toHaveText(['下载图片', '下载原图']);
await expect(page.locator('.download-caret')).toBeVisible();
await page.locator('.download-caret').click();
await expect(page.locator('.download-menu button')).toHaveText(['浏览图', '原图']);
});
test('WeChat webview UA shows guidance overlay instead of downloading', async ({ page }) => {
@@ -85,7 +85,7 @@ test('WeChat webview UA shows guidance overlay instead of downloading', async ({
if (request.url().includes('/download?')) endpointHit = true;
});
await page.locator('.control.download').click();
await page.locator('.download-main').click();
await expect(page.locator('.wechat-guide[role="dialog"]')).toBeVisible();
await expect(page.locator('.wechat-guide h2')).toHaveText('保存图片指引');
await expect(page.locator('.guide-copy')).toBeVisible();
@@ -112,7 +112,7 @@ test('real click downloads browse via signed url from the endpoint', async ({ pa
(response) =>
response.url().includes('/download?variant=browse') && response.status() === 200,
);
await page.locator('.control.download').click();
await page.locator('.download-main').click();
const response = await endpoint;
expect((await response.json()).variant).toBe('browse');
+13 -11
View File
@@ -384,9 +384,11 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
await expect(page.locator('h1.title')).toHaveText(zhTitle);
await expect(page.locator('.categories .chip')).toHaveCount(2); // 全部 + 主舞台
await page.locator('button.locale-switcher').click();
await page.locator('.locale-trigger').click();
await page.locator('.locale-option', { hasText: 'English' }).click();
await expect(page.locator('h1.title')).toHaveText(enTitle);
await page.locator('button.locale-switcher').click(); // 切回 zh,后续断言用中文
await page.locator('.locale-trigger').click(); // 切回 zh,后续断言用中文
await page.locator('.locale-option', { hasText: '中文' }).click();
await expect(page.locator('h1.title')).toHaveText(zhTitle);
// 瀑布流 3 张 → 分类 chip 过滤 1 张 → 全部恢复 3 张。
@@ -397,18 +399,18 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
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(['最新', '热门']);
await expect(page.locator('.view-count')).toHaveText('1 次浏览', { timeout: 15_000 });
await expect(page.locator('.sort-tabs .sort-tab')).toHaveText(['最新', '热门']);
// 大图:点击开图 → 真实字节 → 滚轮缩放 → 双击复位 → 关闭回列表。
const lightboxPhoto = photoIds[1]!; // 800×500(后续 admin 腿按尺寸锚定隐藏)
await page.locator('.cell').nth(1).click();
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('.photo-viewer')).toBeVisible();
await expect(
page.locator('img.photo'),
).toHaveAttribute('src', new RegExp(lightboxPhoto));
// Task 10:大图内真实点赞 1 次 → 防抖冲批 → 权威计数 1。
await page.locator('.control.like-button').click();
await page.locator('.action-bar .like-slot .action-btn').click();
await expect(page.locator('.like-count')).toHaveText('1');
await expect
.poll(async () =>
@@ -416,7 +418,7 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
)
.toBeGreaterThan(0);
expect(page.url()).toContain(`/a/${publicToken}/p/${lightboxPhoto}`);
const box = await page.locator('.lightbox').boundingBox();
const box = await page.locator('.photo-viewer').boundingBox();
expect(box).not.toBeNull();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
await page.mouse.wheel(0, -360);
@@ -435,12 +437,12 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
.evaluate((img) => new DOMMatrixReadOnly(getComputedStyle(img).transform).a),
)
.toBeCloseTo(1, 1);
await page.locator('.control.close').click();
await expect(page.locator('.lightbox')).toHaveCount(0);
await page.locator('.viewer-close').click();
await expect(page.locator('.photo-viewer')).toHaveCount(0);
// 深链直达:/a/{token}/p/{photoId} 直接开在大图上。
await page.goto(`/a/${publicToken}/p/${lightboxPhoto}`);
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('.photo-viewer')).toBeVisible();
await expect(page.locator('img.photo')).toHaveAttribute(
'src',
new RegExp(lightboxPhoto),
@@ -473,7 +475,7 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
const notice = page.locator('.deep-link-notice');
await expect(notice).toBeVisible();
await expect(notice).toContainText('照片不存在或已下线');
await expect(page.locator('.lightbox')).toHaveCount(0);
await expect(page.locator('.photo-viewer')).toHaveCount(0);
// ── ⑤ 空相册 B:viewer 空态提示(B 经 API 建于 cn,先同步到 ov)──
await flushSync(emptyAlbumId);
+15 -15
View File
@@ -74,9 +74,9 @@ test('lightbox likes accumulate to 3 and persist across reload', async ({ page }
await openAlbum(page, token);
await page.locator('.cell').first().click();
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('.photo-viewer')).toBeVisible();
for (let i = 0; i < 3; i++) {
await page.locator('.control.like-button').click();
await page.locator('.action-bar .like-slot .action-btn').click();
}
// 防抖 500ms 冲批 → 服务端权威校准 3(响应/WS burst 先后到达均为 3)。
await expect(page.locator('.like-count')).toHaveText('3');
@@ -92,10 +92,10 @@ test('lightbox likes accumulate to 3 and persist across reload', async ({ page }
// 刷新后从服务端列表回归:深链自动重开大图(URL 仍停在该照片),计数 3;
// 关闭后格子计数亦 3(sessionId 会话内持久,幂等面不变)。
await page.reload();
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('.photo-viewer')).toBeVisible();
await expect(page.locator('.like-count')).toHaveText('3');
await page.locator('.control.close').click();
await expect(page.locator('.lightbox')).toHaveCount(0);
await page.locator('.viewer-close').click();
await expect(page.locator('.photo-viewer')).toHaveCount(0);
await expect(page.locator('.cell-like .count').first()).toHaveText('3');
});
@@ -124,7 +124,7 @@ test('others burst bubbles on my page; my own like does not', async ({ browser,
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();
await expect(page.locator('.photo-viewer')).toBeVisible();
} finally {
await ctx2.close();
}
@@ -173,7 +173,7 @@ test('hidden photo disappears from the feed and closes the lightbox via WS push'
// 大图停在第 2 张:下线推送应回退邻居并收口(closeLightbox 路径)。
await page.locator('.cell').nth(1).click();
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('.photo-viewer')).toBeVisible();
await deleteSeededPhoto(photoIds[1]!);
await flushSync(albumId); // photo.deleted 走真实管线 → ov 撤下 + WS 房间广播
@@ -181,7 +181,7 @@ test('hidden photo disappears from the feed and closes the lightbox via WS push'
// 推送到达:列表无刷新即时 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('.photo-viewer')).toBeVisible();
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[2]!));
});
@@ -198,7 +198,7 @@ test('sort toggle switches latest/hot snapshots and is disabled in the lightbox'
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 expect(page.locator('.sort-tabs .sort-tab').nth(1)).toHaveClass(/active/);
// 切回最新:原顺序恢复(冷快照语义,各自独立)。
await page.getByRole('button', { name: '最新' }).click();
@@ -206,9 +206,9 @@ test('sort toggle switches latest/hot snapshots and is disabled in the lightbox'
// 大图打开期间排序切换禁用(快照冻结,避免半开序列换序)。
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();
await expect(page.locator('.photo-viewer')).toBeVisible();
await expect(page.locator('.sort-tabs .sort-tab').first()).toBeDisabled();
await expect(page.locator('.sort-tabs .sort-tab').nth(1)).toBeDisabled();
});
test('visit counting: fresh context counts once, reload is idempotent, second context counts', async ({
@@ -222,19 +222,19 @@ test('visit counting: fresh context counts once, reload is idempotent, second co
try {
await openAlbum(page1, token);
// 首访计数(上报成功后以权威 viewCount 回填)。
await expect(page1.locator('.view-count')).toHaveText('1', { timeout: 15_000 });
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');
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 });
await expect(page2.locator('.view-count')).toHaveText('2 次浏览', { timeout: 15_000 });
} finally {
await ctx2.close();
}
+12 -12
View File
@@ -71,7 +71,7 @@ test('open from feed, navigate in sequence, zoom, close restores scroll and url'
// 点击第 3 格 → 大图:browse 图取回真实字节,URL 换为深链。
await page.locator('.cell').nth(2).click();
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('.photo-viewer')).toBeVisible();
await expect
.poll(() => browseNaturalWidth(page))
.toBeGreaterThan(0);
@@ -88,10 +88,10 @@ test('open from feed, navigate in sequence, zoom, close restores scroll and url'
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[step]!), { timeout: 10_000 });
}
expect(page.url()).toContain(`/a/${token}/p/${photoIds[5]}`);
await expect(page.locator('.counter')).toHaveText('6 / 8');
await expect(page.locator('.position-indicator')).toHaveText('6 / 8');
// 滚轮缩放(无需 ctrl):transform scale > 1;双击复位。
const box = await page.locator('.lightbox').boundingBox();
const box = await page.locator('.photo-viewer').boundingBox();
expect(box).not.toBeNull();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
await page.mouse.wheel(0, -360);
@@ -109,8 +109,8 @@ test('open from feed, navigate in sequence, zoom, close restores scroll and url'
.toBeCloseTo(1, 1);
// 关闭:URL 弹回相册路由,滚动精确回位。
await page.locator('.control.close').click();
await expect(page.locator('.lightbox')).toHaveCount(0);
await page.locator('.viewer-close').click();
await expect(page.locator('.photo-viewer')).toHaveCount(0);
await expect(page).toHaveURL(new RegExp(`/a/${token}/?$`));
await expect
.poll(() => page.evaluate(() => window.scrollY))
@@ -123,12 +123,12 @@ test('deep link opens on the target photo with the list loaded behind', async ({
const target = photoIds[4]!; // 第 5 张
await openLocale(page, token, `/p/${target}`);
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('.photo-viewer')).toBeVisible();
await expect
.poll(() => browseNaturalWidth(page))
.toBeGreaterThan(0);
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(target));
await expect(page.locator('.counter')).toHaveText('5 / 8');
await expect(page.locator('.position-indicator')).toHaveText('5 / 8');
// 背后列表已构成序列(8 张)。
await expect(page.locator('.cell')).toHaveCount(8);
@@ -138,8 +138,8 @@ test('deep link opens on the target photo with the list loaded behind', async ({
expect(page.url()).toContain(`/a/${token}/p/${photoIds[3]}`);
// 关闭 → 回相册路由(深链直达无本会话 push:replace 语义)。
await page.locator('.control.close').click();
await expect(page.locator('.lightbox')).toHaveCount(0);
await page.locator('.viewer-close').click();
await expect(page.locator('.photo-viewer')).toHaveCount(0);
await expect(page).toHaveURL(new RegExp(`/a/${token}/?$`));
});
@@ -155,7 +155,7 @@ test('deep link to a removed photo shows the localized notice and a working back
const notice = page.locator('.deep-link-notice');
await expect(notice).toBeVisible();
await expect(notice).toContainText('照片不存在或已下线');
await expect(page.locator('.lightbox')).toHaveCount(0);
await expect(page.locator('.photo-viewer')).toHaveCount(0);
const back = notice.getByRole('link', { name: '返回相册' });
await back.click();
@@ -172,13 +172,13 @@ test('navigating past the first page extends the sequence', async ({ page }) =>
await expect(page.locator('.cell')).toHaveCount(30);
await page.locator('.cell').nth(29).click(); // 第 1 页最后一张
await expect(page.locator('.lightbox')).toBeVisible();
await expect(page.locator('.photo-viewer')).toBeVisible();
// 打开即临近末页边界:相邻预取已后台拉取第 2 页(设计行为),右切越过末尾
// 落在第 31 张(序列第 2 页),列表同步扩展。
await page.keyboard.press('ArrowRight');
await expect(page.locator('img.photo')).toHaveAttribute('src', new RegExp(photoIds[30]!), { timeout: 15_000 });
await expect(page.locator('.counter')).toHaveText('31 / 32');
await expect(page.locator('.position-indicator')).toHaveText('31 / 32');
// 背后列表同步扩展到 32 张。
await expect(page.locator('.cell')).toHaveCount(32);
});
+25
View File
@@ -232,6 +232,31 @@ describe('photo feed (Task 7 / Task 8 sequence props)', () => {
expect(wrapper.find('.new-photo-bar').exists()).toBe(false);
});
// —— UI-02 Task 6(§7 状态矩阵):加载更多失败——保留已有照片,底部重试 ——
it('load-more failure keeps the loaded photos and offers a bottom retry', async () => {
const { wrapper } = mountFeed();
await flushPromises();
expect(wrapper.findAll('.cell')).toHaveLength(3);
const sentinel = wrapper.find('.sentinel');
mockedGetPublicPhotos.mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network down', 0, null));
sentinelObserver().trigger([sentinel.element], true);
await flushPromises();
// 已有照片不被清空;不渲染整页错误块,只在底部给重试。
expect(wrapper.findAll('.cell')).toHaveLength(3);
expect(wrapper.find('.error[role="alert"]').exists()).toBe(false);
const retry = wrapper.find('.more-failed button');
expect(retry.text()).toBe('重试');
// 底部重试成功 → 追加第 2 页并到尽头。
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([makePhoto('p4')], null));
await retry.trigger('click');
await flushPromises();
expect(wrapper.findAll('.cell')).toHaveLength(4);
expect(wrapper.find('.end-note').text()).toBe('已经到底了');
});
it('renders the localized empty state when the album has no photos', async () => {
mockedGetPublicPhotos.mockResolvedValueOnce(makeList([]));
const { wrapper } = mountFeed();
+22
View File
@@ -53,4 +53,26 @@ describe('viewer i18n', () => {
expect(wrapper.text()).toContain('打开相册链接即可观看');
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-CN');
});
// UI-02 Task 6(V05 §5.4 分享链路闭环):分享 URL 携带 ?lang=,接收端
// 首载据此定语言——优先于本地持久化偏好;不合法值(fr/auto)自然回退。
it('initial locale honors the ?lang= share parameter over the stored preference', () => {
localStorage.setItem(LOCALE_STORAGE_KEY, 'zh-CN');
window.history.pushState({}, '', '/a/token?lang=en');
expect(createViewerI18n().global.locale.value).toBe('en');
// 非法/不支持值:回落到持久化偏好。
window.history.pushState({}, '', '/a/token?lang=auto');
expect(createViewerI18n().global.locale.value).toBe('zh-CN');
window.history.pushState({}, '', '/a/token?lang=fr');
expect(createViewerI18n().global.locale.value).toBe('zh-CN');
// 无参数:持久化偏好照旧生效。
window.history.pushState({}, '', '/a/token');
expect(createViewerI18n().global.locale.value).toBe('zh-CN');
// 参数不写 localStorage——接收者未做选择,显式切换才持久化。
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-CN');
});
});
@@ -432,6 +432,32 @@ describe('photo lightbox dark viewer (UI-02 V04)', () => {
trigger.remove();
});
// UI-02 Task 6 无障碍抽检(§5.3):Tab 循环困于对话框——两端折回,不穿透。
it('traps Tab focus inside the dialog (wrap-around at both ends)', async () => {
const wrapper = mountLightbox('p2');
document.body.appendChild(wrapper.element);
await flushPromises();
const focusables = Array.from(
wrapper.element.querySelectorAll('button:not([disabled])'),
).filter((el): el is HTMLElement => el instanceof HTMLElement);
expect(focusables.length).toBeGreaterThan(1);
const first = focusables[0]!;
const last = focusables[focusables.length - 1]!;
// 末元素正向 Tab → 折回首元素。
last.focus();
last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(document.activeElement).toBe(first);
// 首元素 Shift+Tab → 折回末元素。
first.focus();
first.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(last);
});
it('locks body scroll while open and restores the previous value on close', async () => {
document.body.style.overflow = 'auto';
const wrapper = mountLightbox('p2');
@@ -454,6 +480,16 @@ describe('photo lightbox dark viewer (UI-02 V04)', () => {
expect(source).toContain('env(safe-area-inset-top, 0px)');
expect(source).toContain('env(safe-area-inset-bottom, 0px)');
});
it('layers the fullscreen overlay above the sticky topbar (close button clickable)', () => {
// jsdom 无布局:源级断言(同上)。覆盖层 z=200 须高于 sticky 顶栏 z=120
// (App.vue)——否则顶栏盖住关闭按钮的可点区(e2e 曾拦截 click 的真实回归)。
const source = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), '..', 'components', 'PhotoLightbox.vue'),
'utf8',
);
expect(source).toMatch(/\.photo-viewer\s*\{[^}]*z-index:\s*200/s);
});
});
describe('photo lightbox interactions (UI-02 V05)', () => {
+3 -1
View File
@@ -705,7 +705,9 @@ onBeforeUnmount(() => {
.photo-viewer {
position: fixed;
inset: 0;
z-index: 100;
/* 200:盖住 sticky 顶栏(z=120)与偏好菜单(z=130)——全屏模态下顶栏不可
* 点也不可见;下线轻提示(z=210)仍在其上。 */
z-index: 200;
height: 100vh;
height: 100dvh;
display: flex;
@@ -449,6 +449,8 @@ onBeforeUnmount(() => {
display: flex;
gap: 24px;
margin-top: 8px;
/* 标签与信息区 16px 对齐(效果图 §3.1/§4.2);分隔线保持通栏。 */
padding: 0 var(--viewer-pad-info, 16px);
border-bottom: 1px solid var(--viewer-divider, #e5eaf1);
}
.sort-tab {
@@ -494,7 +496,9 @@ onBeforeUnmount(() => {
gap: 8px;
overflow-x: auto;
padding: 2px var(--viewer-pad-info, 16px) 6px;
margin: -2px calc(var(--viewer-pad-info, 16px) * -1) 0;
/* 仅纵向负边距抵消 padding:本元件父级已是通栏宽,横向负边距会把盒子推出
* 视口(页面出现横向滚动);首胶囊的 16px 缩进由上方 padding 提供。 */
margin: -2px 0 0;
scrollbar-width: thin;
}
.chip {
+10 -1
View File
@@ -11,8 +11,17 @@ function isSupported(value: string): value is ViewerLocale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
// 持久化优先(localStorage),其次浏览器语言(zh*/en 前缀),兜底 zh-CN。
// 分享链接 ?lang= 参数优先(V05 §5.4:相册/照片分享 URL 携带语言,接收端
// 据此呈现——不持久化,接收者未做选择;显式切换仍走 LocaleSwitcher 落盘),
// 其次持久化(localStorage),再次浏览器语言(zh*/en 前缀),兜底 zh-CN。
// 嵌入 iframe 的 lang 查询(zh-CN/en/auto)语义在 embed/feed.ts 独立解析,
// 此处仅认 zh-CN/en——auto/非法值自然落入既有解析链,EmbedPage 加载后仍会
// 按相册 defaultLocale 覆盖,互不冲突。
function resolveInitialLocale(): ViewerLocale {
if (typeof window !== 'undefined') {
const param = new URLSearchParams(window.location.search).get('lang');
if (param !== null && isSupported(param)) return param;
}
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored !== null && isSupported(stored)) return stored;
+3 -1
View File
@@ -437,7 +437,9 @@ onBeforeUnmount(() => {
left: 50%;
transform: translateX(-50%);
margin: 0;
z-index: 110;
/* 大图覆盖层(.photo-viewer z=200)之上——下线提示须在查看器打开时仍可见;
* 顶栏 z=120 已被覆盖层盖住,不再互相遮挡。 */
z-index: 210;
border-radius: 8px;
background: rgba(17, 24, 39, 0.9);
color: #f9fafb;
+7
View File
@@ -102,6 +102,13 @@ Schema 所有权:国内库表(users、albums、photos、upload_sessions、ou
> 1064,+129);loader 9462B/3728B gzip。320/430 宽度、文字缩放与深色真机实拍随
> G2 一并执行)。
> **进度:UI-02 观众页视觉重构完成,2026-09-21,tag `ui02`**(非阶段行——观众侧
> 横向视觉升级:蓝白活动首页(品牌栏+宣传图+信息区+排序/分类工具栏)+ 响应式双列
> 瀑布流(点赞角标/新图提示)+ 深色沉浸式大图查看器(缩略图导航+位置真实回退+点赞
> 粒子+分享下载);纯 viewer 前端改造,业务数据流零变更,样式作用域隔离不影响
> EmbedPage。验收记录见 docs/plans/progress/UI02-验收记录.md:V-A01–A15 矩阵、
> 前后截图、全量回归数字。)
阶段顺序 P1 → P2 → P3 → P4a → P4b → P5。P3 与 P4a 在 P2 契约稳定后可并行;P4b 依赖 P2 的公开列表契约与同步通道(展示位事件扩展),其 T15 全端语言验收还需 P3 完成;P5 依赖全部前序。
每阶段出口执行统一回归门:lint + typecheck + 单元 + 集成 + E2E 全绿,并产出《阶段验收记录》(docs/plans/progress/,逐项对照 A 编号登记证据与未尽事项)。
+130 -20
View File
@@ -1,29 +1,139 @@
# UI-02 观众页视觉重构 验收记录
基线 commit:3327551 · 日期:2026-09-21 · 环境:Windows + Docker + viewer dev (5175)
基线 commit:f31388c(tag `ui01`)· 完成 commit:本提交(tag `ui02`)· 日期:2026-09-21 · 执行环境:Windows 11 + Docker Desktop(mysql/redis×2 + minio)+ Playwright Chromium(1440×900 / 390×844 / 320×568)
## 基线截图
## 1. 基线与完成 commit
| 编号 | 页面 | 语言 | 文件 |
| 项 | 值 |
|---|---|
| 基线 | f31388c(UI-01 完成提交,tag `ui01`) |
| 完成 | Task 6 验收提交(含代码修正 + 本记录 + after 截图 + 总纲注记),tag `ui02` |
| 工作树 | 基线后首个提交为方案文档(3327551);验收提交仅含 apps/viewer 代码/测试、本记录、总纲注记与 after 截图目录 |
任务提交链:3327551(方案)→ 2cbd167 → 9c3915b → 3d89fcd → 89f4589 → 160e7e5 → 本提交,代码提交共 6 个。
## 2. 任务完成表(Task 1–6)
| 任务 | 状态 | commit | 备注 |
|---|---|---|---|
| 01 | 首页 | zh-CN | ui02-screenshots/baseline/01-home-zh-CN.png |
| 02 | 首页 | en | ui02-screenshots/baseline/01-home-en.png |
| 03 | 大图 | zh-CN | ui02-screenshots/baseline/02-lightbox-zh-CN.png |
| 04 | 大图 | en | ui02-screenshots/baseline/02-lightbox-en.png |
| Task 1: 基线截图与验收骨架 | ✅ | 2cbd167 | 改版前观众页 4 张基线截图(首页/大图 × 中/英)+ 本记录骨架 |
| Task 2: V01+V02 视觉基础与活动信息 | ✅ | 9c3915b | 作用域 token / 顶栏(品牌+语言+减少动画)/ 宣传图 / 信息区(标题/说明/元信息/分享)/ 排序与分类工具栏(AlbumHeader 提取) |
| Task 3: V03 瀑布流 | ✅ | 3d89fcd | 响应式列数 / 真实宽高 / 点赞角标 / 新图提示 / 懒加载与占位 |
| Task 4: V04 深色大图查看器 | ✅ | 89f4589 | 深色沉浸式布局 / 缩略图条 / 操作栏 / 保留缩放·导航·路由编排 |
| Task 5: V05 互动与分享下载 | ✅ | 160e7e5 | 心形粒子 / 互动轻提示 / 相册与单图分享(navigator.share + 复制回退)/ 下载菜单 |
| Task 6: V06+V07 状态国际化与验收 | ✅ | 本提交 | 全量回归 / 状态矩阵核验 / l10n 补齐(?lang= 参数)/ a11y 抽查 / 截图 / 本记录 / tag |
## 任务完成表
| Task | 状态 | 提交 |
|---|---|---|
| 1: V00 基线 | ✅ | (本次) |
| 2: V01+V02 首页 | 待执行 | |
| 3: V03 瀑布流 | 待执行 | |
| 4: V04 大图查看器 | 待执行 | |
| 5: V05 分享下载互动 | 待执行 | |
| 6: V06+V07 验收 | 待执行 | |
## V-A 验收矩阵
## 3. 验收矩阵(方案 §9.2)
| 编号 | 状态 | 证据 |
|---|---|---|
| V-A01 至 V-A15 | 待验 | |
| V-A01 | ✅ | App.vue 无手机外壳/假内容/底部导航;层级=顶栏→宣传图→信息→工具栏→瀑布流;截图 after/home-390-zh.png、home-1440.png;album-header.spec.ts(标题/说明/元信息结构) |
| V-A02 | ✅ | PhotoFeed 瀑布流(真实宽高绝对定位,非等高网格);feed.spec.ts(列数/占位/懒加载/身份保持);截图 home-320(2 列)/ home-390 / home-1440 |
| V-A03 | ✅ | album-header.spec.ts:宣传图缺失/加载失败收起且信息可用、标题两行钳制+展开、说明三行+展开/收起、日期地点缺失自然省略、窄屏元信息换行 |
| V-A04 | ✅ | 浏览数=album.viewCount 初值 + 上报回调/stats.updated 校准(ActivityPage useVisitReport);e2e journey.spec '1 次浏览';分页/点赞/切图不重复计访问(visitor 幂等键,A14 沿用) |
| V-A05 | ✅ | e2e album.spec(sort-tabs 文案 ['最新','热门']、分类筛选列收窄断言);likes.spec(热门快照重取+锁定);feed.spec 新图提示(不强制跳顶);分类 ID 跨语言保留(activity.spec) |
| V-A06 | ✅ | lightbox.spec.ts(完整显示/左右切换/越过末尾自动翻页/位置指示/缩略图条同步);截图 after/lightbox-390-zh.png、lightbox-1440.png;缩放交互沿用 Task 8 既有实现 |
| V-A07 | ✅ | activity.spec.ts:深链直达(solo 兜底+相邻页归位)、push/back 关闭回位(滚动锚点恢复)、浏览器后退关闭、照片移除(WS/404)轻提示+邻居回退;lightbox.spec 'layers above sticky topbar'(z=200) |
| V-A08 | ✅ | 分享 URL 携带 ?lang=(ActivityPage photoShareUrl);接收端首载优先参数——i18n.spec 'initial locale honors the ?lang= share parameter';复制失败回退显示 URL(shareState 'failed',album-header.spec);分享菜单(lightbox share-btn + 头部分享) |
| V-A09 | ✅ | e2e download.spec(.download-main 菜单 ['浏览图','原图']、相册开关关闭时入口隐藏);useDownload 单测(签名 URL 过期→受控刷新有限重试→占位);失败仅本地反馈(InteractionNotice) |
| V-A10 | ✅ | e2e likes.spec(连续点赞真实累计、自己回声不重复计入、WS 实时多观众可见、断连降级轮询后恢复);useLikes 批次+校准单测 |
| V-A11 | ✅ | likes.spec(他人点赞不污染当前数值);LikeBubbles 数量上限+点击定位;大图打开时页面级气泡清空(ActivityPage `lightboxPhotoId === null ? bubbles : []`),改由查看器内互动提示承载(§6.2) |
| V-A12 | ✅ | 减少动画:HeartParticles/LikeBubbles/过渡尊重 prefers-reduced-motion 与顶栏开关(heart-particles 单测);lightbox.spec 'traps Tab focus inside the dialog'(含首尾环绕);:focus-visible 全套主色描边;aria-modal/role=dialog/aria-label |
| V-A13 | ✅ | viewer/position.ts 诚实口径单测:solo/未定位→'照片预览'、无总数且还有页→'已加载 N 张'、到底→'i / n'、总数仅服务端提供才显示;新图提示数量来自 realtime 实际合并数 |
| V-A14 | ✅ | 中英模板零硬编码(i18n 键集相等守护 error-coverage.spec);html lang 随 locale(App.vue watchEffect);320/390/1440 截图无横向溢出(320 实测 scrollWidth=clientWidth);lightbox 100dvh+safe-area、数字 tabular-nums;⚠️ 横屏与系统字体放大留 G2 真机(同 UI-01 A07 口径) |
| V-A15 | ✅ | `git diff ui01..HEAD` 范围=apps/viewer + docs(业务后端/移动端/嵌入链零改动);embed 页独立外壳无顶栏无外边距(embed.spec 全绿);loader 构建字节一致(9462 B);api-cn/sync/worker/admin e2e 全绿 |
## 4. 状态矩阵核验(方案 §7)
| 状态 | 结果 | 证据 |
|---|---|---|
| 加载中 | ✅ | 相册状态文本 + PhotoFeed 骨架占位(feed.spec) |
| 相册不存在/未公开 | ✅ | 本地化不可用提示 + 重试(ActivityPage loadError;activity.spec) |
| 无照片 | ✅ | '照片即将上线' 空态,无上传按钮(e2e 实拍 after/empty-album-390.png;feed.spec) |
| 首载网络失败 | ✅ | 错误提示 + 重试按钮(activity.spec) |
| 加载更多失败 | ✅ | 已加载照片保留 + 底部重试(Task 6 新增 feed.spec 'load-more failure keeps the loaded photos and offers a bottom retry') |
| URL 过期 | ✅ | 受控刷新有限重试→占位(useDownload/lightbox 单测) |
| 照片下线/删除 | ✅ | 移除 + 邻居回退 + 轻提示(z=210 盖查看器;activity.spec) |
| 深链照片不存在 | ✅ | 本地化提示 + 返回相册链接(deep-link-notice;activity.spec) |
| WS 断连 | ✅ | 降级轮询继续合并新图(e2e likes.spec 'WS loss degrades to polling'),提示为连接态而非'无新照片' |
| 分享/下载失败 | ✅ | 仅本地轻提示/回退(shareState failed 显示 URL;下载失败 InteractionNotice),不弹全局错误 |
## 5. 截图索引
基线(2026-09-21,commit 2cbd167):`ui02-screenshots/baseline/`(改版前观众页)
| 编号 | 页面 | 文件 |
|---|---|---|
| 01 | 首页(中/英) | baseline/01-home-zh-CN.png、01-home-en.png |
| 02 | 大图(中/英) | baseline/02-lightbox-zh-CN.png、02-lightbox-en.png |
优化后(2026-09-21,本提交):`ui02-screenshots/after/`
| 编号 | 页面 | 文件 | 验证点 |
|---|---|---|---|
| 01 | 首页·390·中 | after/home-390-zh.png | 层级/对齐(标题=tab=胶囊 16px)/无横向滚动 |
| 02 | 首页·390·英 | after/home-390-en.png | `?lang=en` 直达英文(html lang=en) |
| 03 | 大图·390·中 | after/lightbox-390-zh.png | 深色沉浸/顶栏关闭/位置指示/操作栏/缩略图条 |
| 04 | 大图·390·英 | after/lightbox-390-en.png | 英文文案(Share 等) |
| 05 | 首页·320 | after/home-320.png | 最窄无溢出(实测 scrollWidth=clientWidth=305) |
| 06 | 大图·320 | after/lightbox-320.png | 操作栏/缩略图条不溢出 |
| 07 | 首页·1440 | after/home-1440.png | 居中 1120 列 + 对齐 168.5px(列边+16) |
| 08 | 大图·1440 | after/lightbox-1440.png | 桌面深色大图 + 悬停控件 |
| 09 | 空相册·390 | after/empty-album-390.png | '照片即将上线' 空态无上传按钮 |
采集环境:demo-seed 12 张渐变真实上传图 + 本地 dev 栈(cn 8080 / ov 8081 / worker / sync 2s 推送 / viewer 5175);320/390 检查含 JS 实测文档横向溢出(修复后均为 0)。
与效果图差异:效果图中的手机外壳/编号/投影为展示板元素未带入(§3.1 约定);分类胶囊数、浏览数等为真实接口数据,与效果图的假数据不同属预期。
## 6. 测试命令与真实结果
命令序列:清场(杀残留 dev 进程)→ `pnpm lint` → `pnpm typecheck` → `pnpm test` → `pnpm build` → `pnpm e2e` → `source scripts/flutter-env.sh && cd apps/mobile && flutter analyze && flutter test` → `pnpm --filter @photo/embed-loader build` + 字节比对。
| 门 | 结果 |
|---|---|
| pnpm lint | 全部任务通过(Task 6 后 viewer 单包复跑 ✓) |
| pnpm typecheck | 全部任务通过(含 Task 6 新增/修改测试;CSS 修正后 viewer 复跑 ✓) |
| pnpm test(单元) | **426 用例全绿**:viewer 222(28 文件)/ admin 77(14)/ embed-loader 27 / contracts 29 / storage 6 / api-cn 51 / worker 14 |
| pnpm build | 全部任务通过(viewer dist 223.85 kB JS / 78.42 gzip;CSS 27.24 kB / 5.25 gzip) |
| pnpm e2e | api-cn 257/257(24 套件)/ sync 27/27 / worker 13/13 / admin 全绿 / viewer 44:41 通过 + 1 环境性抖动(隔离复跑 6/6 ✓,见偏差 4)+ 2 未运行(同文件串行连带,复跑含于 6/6) |
| flutter analyze | No issues found!(0 问题) |
| flutter test | 341/341 全绿(UI-02 未触及移动端,回归保护) |
| loader build+比对 | dist/v1/loader.js 9462 B 与 apps/viewer/public/embed/v1/loader.js 字节一致 |
测试统计对比(UI-01 → UI-02):
| 维度 | 前(UI-01) | 后(UI-02) | Δ |
|---|---|---|---|
| Web 单元 | 359(viewer 155/20 文件) | 426(viewer 222/28 文件) | +67 |
| Web e2e | 364(viewer 44/9 specs) | 364(viewer 44/9 specs,选择器全面迁移至新结构) | 0 |
| Mobile | 341/341 | 341/341 | 0(保护性回归) |
## 7. 既有改动保护与外部环境
- 纯 viewer 前端改造:`git diff ui01..HEAD` = apps/viewer + docs(73 文件),业务数据流/API/同步/嵌入链零变更;EmbedPage 独立外壳未受 token 作用域影响(embed e2e 全绿)。
- 无关未跟踪文件未纳入提交:`.playwright-mcp/`、`docs/designs/app-redesign-2026-09-21/` 保持原状。
- 未触碰用户自有进程(scripts/serve.mjs 4173、Playwright MCP);截图用 dev 栈用毕已清理(8080/8081/5175 释放)。
- 演示数据(demo-seed 相册 + 空相册)写入本地 dev 库,不涉及远程/云资源。
## 8. 方案偏差与剩余事项
偏差:
1. **顶栏 z-index 压盖大图(Task 6 发现并修复)**:sticky 顶栏 z=120 盖住 .photo-viewer z=100,关闭按钮不可点(e2e 首跑 4 项失败定位)。修复:查看器 z=200、下线轻提示 z=210,并补 lightbox.spec 源码断言防回归。
2. **工具栏对齐与 1px 横向溢出(Task 6 截图核验发现并修复)**:.chips-track 横向负边距在通栏父级上把盒子推出视口(390 视口 scrollWidth 391→页面横向滚动),且排序 tab/分类胶囊与信息区 16px 缩进不齐(效果图三者同缩进)。修复:去掉横向负边距、.sort-tabs 补 16px 内边距;320/390/1440 实测零溢出并对齐,截图为修复后版本。
3. **e2e 选择器迁移收敛在 Task 6**:Tasks 2–5 专注实现与单元测试,5 个 e2e spec 仍指向旧结构(首跑 15 项失败);Task 6 完成 album/download/likes/viewer/journey 全量迁移(含两步语言切换、下载菜单文案)。
4. **embed-crossorigin 环境性抖动**:全量并行下 outbox_events.updateMany 偶发 MySQL 写冲突/死锁(PrismaClientKnownRequestError,种子并行 flushSync 竞争),两轮全量各命中 1 例且不同用例;隔离复跑 6/6 通过。与 UI-01 偏差 3 同类的测试基建问题,非代码回归;后续可为 flushOutbox 加重试(独立小改,未夹带)。
5. **`?lang=` 分享参数(Task 6 补齐)**:方案 §5.4 要求分享链接携带语言,Task 5 已带参但接收端未优先解析;Task 6 在 resolveInitialLocale 增加 ?lang= 最高优先(不写 localStorage——接收者未做选择),配 i18n.spec 用例。
6. **26 个 .playwright-mcp 快照文件误入 3327551(方案文档提交)**:不影响运行;建议后续以一次 `git rm` 清理(本次不重写历史)。
7. **加载中状态形态**:相册加载为诚实状态文本 + 瀑布流骨架占位,未做信息区整块骨架(字段未知时无法诚实渲染占位文案);与方案 §7 'loading→skeleton' 的合理收敛。
剩余(交接):
- G2 真机追加:横屏实拍、系统字体放大 1.3/2.0、真实全景/长图样本(沿 UI-01 清单)。
- flushOutbox 死锁重试(测试基建小改,见偏差 4)。
- .playwright-mcp 历史文件清理(见偏差 6)。
## 9. 外部关口(不变)
- G2 真机(未执行,不阻塞 UI-02 本地验收)
- G4/D08 用户接口、P5 真实环境接入按总纲推进;UI-02 全部为本地开发环境验证。
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB