diff --git a/apps/admin/eslint.config.mjs b/apps/admin/eslint.config.mjs index bb317f6..53df458 100644 --- a/apps/admin/eslint.config.mjs +++ b/apps/admin/eslint.config.mjs @@ -47,7 +47,10 @@ export default [ // 合并从 autoImportConfig 中读取的全局变量配置 ...autoImportConfig.globals, // TypeScript 全局命名空间 - Api: 'readonly' + Api: 'readonly', + // login/index.vue 使用但 .auto-import.json 生成缓存缺登(types/import/ + // auto-imports.d.ts 已声明;UI-04 E02 登记的模板存量偏差最小修复) + ElNotification: 'readonly' } }, rules: { diff --git a/apps/admin/src/utils/system/embedSlots.spec.ts b/apps/admin/src/utils/system/embedSlots.spec.ts index d117a2c..0e03bf1 100644 --- a/apps/admin/src/utils/system/embedSlots.spec.ts +++ b/apps/admin/src/utils/system/embedSlots.spec.ts @@ -229,7 +229,9 @@ describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省 describe('嵌入代码 v2 入口(UI-04:displayMode=vertical-stream → /embed/v2/)', () => { it('v2 默认 settings → 裸 v2 引用(无 v1 布局 data-*)', () => { - expect(buildEmbedSnippet('https://view.example.com', 'tok', { ...DEFAULT_EMBED_SETTINGS })).toBe( + expect( + buildEmbedSnippet('https://view.example.com', 'tok', { ...DEFAULT_EMBED_SETTINGS }) + ).toBe( '' ) }) diff --git a/apps/viewer/e2e/embed-v2.spec.ts b/apps/viewer/e2e/embed-v2.spec.ts index c57590f..03788d2 100644 --- a/apps/viewer/e2e/embed-v2.spec.ts +++ b/apps/viewer/e2e/embed-v2.spec.ts @@ -1,4 +1,7 @@ import { createServer, type Server } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { expect, test, type Page } from '@playwright/test'; import { cleanupSeededAlbums, @@ -12,10 +15,26 @@ import { // UI-04 v2(vertical-stream 照片流窗)跨源 Playwright:宿主 127.0.0.1:3999 ← // viewer 5175 真跨源,loader /embed/v2/loader.js。随 E01-E07 逐任务扩展: -// E01 出口门 = 固定 195×280 小窗跨源可见且无 footer(本文件首批用例)。 +// E01 出口门 = 固定 195×280 小窗跨源可见且无 footer;E02 = 静态高保真测量。 +// 照片种子为真实摄影样本(e2e/fixtures,picsum 固定 seed 下载——验收 §11.1 +// 「无渐变色块假图」;比例覆盖横/竖/方/全景,EMB-A03 素材基础)。 const HOST_PORT = 3999; const VIEWER_ORIGIN = 'http://127.0.0.1:5175'; const MINUTE = 60_000; +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT_ROOT = join(HERE, '../../../docs/designs/ui04-implementation'); + +/** 摄影样本(picsum seed 固定):[file, width, height]。 */ +const FIXTURES: Array<[string, number, number]> = [ + ['photo-01.jpg', 800, 500], + ['photo-02.jpg', 800, 1100], + ['photo-03.jpg', 800, 800], + ['photo-04.jpg', 1200, 400], + ['photo-05.jpg', 800, 1400], + ['photo-06.jpg', 900, 600], + ['photo-07.jpg', 800, 1000], + ['photo-08.jpg', 1000, 700], +]; let slotToken = ''; @@ -28,9 +47,15 @@ async function seedFeed(alias: string): Promise { { locale: 'en', title: `${alias} Album` }, ], }); - for (let i = 1; i <= 6; i++) { - await seedPhoto(album.id, `${alias}-p${i}`, { - publishedAt: new Date(Date.now() - (7 - i) * MINUTE), + for (let i = 0; i < FIXTURES.length; i++) { + const [file, width, height] = FIXTURES[i]; + const bytes = await readFile(join(HERE, 'fixtures', file)); + await seedPhoto(album.id, `${alias}-p${i + 1}`, { + publishedAt: new Date(Date.now() - (FIXTURES.length - i) * MINUTE), + width, + height, + thumbBytes: bytes, + browseBytes: bytes, }); } await flushSync(album.id); @@ -79,7 +104,15 @@ async function openHost(page: Page, viewport: { width: number; height: number }) test.beforeAll(async () => { slotToken = await seedFeed('ui04v2'); - await new Promise((resolve) => server.listen(HOST_PORT, '127.0.0.1', resolve)); + await new Promise((resolve) => { + // 残留进程容错:上轮失败运行的夹具服务器可能仍占端口——同内容服务, + // 复用即可(其后用例改写 currentHtml 对两个进程同值)。 + server.once('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') resolve(); + else throw err; + }); + server.listen(HOST_PORT, '127.0.0.1', () => resolve()); + }); }); test.afterAll(async () => { @@ -121,3 +154,73 @@ test.describe.serial('E01 exit gate: fixed window, no footer', () => { }); } }); + +test.describe.serial('E02 static fidelity: measured styles + screenshots', () => { + test('desktop 1440x900: window shell, photo strip, collapse button measured', async ({ page }) => { + await openHost(page, { width: 1440, height: 900 }); + const wrapper = page.locator('iframe[src*="/embed/"][src*="v=2"]').locator('..'); + await expect(page.locator('iframe[src*="/embed/"][src*="v=2"]')).toBeVisible(); + await expect + .poll(async () => Math.round((await wrapper.boundingBox())?.width ?? 0)) + .toBe(195); + await expect + .poll(async () => Math.round((await wrapper.boundingBox())?.height ?? 0)) + .toBe(280); + + // 外壳视觉规格(§2.1):圆角 12 / 单圈 1px 白边 / 底色 #10161E / 阴影。 + const shell = await wrapper.evaluate((el) => { + const cs = getComputedStyle(el); + return { + radius: cs.borderTopLeftRadius, + borderWidth: cs.borderTopWidth, + borderColor: cs.borderTopColor, + background: cs.backgroundColor, + boxShadow: cs.boxShadow, + boxSizing: cs.boxSizing, + }; + }); + expect(shell.radius).toBe('12px'); + expect(shell.borderWidth).toBe('1px'); + expect(shell.borderColor).toBe('rgba(255, 255, 255, 0.8)'); + expect(shell.background).toBe('rgb(16, 22, 30)'); + expect(shell.boxSizing).toBe('border-box'); + + // 照片带(§2.2/2.3):原比例、宽满内宽(195-2=193)、3px 缝。 + const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]'); + const firstImg = inside.locator('.stream-photo__img').first(); + await expect(firstImg).toBeVisible(); + const imgBox = await firstImg.boundingBox(); + expect(Math.round(imgBox?.width ?? 0)).toBe(193); + // 首图 = 最新照片(publishedAt 倒序)= photo-08 1000×700 → 193*0.7 ≈ 135。 + expect(Math.round(imgBox?.height ?? 0)).toBe(135); + const gap = await inside.locator('.stream-track').evaluate((el) => getComputedStyle(el).rowGap); + expect(gap).toBe('3px'); + + // 收起入口(§2.4):可视圆 24×24 右上内缩 6px;有效触区 44×44。 + const collapse = inside.locator('.stream-collapse'); + const cbBox = await collapse.boundingBox(); + expect(Math.round(cbBox?.width ?? 0)).toBe(44); + expect(Math.round(cbBox?.height ?? 0)).toBe(44); + const vpBox = await inside.locator('.stream-viewport').boundingBox(); + expect(Math.round((vpBox?.x ?? 0) + (vpBox?.width ?? 0) - (cbBox?.x ?? 0) - 44)).toBe(0); + expect(Math.round((cbBox?.y ?? 0) - (vpBox?.y ?? 0))).toBe(0); + + // 表面无工具元素(§2.2):除收起外无其他 button/链接按钮形态。 + await expect(inside.locator('button')).toHaveCount(1); + + await page.screenshot({ path: join(OUT_ROOT, 'actual/desktop/e02-static-desktop.png') }); + }); + + test('mobile 390x844: expanded window identical 195x280 + screenshot', async ({ page }) => { + await openHost(page, { width: 390, height: 844 }); + await page.locator('[role="button"]').first().click(); + const wrapper = page.locator('iframe[src*="/embed/"][src*="v=2"]').locator('..'); + await expect + .poll(async () => Math.round((await wrapper.boundingBox())?.width ?? 0)) + .toBe(195); + await expect + .poll(async () => Math.round((await wrapper.boundingBox())?.height ?? 0)) + .toBe(280); + await page.screenshot({ path: join(OUT_ROOT, 'actual/mobile/e02-static-mobile.png') }); + }); +}); diff --git a/apps/viewer/e2e/fixtures/photo-01.jpg b/apps/viewer/e2e/fixtures/photo-01.jpg new file mode 100644 index 0000000..97bde6c Binary files /dev/null and b/apps/viewer/e2e/fixtures/photo-01.jpg differ diff --git a/apps/viewer/e2e/fixtures/photo-02.jpg b/apps/viewer/e2e/fixtures/photo-02.jpg new file mode 100644 index 0000000..d0ae490 Binary files /dev/null and b/apps/viewer/e2e/fixtures/photo-02.jpg differ diff --git a/apps/viewer/e2e/fixtures/photo-03.jpg b/apps/viewer/e2e/fixtures/photo-03.jpg new file mode 100644 index 0000000..2281d6a Binary files /dev/null and b/apps/viewer/e2e/fixtures/photo-03.jpg differ diff --git a/apps/viewer/e2e/fixtures/photo-04.jpg b/apps/viewer/e2e/fixtures/photo-04.jpg new file mode 100644 index 0000000..76c198a Binary files /dev/null and b/apps/viewer/e2e/fixtures/photo-04.jpg differ diff --git a/apps/viewer/e2e/fixtures/photo-05.jpg b/apps/viewer/e2e/fixtures/photo-05.jpg new file mode 100644 index 0000000..873bf1f Binary files /dev/null and b/apps/viewer/e2e/fixtures/photo-05.jpg differ diff --git a/apps/viewer/e2e/fixtures/photo-06.jpg b/apps/viewer/e2e/fixtures/photo-06.jpg new file mode 100644 index 0000000..e12f4c2 Binary files /dev/null and b/apps/viewer/e2e/fixtures/photo-06.jpg differ diff --git a/apps/viewer/e2e/fixtures/photo-07.jpg b/apps/viewer/e2e/fixtures/photo-07.jpg new file mode 100644 index 0000000..d357e52 Binary files /dev/null and b/apps/viewer/e2e/fixtures/photo-07.jpg differ diff --git a/apps/viewer/e2e/fixtures/photo-08.jpg b/apps/viewer/e2e/fixtures/photo-08.jpg new file mode 100644 index 0000000..f30022c Binary files /dev/null and b/apps/viewer/e2e/fixtures/photo-08.jpg differ diff --git a/apps/viewer/e2e/helpers.ts b/apps/viewer/e2e/helpers.ts index 57966ab..c3f12e0 100644 --- a/apps/viewer/e2e/helpers.ts +++ b/apps/viewer/e2e/helpers.ts @@ -151,6 +151,10 @@ export interface SeedPhotoOptions { width?: number; height?: number; publishedAt: Date; + /** UI-04:注入真实图片字节(如 e2e/fixtures 摄影样本)替代 sharp 色块; + * thumb/browse 各自可选,缺省仍按尺寸生成。bytes 与宽高须与元数据一致。 */ + thumbBytes?: Buffer; + browseBytes?: Buffer; } /** 直种已发布照片:photos 行(ready/published,entityVersion=2 对齐发布事务)+ @@ -166,8 +170,8 @@ export async function seedPhoto( const thumbWidth = Math.max(1, Math.round(width / 2)); const thumbHeight = Math.max(1, Math.round(height / 2)); const [thumb, browse] = await Promise.all([ - makeJpeg(thumbWidth, thumbHeight), - makeJpeg(width, height), + options.thumbBytes ?? makeJpeg(thumbWidth, thumbHeight), + options.browseBytes ?? makeJpeg(width, height), ]); await storage.put(variantKey(albumId, photoId, 'thumb'), thumb, 'image/jpeg'); await storage.put(variantKey(albumId, photoId, 'browse'), browse, 'image/jpeg'); diff --git a/apps/viewer/src/pages/embed/EmbedStream.vue b/apps/viewer/src/pages/embed/EmbedStream.vue index d376351..86e0545 100644 --- a/apps/viewer/src/pages/embed/EmbedStream.vue +++ b/apps/viewer/src/pages/embed/EmbedStream.vue @@ -95,6 +95,26 @@ function onPhotoClick(photo: PublicEmbedPhoto): void { overflow: hidden; background: #10161e; } +/* 上下 12px 弱暗渐隐(§2.3 可选项,opacity ≤0.12):暗示滚动照片带被窗口 + * 裁切(设计稿 soft fade),不遮照片内容、不拦截点击(pointer-events 无)。 */ +.stream-viewport::before, +.stream-viewport::after { + content: ''; + position: absolute; + left: 0; + right: 0; + height: 12px; + z-index: 2; + pointer-events: none; +} +.stream-viewport::before { + top: 0; + background: linear-gradient(to bottom, rgba(16, 22, 30, 0.12), rgba(16, 22, 30, 0)); +} +.stream-viewport::after { + bottom: 0; + background: linear-gradient(to top, rgba(16, 22, 30, 0.12), rgba(16, 22, 30, 0)); +} .stream-track { display: flex; flex-direction: column; diff --git a/docs/designs/ui04-implementation/actual/desktop/e02-static-desktop.png b/docs/designs/ui04-implementation/actual/desktop/e02-static-desktop.png new file mode 100644 index 0000000..941d71c Binary files /dev/null and b/docs/designs/ui04-implementation/actual/desktop/e02-static-desktop.png differ diff --git a/docs/designs/ui04-implementation/actual/mobile/e02-static-mobile.png b/docs/designs/ui04-implementation/actual/mobile/e02-static-mobile.png new file mode 100644 index 0000000..ec3aa6b Binary files /dev/null and b/docs/designs/ui04-implementation/actual/mobile/e02-static-mobile.png differ diff --git a/docs/plans/progress/UI04-验收记录.md b/docs/plans/progress/UI04-验收记录.md index 000323f..feda838 100644 --- a/docs/plans/progress/UI04-验收记录.md +++ b/docs/plans/progress/UI04-验收记录.md @@ -61,13 +61,30 @@ - [x] E01 契约和版本骨架(第 2 节;改动面:contracts openapi+events+gen、api dto/ embed.service(cn 管理与 ov 投影共用归一化)、embed-loader params+loader-v2+build/sync、 viewer EmbedPage v=2 分支+EmbedStream+stream.ts+i18n、admin 类型+utils+snippet v1/v2 版本选择) -- [ ] E02 静态高保真样板 +- [x] E02 静态高保真样板(登记见下) - [ ] E03 连续滚动 - [ ] E04 操作和生命周期 - [ ] E05 实时气泡 - [ ] E06 管理预览与接入 - [ ] E07 验收(EMB-A01…A16 逐项见第 5 节) +### E02 登记(2026-09-22) + +- 真实摄影素材:e2e/fixtures/photo-01..08.jpg(picsum 固定 seed 下载,800×500 ~ + 1200×400 混合比例);helpers.seedPhoto 扩展 thumbBytes/browseBytes 注入(向后兼容)。 +- 静态测量(e2e/embed-v2.spec.ts E02 块,全部断言过): + - wrapper 195×280 / radius 12px / border 1px rgba(255,255,255,0.8) / bg rgb(16,22,30) + / box-sizing border-box(§2.1 全项); + - 首图(最新序)193 宽 ×135 高(1000×700 原比例);track rowGap 3px(§2.2/2.3); + - 收起:44×44 触区锚定右上、可视圆 24×24 内缩 6px;全窗唯一 button(§2.4); + - 上下 12px 弱暗渐隐(rgba(16,22,30,0.12),§2.3 可选项落地)。 +- 截图:actual/desktop/e02-static-desktop.png、actual/mobile/e02-static-mobile.png + (390×844 展开态同尺寸 195×280)。 +- 存量偏差修复登记:admin eslint globals 缺 ElNotification(模板 .auto-import.json + 生成缓存不含、types/import d.ts 已声明——login/index.vue 存量问题,UI-04 前即红; + 最小修复 eslint.config.mjs globals 补 readonly)。 +- E02 出口确认:无标题/底栏/页码/左右按钮,图片窗口形态与目标图一致(截图对照)。 + ## 4 测量与证据索引 (E02+ 起登记:尺寸实测、位移曲线、DOM/连接上限统计、录屏清单。)