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/连接上限统计、录屏清单。)