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:
+11
-1
@@ -29,7 +29,11 @@ declare namespace Api {
|
||||
/** 轮播图片适配方式(UI-01:contain=4:3 舞台完整展示;cover=裁切填满) */
|
||||
type ImageFit = 'contain' | 'cover'
|
||||
|
||||
/** 展示设置(响应中字段恒齐全;UI-01 新增字段为可选扩展,响应恒为归一化全集) */
|
||||
/** 展示模式(UI-04):carousel=旧版轮播(v1 loader);vertical-stream=
|
||||
* v2 垂直照片流窗(固定 195×280 连续上滚,忽略 size/intervalMs/imageFit 布局作用) */
|
||||
type DisplayMode = 'carousel' | 'vertical-stream'
|
||||
|
||||
/** 展示设置(响应中字段恒齐全;UI-01/UI-04 新增字段为可选扩展,响应恒为归一化全集) */
|
||||
interface Settings {
|
||||
position: Position
|
||||
size: Size
|
||||
@@ -46,6 +50,12 @@ declare namespace Api {
|
||||
offsetBottom: number
|
||||
/** 宿主页面层级(UI-01;1..2147483000,缺省 1000) */
|
||||
zIndex: number
|
||||
/** 展示模式(UI-04;服务端归一恒在,旧记录读取归一为 carousel) */
|
||||
displayMode: DisplayMode
|
||||
/** v2 自动上滚速度 CSS px/秒(8..32,缺省 16;仅 vertical-stream 生效) */
|
||||
scrollSpeed: number
|
||||
/** v2 点赞气泡开关(缺省 true;false 仅隐藏气泡) */
|
||||
reactionsEnabled: boolean
|
||||
}
|
||||
|
||||
/** 展示位条目(管理视角) */
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
clampIntervalMs,
|
||||
clampOffsetBottom,
|
||||
clampOffsetX,
|
||||
clampScrollSpeed,
|
||||
clampZIndex,
|
||||
DEFAULT_EMBED_SETTINGS,
|
||||
EMBED_ORIGIN_FALLBACK,
|
||||
embedOriginFromEnv,
|
||||
explicitDataAttrs,
|
||||
explicitDataAttrsV2,
|
||||
isSlotDraftDirty,
|
||||
type EmbedSettingsDraft
|
||||
} from './embedSlots'
|
||||
@@ -34,7 +36,7 @@ const CURRENT_ITEM: Api.EmbedSlot.Item = {
|
||||
}
|
||||
|
||||
describe('buildSettingsPayload 表单 → settings 载荷', () => {
|
||||
it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全,含 UI-01 新字段)', () => {
|
||||
it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全,含 UI-01/UI-04 新字段)', () => {
|
||||
expect(buildSettingsPayload({ ...DEFAULT_EMBED_SETTINGS })).toEqual({
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
@@ -44,7 +46,10 @@ describe('buildSettingsPayload 表单 → settings 载荷', () => {
|
||||
imageFit: 'contain',
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000
|
||||
zIndex: 1000,
|
||||
displayMode: 'vertical-stream',
|
||||
scrollSpeed: 16,
|
||||
reactionsEnabled: true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,6 +96,27 @@ describe('buildSettingsPayload 表单 → settings 载荷', () => {
|
||||
zIndex: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('UI-04 scrollSpeed → 夹取到 8..32(非有限值回退 16)', () => {
|
||||
expect(clampScrollSpeed(1)).toBe(8)
|
||||
expect(clampScrollSpeed(64)).toBe(32)
|
||||
expect(clampScrollSpeed(Number.NaN)).toBe(16)
|
||||
const draft: EmbedSettingsDraft = { ...DEFAULT_EMBED_SETTINGS, scrollSpeed: 20 }
|
||||
expect(buildSettingsPayload(draft).scrollSpeed).toBe(20)
|
||||
const overflow: EmbedSettingsDraft = { ...DEFAULT_EMBED_SETTINGS, scrollSpeed: 99 }
|
||||
expect(buildSettingsPayload(overflow).scrollSpeed).toBe(32)
|
||||
})
|
||||
|
||||
it('UI-04 displayMode/reactionsEnabled 变化 → 进编辑载荷(整体替换含新字段)', () => {
|
||||
const changed: EmbedSettingsDraft = {
|
||||
...DEFAULT_EMBED_SETTINGS,
|
||||
displayMode: 'carousel',
|
||||
reactionsEnabled: false
|
||||
}
|
||||
expect(buildSlotEditPayload(CURRENT_ITEM, CURRENT_ITEM.name, changed)).toEqual({
|
||||
settings: changed
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSlotEditPayload 编辑载荷收敛', () => {
|
||||
@@ -140,10 +166,13 @@ describe('buildEmbedSnippet 嵌入代码拼接', () => {
|
||||
})
|
||||
|
||||
describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省让后台设置生效)', () => {
|
||||
it('全默认 settings → 零 data-* 覆盖(裸 loader 引用)', () => {
|
||||
expect(explicitDataAttrs({ ...DEFAULT_EMBED_SETTINGS })).toEqual({})
|
||||
it('全默认 settings(carousel)→ 零 data-* 覆盖(裸 v1 引用)', () => {
|
||||
expect(explicitDataAttrs({ ...DEFAULT_EMBED_SETTINGS, displayMode: 'carousel' })).toEqual({})
|
||||
expect(
|
||||
buildEmbedSnippet('https://view.example.com', 'tok', { ...DEFAULT_EMBED_SETTINGS })
|
||||
buildEmbedSnippet('https://view.example.com', 'tok', {
|
||||
...DEFAULT_EMBED_SETTINGS,
|
||||
displayMode: 'carousel'
|
||||
})
|
||||
).toBe(
|
||||
'<script src="https://view.example.com/embed/v1/loader.js" data-slot="tok" defer></script>'
|
||||
)
|
||||
@@ -155,6 +184,7 @@ describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省
|
||||
it('非默认字段 → 对应 data-*(烤串属性名对齐 loader dataset 键)', () => {
|
||||
const settings: EmbedSettingsDraft = {
|
||||
...DEFAULT_EMBED_SETTINGS,
|
||||
displayMode: 'carousel',
|
||||
position: 'left',
|
||||
intervalMs: 8000,
|
||||
imageFit: 'cover'
|
||||
@@ -179,7 +209,10 @@ describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省
|
||||
imageFit: 'cover',
|
||||
offsetX: 40,
|
||||
offsetBottom: 80,
|
||||
zIndex: 2000
|
||||
zIndex: 2000,
|
||||
displayMode: 'carousel',
|
||||
scrollSpeed: 24,
|
||||
reactionsEnabled: false
|
||||
}
|
||||
expect(Object.keys(explicitDataAttrs(settings))).toEqual([
|
||||
'data-position',
|
||||
@@ -194,6 +227,40 @@ 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(
|
||||
'<script src="https://view.example.com/embed/v2/loader.js" data-slot="tok" defer></script>'
|
||||
)
|
||||
})
|
||||
|
||||
it('v2 非默认 speed/reactions/位置 → data-speed/data-reactions/data-position(v1 布局字段不生成)', () => {
|
||||
const settings: EmbedSettingsDraft = {
|
||||
...DEFAULT_EMBED_SETTINGS,
|
||||
position: 'left',
|
||||
size: 'sm',
|
||||
intervalMs: 8000,
|
||||
imageFit: 'cover',
|
||||
scrollSpeed: 24,
|
||||
reactionsEnabled: false
|
||||
}
|
||||
expect(buildEmbedSnippet('https://view.example.com', 'tok', settings)).toBe(
|
||||
'<script src="https://view.example.com/embed/v2/loader.js" data-slot="tok" data-position="left" data-speed="24" data-reactions="off" defer></script>'
|
||||
)
|
||||
})
|
||||
|
||||
it('explicitDataAttrsV2:剥离 v1 布局字段,追加 speed/reactions(仅非默认)', () => {
|
||||
const base = { ...DEFAULT_EMBED_SETTINGS, position: 'left' as const, size: 'sm' as const }
|
||||
expect(explicitDataAttrsV2(base)).toEqual({ 'data-position': 'left' })
|
||||
expect(explicitDataAttrsV2({ ...DEFAULT_EMBED_SETTINGS, scrollSpeed: 32 })).toEqual({
|
||||
'data-speed': '32'
|
||||
})
|
||||
expect(explicitDataAttrsV2({ ...DEFAULT_EMBED_SETTINGS, reactionsEnabled: false })).toEqual({
|
||||
'data-reactions': 'off'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPreviewPlacement 本地预览停靠几何(草稿即时反映,纯本地)', () => {
|
||||
it('desktop:按配置展开(collapsed=collapsedDefault),几何随草稿字段变化', () => {
|
||||
const placement = buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'desktop')
|
||||
|
||||
@@ -35,13 +35,19 @@ export const EMBED_OFFSET_BOTTOM_MAX = 240
|
||||
export const EMBED_Z_INDEX_MIN = 1
|
||||
export const EMBED_Z_INDEX_MAX = 2147483000
|
||||
|
||||
/** 契约 UI-04 v2(vertical-stream)字段边界(对齐 loader params.js v2) */
|
||||
export const EMBED_SCROLL_SPEED_MIN = 8
|
||||
export const EMBED_SCROLL_SPEED_MAX = 32
|
||||
|
||||
/** size → 悬浮组件宽度 px(sm 窄屏降级档;与 loader SIZE_WIDTH 对齐) */
|
||||
export const EMBED_SIZE_WIDTH: Record<Api.EmbedSlot.Size, number> = { sm: 280, md: 320 }
|
||||
|
||||
/** 设置表单草稿(与契约 Settings 同形;创建对话框初始值即契约缺省值) */
|
||||
export type EmbedSettingsDraft = Api.EmbedSlot.Settings
|
||||
|
||||
/** 契约缺省设置(创建对话框初始值;服务端 settings 整体缺省时同值落库) */
|
||||
/** 契约缺省设置(创建对话框初始值;服务端 settings 整体缺省时同值落库——
|
||||
* 唯 UI-04 例外:管理端新建默认 displayMode=vertical-stream(§8.2「默认新建
|
||||
* 选择新版」,经表单显式提交;服务端/契约缺省仍是 carousel,存量记录不迁移)。 */
|
||||
export const DEFAULT_EMBED_SETTINGS: EmbedSettingsDraft = {
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
@@ -51,7 +57,10 @@ export const DEFAULT_EMBED_SETTINGS: EmbedSettingsDraft = {
|
||||
imageFit: 'contain',
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000
|
||||
zIndex: 1000,
|
||||
displayMode: 'vertical-stream',
|
||||
scrollSpeed: 16,
|
||||
reactionsEnabled: true
|
||||
}
|
||||
|
||||
/** 环境嵌入基址(VITE_EMBED_ORIGIN 缺省/空白兜底本机 dev;尾斜杠容忍) */
|
||||
@@ -62,17 +71,21 @@ export function embedOriginFromEnv(env: { VITE_EMBED_ORIGIN?: string }): string
|
||||
|
||||
/** 部署基址 + 展示位令牌 → 嵌入代码(宿主页粘贴即用;token 不可枚举)。
|
||||
* UI-01:可选携带 settings——仅与契约默认不同的字段生成 data-* 覆盖
|
||||
* (缺省字段省略 → 后台设置生效;loader 侧 explicit 表只记真正提供的字段)。 */
|
||||
* (缺省字段省略 → 后台设置生效;loader 侧 explicit 表只记真正提供的字段)。
|
||||
* UI-04:displayMode=vertical-stream → 入口用 /embed/v2/loader.js(data-speed/
|
||||
* data-reactions 覆盖仅 v2 生成);carousel → v1 入口(v2 字段不生成属性)。 */
|
||||
export function buildEmbedSnippet(
|
||||
origin: string,
|
||||
slotToken: string,
|
||||
settings?: Api.EmbedSlot.Settings | null
|
||||
): string {
|
||||
const attrs = explicitDataAttrs(settings)
|
||||
const isV2 = settings?.displayMode === 'vertical-stream'
|
||||
const attrs = isV2 ? explicitDataAttrsV2(settings) : explicitDataAttrs(settings)
|
||||
const attrText = Object.entries(attrs)
|
||||
.map(([name, value]) => ` ${name}="${value}"`)
|
||||
.join('')
|
||||
return `<script src="${origin.replace(/\/+$/, '')}/embed/v1/loader.js" data-slot="${slotToken}"${attrText} defer></script>`
|
||||
const version = isV2 ? 'v2' : 'v1'
|
||||
return `<script src="${origin.replace(/\/+$/, '')}/embed/${version}/loader.js" data-slot="${slotToken}"${attrText} defer></script>`
|
||||
}
|
||||
|
||||
/** settings 字段 → loader data-* 属性名(dataset 驼峰 ↔ HTML 烤串) */
|
||||
@@ -85,7 +98,8 @@ const DATA_ATTR_NAMES: Partial<Record<keyof Api.EmbedSlot.Settings, string>> = {
|
||||
offsetX: 'data-offset-x',
|
||||
offsetBottom: 'data-offset-bottom',
|
||||
zIndex: 'data-z-index'
|
||||
// collapsedDefault 无 data-* 支持(loader 不读取;窄屏强制收起另有规则)
|
||||
// collapsedDefault 无 data-* 支持(loader 不读取;窄屏强制收起另有规则);
|
||||
// displayMode 不生成(版本由入口 URL 决定,§7.2 v2 不可经旧 data-size 复原)。
|
||||
}
|
||||
|
||||
/** settings → 显式覆盖的 data-* 属性表(仅非默认字段;顺序稳定便于断言/快照) */
|
||||
@@ -103,6 +117,25 @@ export function explicitDataAttrs(
|
||||
return attrs
|
||||
}
|
||||
|
||||
/** v2(vertical-stream)data-* 覆盖表:共用字段同上 + speed/reactions;
|
||||
* v1 布局字段(size/interval/imageFit)对 v2 无布局作用,不生成(§7.2)。 */
|
||||
export function explicitDataAttrsV2(
|
||||
settings?: Api.EmbedSlot.Settings | null
|
||||
): Record<string, string> {
|
||||
const attrs = explicitDataAttrs(settings)
|
||||
delete attrs['data-size']
|
||||
delete attrs['data-interval']
|
||||
delete attrs['data-image-fit']
|
||||
if (settings === null || settings === undefined) return attrs
|
||||
if (settings.scrollSpeed !== DEFAULT_EMBED_SETTINGS.scrollSpeed) {
|
||||
attrs['data-speed'] = String(settings.scrollSpeed)
|
||||
}
|
||||
if (settings.reactionsEnabled !== DEFAULT_EMBED_SETTINGS.reactionsEnabled) {
|
||||
attrs['data-reactions'] = settings.reactionsEnabled ? 'on' : 'off'
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
/** intervalMs 夹取到契约区间(非有限值回退缺省 5000;四舍五入整毫秒) */
|
||||
export function clampIntervalMs(value: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.intervalMs
|
||||
@@ -127,6 +160,12 @@ export function clampZIndex(value: number): number {
|
||||
return Math.min(EMBED_Z_INDEX_MAX, Math.max(EMBED_Z_INDEX_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
/** scrollSpeed 夹取到契约区间(UI-04:非有限值回退缺省 16) */
|
||||
export function clampScrollSpeed(value: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.scrollSpeed
|
||||
return Math.min(EMBED_SCROLL_SPEED_MAX, Math.max(EMBED_SCROLL_SPEED_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
/** 表单草稿 → 完整 settings 载荷(整体提交:字段齐全 + 数值字段夹取) */
|
||||
export function buildSettingsPayload(draft: EmbedSettingsDraft): Api.EmbedSlot.Settings {
|
||||
return {
|
||||
@@ -134,7 +173,8 @@ export function buildSettingsPayload(draft: EmbedSettingsDraft): Api.EmbedSlot.S
|
||||
intervalMs: clampIntervalMs(draft.intervalMs),
|
||||
offsetX: clampOffsetX(draft.offsetX),
|
||||
offsetBottom: clampOffsetBottom(draft.offsetBottom),
|
||||
zIndex: clampZIndex(draft.zIndex)
|
||||
zIndex: clampZIndex(draft.zIndex),
|
||||
scrollSpeed: clampScrollSpeed(draft.scrollSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +189,10 @@ function settingsChanged(current: Api.EmbedSlot.Settings, next: EmbedSettingsDra
|
||||
current.imageFit !== next.imageFit ||
|
||||
current.offsetX !== clampOffsetX(next.offsetX) ||
|
||||
current.offsetBottom !== clampOffsetBottom(next.offsetBottom) ||
|
||||
current.zIndex !== clampZIndex(next.zIndex)
|
||||
current.zIndex !== clampZIndex(next.zIndex) ||
|
||||
current.displayMode !== next.displayMode ||
|
||||
current.scrollSpeed !== clampScrollSpeed(next.scrollSpeed) ||
|
||||
current.reactionsEnabled !== next.reactionsEnabled
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import {
|
||||
cleanupSeededAlbums,
|
||||
createEmbedSlot,
|
||||
flushOutbox,
|
||||
flushSync,
|
||||
seedAlbum,
|
||||
seedPhoto,
|
||||
updateEmbedSlot,
|
||||
} from './helpers';
|
||||
|
||||
// UI-04 v2(vertical-stream 照片流窗)跨源 Playwright:宿主 127.0.0.1:3999 ←
|
||||
// viewer 5175 真跨源,loader /embed/v2/loader.js。随 E01-E07 逐任务扩展:
|
||||
// E01 出口门 = 固定 195×280 小窗跨源可见且无 footer(本文件首批用例)。
|
||||
const HOST_PORT = 3999;
|
||||
const VIEWER_ORIGIN = 'http://127.0.0.1:5175';
|
||||
const MINUTE = 60_000;
|
||||
|
||||
let slotToken = '';
|
||||
|
||||
async function seedFeed(alias: string): Promise<string> {
|
||||
const album = await seedAlbum({
|
||||
alias,
|
||||
defaultLocale: 'zh-CN',
|
||||
translations: [
|
||||
{ locale: 'zh-CN', title: `${alias} 中文相册` },
|
||||
{ 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),
|
||||
});
|
||||
}
|
||||
await flushSync(album.id);
|
||||
const slot = await createEmbedSlot(`ui04-v2-${alias}`);
|
||||
// v2 模式显式提交(E01 契约:displayMode vertical-stream + 速度/气泡)
|
||||
await updateEmbedSlot(slot.id, {
|
||||
albumId: album.id,
|
||||
settings: {
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
intervalMs: 5000,
|
||||
lang: 'auto',
|
||||
collapsedDefault: false,
|
||||
imageFit: 'contain',
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000,
|
||||
displayMode: 'vertical-stream',
|
||||
scrollSpeed: 16,
|
||||
reactionsEnabled: true,
|
||||
},
|
||||
});
|
||||
await flushOutbox();
|
||||
return slot.token;
|
||||
}
|
||||
|
||||
let currentHtml = '';
|
||||
const server: Server = createServer((_req, res) => {
|
||||
res.setHeader('content-type', 'text/html; charset=utf-8');
|
||||
res.end(currentHtml);
|
||||
});
|
||||
|
||||
async function openHost(page: Page, viewport: { width: number; height: number }): Promise<void> {
|
||||
await page.setViewportSize(viewport);
|
||||
currentHtml = `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>UI-04 v2 host</title></head>
|
||||
<body style="min-height: 1200px; font-family: system-ui">
|
||||
<h1>Host page (v2 stream)</h1>
|
||||
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slotToken}" defer></script>
|
||||
</body>
|
||||
</html>`;
|
||||
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
slotToken = await seedFeed('ui04v2');
|
||||
await new Promise<void>((resolve) => server.listen(HOST_PORT, '127.0.0.1', resolve));
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanupSeededAlbums();
|
||||
server.close();
|
||||
});
|
||||
|
||||
test.describe.serial('E01 exit gate: fixed window, no footer', () => {
|
||||
for (const [name, viewport] of [
|
||||
['desktop 1440x900', { width: 1440, height: 900 }],
|
||||
['mobile 390x844', { width: 390, height: 844 }],
|
||||
] as const) {
|
||||
test(`v2 window is fixed 195x280 on ${name}`, async ({ page }) => {
|
||||
const mobile = viewport.width < 480;
|
||||
await openHost(page, viewport);
|
||||
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||||
const wrapper = frame.locator('..');
|
||||
// 手机默认收起(沿用 v1 策略):点击把手展开后测量。
|
||||
if (mobile) {
|
||||
await page.locator('[role="button"]').first().click();
|
||||
}
|
||||
await expect(frame).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);
|
||||
// iframe 内:无 header/footer(E01 静态壳);右上收起按钮存在。
|
||||
const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]');
|
||||
await expect(inside.getByRole('button', { name: /收起照片窗|Collapse photo window/ })).toBeVisible();
|
||||
// v1 结构不出现(判别渲染 DOM 而非原始 HTML——dev 模式内联 CSS 含全部
|
||||
// scoped 类名,字符串断言会误报;「活动照片」字样亦存在于照片 alt 文案)。
|
||||
await expect(inside.locator('.embed-head')).toHaveCount(0);
|
||||
await expect(inside.locator('.embed-footer')).toHaveCount(0);
|
||||
await expect(inside.getByText('查看相册')).toHaveCount(0);
|
||||
await expect(inside.getByText('照片轮播')).toHaveCount(0);
|
||||
await expect(inside.locator('.stream-viewport')).toHaveCount(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -54,6 +54,10 @@ function makeSettings(overrides: Partial<EmbedSlotSettings> = {}): EmbedSlotSett
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000,
|
||||
// UI-04 v2 字段(响应恒为归一化全集;旧记录读取归一为 carousel)。
|
||||
displayMode: 'carousel',
|
||||
scrollSpeed: 16,
|
||||
reactionsEnabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// apps/viewer/src/embed/stream.ts —— v2 垂直照片流纯函数集(UI-04 §2.3/§3.2)。
|
||||
// 高度比例防御、环形队列窗口数学、锚定计算全部收敛为纯函数(vitest 直接覆盖);
|
||||
// 组件/编排层(EmbedStream.vue / useVerticalPhotoStream.ts)只做装配。
|
||||
|
||||
/** 元数据异常时的高度比例回退(4:3,§2.3)。 */
|
||||
export const FALLBACK_RATIO = 4 / 3;
|
||||
|
||||
/** 超长图安全钳(高度/宽度上限;防无穷高度破坏循环,§2.3)。 */
|
||||
export const MAX_RATIO = 8;
|
||||
|
||||
/** 照片缝宽度(px,§2.2 硬性 3px)。 */
|
||||
export const PHOTO_GAP_PX = 3;
|
||||
|
||||
export interface StreamPhotoSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** 元数据 → 高度比例(height/width);宽高须 >0 且有限,否则回退 4:3。 */
|
||||
export function photoRatio(size: StreamPhotoSize): number {
|
||||
if (
|
||||
Number.isFinite(size.width) &&
|
||||
Number.isFinite(size.height) &&
|
||||
size.width > 0 &&
|
||||
size.height > 0
|
||||
) {
|
||||
return Math.min(size.height / size.width, MAX_RATIO);
|
||||
}
|
||||
return FALLBACK_RATIO;
|
||||
}
|
||||
|
||||
/** 容器内宽 → 照片展示高度(按比例;内宽 = 窗宽 - 边框)。 */
|
||||
export function photoDisplayHeight(size: StreamPhotoSize, innerWidth: number): number {
|
||||
return photoRatio(size) * innerWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 环形队列窗口(§3.2 步骤 1):给定队列、窗口高度与预载余量,计算从
|
||||
* startIndex 起需要装载的条目数(高度和 ≥ 窗口高 + 上下余量)。返回条目
|
||||
* 数量(至少 1,队列空返回 0)。纯计算——实际 DOM 复用由编排层负责。
|
||||
*/
|
||||
export function windowCount(
|
||||
sizes: StreamPhotoSize[],
|
||||
startIndex: number,
|
||||
innerWidth: number,
|
||||
viewportHeight: number,
|
||||
bufferPx: number,
|
||||
): number {
|
||||
if (sizes.length === 0) return 0;
|
||||
const target = viewportHeight + bufferPx;
|
||||
let acc = 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < sizes.length; i++) {
|
||||
const item = sizes[(startIndex + i) % sizes.length];
|
||||
acc += photoDisplayHeight(item, innerWidth) + PHOTO_GAP_PX;
|
||||
count += 1;
|
||||
if (acc >= target) return count;
|
||||
}
|
||||
// 一轮高度不足以覆盖窗口(极矮照片):返回整队(§3.2.7 少量可见循环
|
||||
// 副本由编排层处理,本函数不放大队列)。
|
||||
return count;
|
||||
}
|
||||
@@ -33,6 +33,9 @@ export default {
|
||||
play: 'Play carousel',
|
||||
prev: 'Previous photo',
|
||||
next: 'Next photo',
|
||||
// UI-04 v2 vertical photo stream window
|
||||
streamLabel: 'Event photo window',
|
||||
collapseWindow: 'Collapse photo window',
|
||||
},
|
||||
download: {
|
||||
action: 'Download photo',
|
||||
|
||||
@@ -34,6 +34,9 @@ export default {
|
||||
play: '播放轮播',
|
||||
prev: '上一张',
|
||||
next: '下一张',
|
||||
// —— UI-04 v2 垂直照片流窗 ——
|
||||
streamLabel: '活动照片窗',
|
||||
collapseWindow: '收起照片窗',
|
||||
},
|
||||
download: {
|
||||
action: '下载图片',
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
// apps/viewer/src/pages/EmbedPage.vue —— 嵌入轮播 iframe 页薄壳(路由 /embed/:slotToken
|
||||
// + 数据/lifecycle 编排;P4b Task 4 创建,UI-01 Task 6 拆分)。独立精简布局:
|
||||
// 无顶栏/页脚,App.vue 按 meta.layout 裸渲染。宿主 loader 以 iframe 载入本页
|
||||
// (280-320px 小视口设计):
|
||||
// · 数据/轮询/暂停/收起/destroy 编排见 ../embed/useEmbedFeed.ts;
|
||||
// · 切换逻辑/舞台/底栏见 ./embed/(EmbedCarousel → EmbedStage + EmbedFooterBar);
|
||||
// · 头部:通用「活动照片」标题 + 收起 ×(全状态可用,含空态——§5.3 断网
|
||||
// 无缓存可收起;有效点击区 ≥44×44);
|
||||
// · 空相册 → 「照片即将上线」小卡;槽 404 → destroy 后无 UI(feed.status gone);
|
||||
// apps/viewer/src/pages/EmbedPage.vue —— 嵌入 iframe 页薄壳(路由 /embed/:slotToken
|
||||
// + 数据/lifecycle 编排;P4b Task 4 创建,UI-01 Task 6 拆分,UI-04 增 v2 分支)。
|
||||
// 独立精简布局:无顶栏/页脚,App.vue 按 meta.layout 裸渲染。宿主 loader 以 iframe
|
||||
// 载入本页:
|
||||
// · 数据/轮询/暂停/收起/destroy 编排见 ../embed/useEmbedFeed.ts(v1/v2 共用);
|
||||
// · v=2(UI-04):./embed/EmbedStream.vue 垂直照片流窗(无 header/footer,
|
||||
// 右上收起,195×280 外窗由 loader v2 负责);speed/reactions 查询为宿主
|
||||
// data-* 显式覆盖,后台 settings.scrollSpeed/reactionsEnabled 次之;
|
||||
// · v1(缺省):./embed/(EmbedCarousel → EmbedStage + EmbedFooterBar)+
|
||||
// 通用「活动照片」标题头 + 收起 ×;
|
||||
// · 空相册 → 「照片即将上线」;槽 404 → destroy 后无 UI(feed.status gone);
|
||||
// · 语言独立于宿主与 localStorage:lang 查询(auto→相册 defaultLocale);
|
||||
// · interval/fit 查询为宿主 data-* 显式覆盖(loader 仅显式透传);后台设置
|
||||
// 经响应 settings 生效(显式 > 后台 > 默认,§5.4)。
|
||||
@@ -17,6 +19,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useEmbedFeed } from '../embed/useEmbedFeed';
|
||||
import { clampEmbedIntervalMs, EMBED_CAROUSEL_DEFAULT_MS } from '../embed/feed';
|
||||
import EmbedCarousel from './embed/EmbedCarousel.vue';
|
||||
import EmbedStream from './embed/EmbedStream.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t, locale } = useI18n();
|
||||
@@ -31,6 +34,11 @@ const langParam = queryText(route.query.lang) ?? 'auto';
|
||||
const intervalText = queryText(route.query.interval);
|
||||
const intervalParamMs = intervalText === null ? null : Number.parseInt(intervalText, 10);
|
||||
const fitParam = queryText(route.query.fit);
|
||||
// UI-04 v2:loader v2 显式透传的速度/气泡开关(缺省不拼——后台设置生效)。
|
||||
const speedText = queryText(route.query.speed);
|
||||
const speedParam = speedText === null ? null : Number.parseInt(speedText, 10);
|
||||
const reactionsOff = queryText(route.query.reactions) === 'off';
|
||||
const isV2 = queryText(route.query.v) === '2';
|
||||
// dev/e2e 轮询加速(跨源宿主可用;生产构建忽略——loader 侧同样仅显式透传)。
|
||||
const pollText = queryText(route.query.poll);
|
||||
const pollParamMs =
|
||||
@@ -85,6 +93,22 @@ const imageFit = computed<'contain' | 'cover'>(() => {
|
||||
return feed.settings?.imageFit ?? 'contain';
|
||||
});
|
||||
|
||||
// UI-04 v2 上滚速度(CSS px/s):查询显式(夹取 8..32)→ 后台设置 → 缺省 16。
|
||||
const streamSpeed = computed(() => {
|
||||
if (speedParam !== null && Number.isFinite(speedParam)) {
|
||||
return Math.min(32, Math.max(8, Math.round(speedParam)));
|
||||
}
|
||||
const s = feed.settings?.scrollSpeed;
|
||||
return typeof s === 'number' && Number.isFinite(s) ? Math.min(32, Math.max(8, Math.round(s))) : 16;
|
||||
});
|
||||
|
||||
// UI-04 v2 气泡开关:查询显式 off → false;否则后台设置 → 缺省 true。
|
||||
const streamReactions = computed(() => {
|
||||
if (reactionsOff) return false;
|
||||
const s = feed.settings?.reactionsEnabled;
|
||||
return typeof s === 'boolean' ? s : true;
|
||||
});
|
||||
|
||||
const albumUrl = computed(() => {
|
||||
const albumToken = feed.album?.publicToken;
|
||||
if (albumToken === undefined) return null;
|
||||
@@ -93,6 +117,15 @@ const albumUrl = computed(() => {
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
/** v2 照片深链构造器(真实 <a> href;受控打开同 openPhoto)。 */
|
||||
function streamPhotoUrl(photoId: string): string {
|
||||
const albumToken = feed.album?.publicToken;
|
||||
const base = albumToken ?? '';
|
||||
const url = new URL(`/a/${encodeURIComponent(base)}/p/${encodeURIComponent(photoId)}`, window.location.origin);
|
||||
url.searchParams.set('lang', feed.resolvedLocale ?? 'zh-CN');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function openPhoto(photoId: string): void {
|
||||
const albumToken = feed.album?.publicToken;
|
||||
if (albumToken === undefined) return;
|
||||
@@ -104,7 +137,21 @@ function openPhoto(photoId: string): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="feed.status !== 'gone'" ref="rootEl" class="embed-root" :class="{ 'is-empty': feed.status !== 'ready' }">
|
||||
<!-- v2(UI-04):无 header/footer 的垂直照片流窗(外壳尺寸/圆角/边框由宿主
|
||||
loader v2 wrapper 负责;本页铺满窗口)。 -->
|
||||
<div v-if="isV2 && feed.status !== 'gone'" ref="rootEl" class="embed-root-v2">
|
||||
<EmbedStream
|
||||
:photos="feed.status === 'ready' ? feed.photos : []"
|
||||
:photo-url="streamPhotoUrl"
|
||||
:speed="streamSpeed"
|
||||
:reactions="streamReactions"
|
||||
:active="feed.active"
|
||||
:reduced-motion="reducedMotion"
|
||||
@open-photo="openPhoto"
|
||||
@collapse="feed.requestCollapse()"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="feed.status !== 'gone'" ref="rootEl" class="embed-root" :class="{ 'is-empty': feed.status !== 'ready' }">
|
||||
<div class="embed-head">
|
||||
<span class="embed-heading">{{ t('embed.widgetTitle') }}</span>
|
||||
<button type="button" class="embed-close" :aria-label="t('embed.close')" @click="feed.requestCollapse()">
|
||||
@@ -132,6 +179,12 @@ function openPhoto(photoId: string): void {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* v2 根:铺满宿主 195×280 外窗。iframe 内 html/body 无高度链(v1 为内容高
|
||||
* 设计),fixed+inset0 直接锚定 iframe 视口——不引入影响 v1 的全局样式。 */
|
||||
.embed-root-v2 {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
/* iframe 内容适配宿主卡片:html/body 无背景(App.vue 基线未设背景,天然透亮,
|
||||
* 圆角/阴影由宿主 loader wrapper 提供),根元素白底小卡 280-320px 设计。 */
|
||||
.embed-root {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<!-- apps/viewer/src/pages/embed/EmbedStream.vue —— UI-04 v2 垂直照片流窗
|
||||
(EmbedPage 于 v=2 查询时渲染)。结构对齐方案 §2.2:
|
||||
PhotoStreamViewport(relative/overflow hidden/手动模式滚动)
|
||||
Track(垂直照片实例带,translateY 驱动——E03 接线)
|
||||
PhotoLink > img(原比例、宽满、block;真实 <a> 指向观众深链 §4.1)
|
||||
HeartParticleLayer(E05:真实 likes.burst 粒子,pointer-events none)
|
||||
CollapseButton(右上收起,§2.4 规格)
|
||||
StateOverlay(loading/empty/error 按需)
|
||||
无 header/footer/页码/箭头/圆点(§2.2 硬性)。图片高度按元数据比例预留
|
||||
(异常宽高回退 4:3 占位,§2.3);间距 3px;外窗(loader wrapper)负责整体
|
||||
圆角与边框,内部不再加圆角。 -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { PublicEmbedPhoto } from '../../api/client';
|
||||
import { photoRatio } from '../../embed/stream';
|
||||
|
||||
const props = defineProps<{
|
||||
photos: PublicEmbedPhoto[];
|
||||
/** 观众深链构造器(真实 <a> href;点击经锁定目标受控打开,§4.1)。 */
|
||||
photoUrl: (photoId: string) => string;
|
||||
/** 自动上滚速度(CSS px/s;reducedMotion/暂停时不动,E03 接线 rAF)。 */
|
||||
speed: number;
|
||||
/** 点赞气泡开关(E05 接线;false 时不建粒子层)。 */
|
||||
reactions: boolean;
|
||||
/** 组件活动闸门(收起/隐藏/不可见/gone → false,停滚动与轮询)。 */
|
||||
active: boolean;
|
||||
reducedMotion: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'open-photo', photoId: string): void;
|
||||
(e: 'collapse'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const ratios = computed(() => props.photos.map((p) => photoRatio(p)));
|
||||
|
||||
// ── 点击语义(§4.1;拖动取消/目标锁定在 E04 随滚动接线补全)──
|
||||
function onPhotoClick(photo: PublicEmbedPhoto): void {
|
||||
if (!props.active) return;
|
||||
emit('open-photo', photo.photoId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stream-viewport" role="region" :aria-label="t('embed.streamLabel')">
|
||||
<div class="stream-track">
|
||||
<a
|
||||
v-for="(photo, i) in photos"
|
||||
:key="photo.photoId"
|
||||
class="stream-photo"
|
||||
:href="photoUrl(photo.photoId)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="t('embed.photoAlt', { n: i + 1 })"
|
||||
@click.prevent="onPhotoClick(photo)"
|
||||
>
|
||||
<img
|
||||
class="stream-photo__img"
|
||||
:src="photo.thumb.url"
|
||||
:alt="t('embed.photoAlt', { n: i + 1 })"
|
||||
:style="{ aspectRatio: `1 / ${ratios[i]}` }"
|
||||
decoding="async"
|
||||
loading="eager"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
<!-- StateOverlay(§2.2):空相册紧凑空态(收起入口保持可用)。 -->
|
||||
<div v-if="photos.length === 0" class="stream-empty" role="note">
|
||||
{{ t('embed.comingSoon') }}
|
||||
</div>
|
||||
<!-- E05:HeartParticleLayer(reactions=true 时挂载) -->
|
||||
<button
|
||||
type="button"
|
||||
class="stream-collapse"
|
||||
:aria-label="t('embed.collapseWindow')"
|
||||
@click="emit('collapse')"
|
||||
>
|
||||
<svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M2 5.25h8v1.5H2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* iframe 内容铺满 loader wrapper 的 195×280 窗(圆角/边框/阴影由宿主负责);
|
||||
* 深底色与宿主外壳一致,加载/缺口不露宿主内容(§2.1)。 */
|
||||
.stream-viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #10161e;
|
||||
}
|
||||
.stream-track {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 3px 照片缝(§2.2) */
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
.stream-photo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.stream-photo__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: #f6f8fb;
|
||||
object-fit: cover;
|
||||
}
|
||||
/* 收起入口(§2.4):可视圆 24×24 右上内缩 6px;有效触区 44×44 锚定右上
|
||||
* (透明扩展),不与照片链接重叠触发。 */
|
||||
.stream-collapse {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.stream-collapse::before {
|
||||
content: '';
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 6px 6px 0 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
.stream-collapse svg {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
color: #647084;
|
||||
}
|
||||
.stream-collapse:focus-visible {
|
||||
outline: 2px solid #ffffff;
|
||||
outline-offset: -2px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.stream-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font: 500 0.75rem/1.4 system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user