feat(embed,ui04): E01 v2 contract skeleton — displayMode/scrollSpeed/reactionsEnabled, v2 loader entry and fixed 195x280 shell

- contracts: EmbedSlotSettings optional v2 fields (carousel default) + SlotSettingsPayload; gen + redocly green
- api: DTO validation + normalization full-set (cn manage + ov public projection share normalizeStoredSettings)
- embed-loader: params v2 parse/merge/url family, loader-v2.js fixed shell (border/radius/#10161E/safe-area, resize ignored, stack gap 12), dual-artifact build+sync; v1 source frozen
- viewer: EmbedPage v=2 branch, EmbedStream (viewport/track/real anchors/collapse/empty), stream.ts pure fns, i18n zh/en
- admin: types + utils (create default vertical-stream, clampScrollSpeed, v1/v2 snippet selection)
- tests: loader 36 (+9 v2), viewer 222, admin 82, api unit 51, v1 embed e2e 20, v2 E01 gate 2 (195x280 desktop+mobile cross-origin, no footer)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 12:50:28 +08:00
parent f8dbc3ab9b
commit 66d1eb3e9f
25 changed files with 1383 additions and 52 deletions
+113
View File
@@ -6,12 +6,17 @@ import { describe, expect, it } from 'vitest';
import {
MESSAGE_TYPES,
buildEmbedUrl,
buildEmbedUrlV2,
clampHeight,
mergeBackendConfig,
mergeBackendConfigV2,
normalizeBackendSettings,
normalizeBackendSettingsV2,
normalizeInterval,
parseConfigMessage,
parseConfigMessageV2,
parseEmbedConfig,
parseEmbedConfigV2,
SIZE_WIDTH,
} from './params.js';
@@ -308,3 +313,111 @@ describe('clampHeight', () => {
expect(clampHeight(Infinity)).toBeNull();
});
});
// ── UI-04 v2(vertical-stream)参数面 ──
describe('parseEmbedConfigV2', () => {
it('空 dataset 全缺省:v2 字段默认 + explicit 全 false', () => {
const c = parseEmbedConfigV2({ slot: 'tok' }, {});
expect(c.version).toBe(2);
expect(c.slot).toBe('tok');
expect(c.lang).toBe('auto');
expect(c.position).toBe('right');
expect(c.speed).toBe(16);
expect(c.reactions).toBe(true);
expect(c.offsetX).toBe(16);
expect(c.offsetBottom).toBe(24);
expect(c.zIndex).toBe(1000);
expect(c.pollMs).toBeNull();
expect(c.explicit).toEqual({
lang: false,
position: false,
speed: false,
reactions: false,
offsetX: false,
offsetBottom: false,
zIndex: false,
});
});
it('data-speed 夹取 8..32;非法回退 16', () => {
expect(parseEmbedConfigV2({ slot: 'a', speed: '1' }, {}).speed).toBe(8);
expect(parseEmbedConfigV2({ slot: 'a', speed: '24' }, {}).speed).toBe(24);
expect(parseEmbedConfigV2({ slot: 'a', speed: '99' }, {}).speed).toBe(32);
expect(parseEmbedConfigV2({ slot: 'a', speed: 'abc' }, {}).speed).toBe(16);
expect(parseEmbedConfigV2({ slot: 'a' }, {}).explicit.speed).toBe(false);
expect(parseEmbedConfigV2({ slot: 'a', speed: '24' }, {}).explicit.speed).toBe(true);
});
it('data-reactions 仅 on/off;其他回退 true', () => {
expect(parseEmbedConfigV2({ slot: 'a', reactions: 'off' }, {}).reactions).toBe(false);
expect(parseEmbedConfigV2({ slot: 'a', reactions: 'on' }, {}).reactions).toBe(true);
expect(parseEmbedConfigV2({ slot: 'a', reactions: 'yes' }, {}).reactions).toBe(true);
});
it('v1 布局字段(size/interval/imageFit)不在 v2 配置面(195×280 硬性)', () => {
const c = parseEmbedConfigV2({ slot: 'a', size: 'md', interval: '8000', imageFit: 'cover' }, {});
expect(c.size).toBeUndefined();
expect(c.intervalMs).toBeUndefined();
expect(c.imageFit).toBeUndefined();
});
});
describe('normalizeBackendSettingsV2 / mergeBackendConfigV2', () => {
it('v2 字段归一:displayMode 白名单 / speed 夹取 / reactions 布尔回退', () => {
const s = normalizeBackendSettingsV2({
position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false,
imageFit: 'contain', offsetX: 16, offsetBottom: 24, zIndex: 1000,
displayMode: 'vertical-stream', scrollSpeed: 20, reactionsEnabled: false,
});
expect(s.displayMode).toBe('vertical-stream');
expect(s.scrollSpeed).toBe(20);
expect(s.reactionsEnabled).toBe(false);
expect(normalizeBackendSettingsV2({ displayMode: 'weird', scrollSpeed: 999, reactionsEnabled: 'x' }).displayMode).toBe('carousel');
expect(normalizeBackendSettingsV2({ scrollSpeed: 999 }).scrollSpeed).toBe(32);
expect(normalizeBackendSettingsV2({ reactionsEnabled: 'x' }).reactionsEnabled).toBe(true);
expect(normalizeBackendSettingsV2(null)).toBeNull();
});
it('合并优先级:data-* 显式保持,缺省字段取后台值', () => {
const config = parseEmbedConfigV2({ slot: 'a', speed: '24' }, {});
const merged = mergeBackendConfigV2(config, {
position: 'left', size: 'md', intervalMs: 5000, lang: 'en', collapsedDefault: true,
imageFit: 'contain', offsetX: 40, offsetBottom: 80, zIndex: 2000,
displayMode: 'vertical-stream', scrollSpeed: 12, reactionsEnabled: false,
});
expect(merged.speed).toBe(24); // 显式 data-speed 胜出
expect(merged.reactions).toBe(false); // 后台(未显式)
expect(merged.position).toBe('left');
expect(merged.lang).toBe('en');
expect(merged.offsetX).toBe(40);
expect(merged.offsetBottom).toBe(80);
expect(merged.zIndex).toBe(2000);
});
});
describe('parseConfigMessageV2 / buildEmbedUrlV2', () => {
it('config 消息:版本闸(低于等于忽略)+ 非法忽略', () => {
const settings = { position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false };
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 2, settings }, 1)).not.toBeNull();
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 1, settings }, 1)).toBeNull();
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 'x', settings }, 0)).toBeNull();
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 2, settings: null }, 0)).toBeNull();
});
it('URL:v=2 恒在;speed/reactions 仅显式拼接;poll 透传', () => {
const base = parseEmbedConfigV2({ slot: 'tok' }, {});
expect(buildEmbedUrlV2('https://e.test', 'tok', base)).toBe(
'https://e.test/embed/tok?v=2&lang=auto&pos=right',
);
const explicit = parseEmbedConfigV2({ slot: 'tok', speed: '20', reactions: 'off', poll: '500' }, {});
expect(buildEmbedUrlV2('https://e.test', 'tok', explicit)).toBe(
'https://e.test/embed/tok?v=2&lang=auto&pos=right&speed=20&reactions=off&poll=500',
);
});
it('槽 token 编码防注入(路径段转义)', () => {
const c = parseEmbedConfigV2({ slot: 'a/b?c' }, {});
expect(buildEmbedUrlV2('https://e.test', 'a/b?c', c)).toContain('/embed/a%2Fb%3Fc?');
});
});