feat(admin): embed slot management with code snippet
P4b Task 5:/system/embed-slots 管理页(Task 8/9 页面模式:useCursorList
游标累积 + 对话框治理)。列表(名称/绑定相册/启停/configVersion/更新时间,
启停开关行内 PATCH → configVersion+1 toast 提示)+ 新建/编辑对话框
(设置表单位置/尺寸/间隔/语言/默认收起,intervalMs 契约区间夹取;编辑按
整体替换语义提交完整 settings)+ 绑定相册对话框(相册标题/ID 搜索选中 →
确认框携带 A22 文案「configVersion 将递增,官网无需改动」;清空即解绑)+
嵌入代码卡({EMBED_ORIGIN}/embed/v1/loader.js + data-slot 令牌,一键复制;
EMBED_ORIGIN=VITE_EMBED_ORIGIN 缺省本机 viewer dev,.env.example 登记)。
- API 层 src/api/embedSlots.ts + types/api/embedSlot.d.ts(契约镜像)
- vitest 9 例:表单→载荷(缺省+夹取)、编辑载荷收敛、snippet 拼接、
环境基址兜底、绑定变更分类、zh/en 键一致 + A22 短语断言
- Playwright e2e(真实 cn+ov 后端):新建→绑定(gov-photos 直种 +
seed-fixture 新增 sync-flush 模式经 bootstrap/真实发送管线投影 photos_ov)
→嵌入代码卡含 token→ov 公开 feed 返回相册照片→换绑 configVersion+1 +
feed 切换(A22)→停用 feed 404;admin playwright 配置补 ov webServer
- i18n zh/en(embedSlotsManage.* + menus.system.embedSlots)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import zhMessages from '@/locales/langs/zh.json'
|
||||
import enMessages from '@/locales/langs/en.json'
|
||||
import {
|
||||
bindChangeKind,
|
||||
buildEmbedSnippet,
|
||||
buildSettingsPayload,
|
||||
buildSlotEditPayload,
|
||||
clampIntervalMs,
|
||||
DEFAULT_EMBED_SETTINGS,
|
||||
EMBED_ORIGIN_FALLBACK,
|
||||
embedOriginFromEnv,
|
||||
type EmbedSettingsDraft
|
||||
} from './embedSlots'
|
||||
|
||||
/** 契约形状的既有条目(编辑载荷断言基准) */
|
||||
const CURRENT_ITEM: Api.EmbedSlot.Item = {
|
||||
id: 'slot-1',
|
||||
publicToken: 'tok0000000000000000000',
|
||||
name: '官网首页悬浮位',
|
||||
enabled: true,
|
||||
albumId: null,
|
||||
albumTitle: null,
|
||||
configVersion: 1,
|
||||
settings: { ...DEFAULT_EMBED_SETTINGS },
|
||||
updatedAt: '2026-09-20T10:00:00.000Z'
|
||||
}
|
||||
|
||||
describe('buildSettingsPayload 表单 → settings 载荷', () => {
|
||||
it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全)', () => {
|
||||
expect(buildSettingsPayload({ ...DEFAULT_EMBED_SETTINGS })).toEqual({
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
intervalMs: 5000,
|
||||
lang: 'auto',
|
||||
collapsedDefault: false
|
||||
})
|
||||
})
|
||||
|
||||
it('intervalMs 越界/非有限值 → 夹取到契约区间(键盘输入兜底)', () => {
|
||||
expect(clampIntervalMs(1)).toBe(3000)
|
||||
expect(clampIntervalMs(2999.6)).toBe(3000)
|
||||
expect(clampIntervalMs(100000)).toBe(30000)
|
||||
expect(clampIntervalMs(Number.NaN)).toBe(5000)
|
||||
expect(clampIntervalMs(Number.POSITIVE_INFINITY)).toBe(5000)
|
||||
const draft: EmbedSettingsDraft = { ...DEFAULT_EMBED_SETTINGS, intervalMs: 2000 }
|
||||
expect(buildSettingsPayload(draft).intervalMs).toBe(3000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSlotEditPayload 编辑载荷收敛', () => {
|
||||
it('name 与 settings 均未变化 → 空载荷(不制造服务端 no-op)', () => {
|
||||
expect(
|
||||
buildSlotEditPayload(CURRENT_ITEM, CURRENT_ITEM.name, { ...DEFAULT_EMBED_SETTINGS })
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('仅 name 变化 → 只送 name;settings 变化 → 送完整 settings(整体替换)', () => {
|
||||
expect(
|
||||
buildSlotEditPayload(CURRENT_ITEM, ' 活动页左侧位 ', { ...DEFAULT_EMBED_SETTINGS })
|
||||
).toEqual({
|
||||
name: '活动页左侧位'
|
||||
})
|
||||
const changed: EmbedSettingsDraft = {
|
||||
...DEFAULT_EMBED_SETTINGS,
|
||||
position: 'left',
|
||||
size: 'sm',
|
||||
intervalMs: 8000,
|
||||
lang: 'zh-CN',
|
||||
collapsedDefault: true
|
||||
}
|
||||
expect(buildSlotEditPayload(CURRENT_ITEM, CURRENT_ITEM.name, changed)).toEqual({
|
||||
settings: changed
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildEmbedSnippet 嵌入代码拼接', () => {
|
||||
it('EMBED_ORIGIN + token → /embed/v1/loader.js 引用(尾斜杠容忍)', () => {
|
||||
expect(buildEmbedSnippet('https://view.example.com', 'abc123def456ghi789jklmno')).toBe(
|
||||
'<script src="https://view.example.com/embed/v1/loader.js" data-slot="abc123def456ghi789jklmno" defer></script>'
|
||||
)
|
||||
expect(buildEmbedSnippet('http://127.0.0.1:5175//', 'tok')).toBe(
|
||||
'<script src="http://127.0.0.1:5175/embed/v1/loader.js" data-slot="tok" defer></script>'
|
||||
)
|
||||
})
|
||||
|
||||
it('环境基址:VITE_EMBED_ORIGIN 缺省/空白兜底本机 dev', () => {
|
||||
expect(embedOriginFromEnv({})).toBe(EMBED_ORIGIN_FALLBACK)
|
||||
expect(embedOriginFromEnv({ VITE_EMBED_ORIGIN: ' ' })).toBe(EMBED_ORIGIN_FALLBACK)
|
||||
expect(embedOriginFromEnv({ VITE_EMBED_ORIGIN: ' https://cdn.example.com/ ' })).toBe(
|
||||
'https://cdn.example.com'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bindChangeKind 绑定变更分类', () => {
|
||||
it('null→id 绑定 / id→id 换绑 / id→null 解绑 / 值不变 none', () => {
|
||||
expect(bindChangeKind(null, 'album-1')).toBe('bind')
|
||||
expect(bindChangeKind('album-1', 'album-2')).toBe('rebind')
|
||||
expect(bindChangeKind('album-1', null)).toBe('unbind')
|
||||
expect(bindChangeKind('album-1', 'album-1')).toBe('none')
|
||||
expect(bindChangeKind(null, null)).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('绑定变更确认文案(A22:configVersion 将递增,官网无需改动)', () => {
|
||||
const zh = zhMessages.embedSlotsManage as Record<string, unknown>
|
||||
const en = enMessages.embedSlotsManage as Record<string, unknown>
|
||||
const zhBind = zh.bind as Record<string, string>
|
||||
const enBind = en.bind as Record<string, string>
|
||||
|
||||
it('zh/en 键集合一致(embedSlotsManage 子树)', () => {
|
||||
const keysOf = (obj: Record<string, unknown>, prefix = ''): string[] =>
|
||||
Object.entries(obj).flatMap(([key, value]) =>
|
||||
typeof value === 'object' && value !== null
|
||||
? keysOf(value as Record<string, unknown>, `${prefix}${key}.`)
|
||||
: [`${prefix}${key}`]
|
||||
)
|
||||
expect(keysOf(en)).toEqual(keysOf(zh))
|
||||
})
|
||||
|
||||
it('三类确认文案均携带 A22 短语(换绑不改官网:loader 只认 token)', () => {
|
||||
for (const key of ['confirmBind', 'confirmRebind', 'confirmUnbind']) {
|
||||
expect(zhBind[key]).toContain('configVersion 将递增')
|
||||
expect(zhBind[key]).toContain('官网无需改动')
|
||||
expect(enBind[key]).toContain('configVersion will increase')
|
||||
expect(enBind[key]).toContain('no change is needed on the host site')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 展示位管理页纯逻辑(无组件依赖,vitest 直接覆盖;P4b Task 5)
|
||||
*
|
||||
* - 嵌入代码:`<script src="{EMBED_ORIGIN}/embed/v1/loader.js"
|
||||
* data-slot="{publicToken}" defer>`(loader 由 viewer 静态服务;EMBED_ORIGIN
|
||||
* 即 viewer 部署基址,经 VITE_EMBED_ORIGIN 配置,缺省兜底本机 dev 5175)
|
||||
* - settings 表单 → 载荷:整体提交语义(提供时字段齐全),intervalMs 在
|
||||
* 契约区间 3000..30000 内夹取(ElInputNumber min/max 之外的键盘输入兜底)
|
||||
* - 编辑载荷收敛:只提交变更字段(name/settings 任一变化才送;settings
|
||||
* 一旦送即为完整对象——契约整体替换语义);绑定变更(albumId)走独立
|
||||
* 绑定对话框,不经编辑载荷
|
||||
* - 绑定变更分类:bind(未绑定→绑定)/ rebind(换绑)/ unbind(解绑)/
|
||||
* none(未变化不提交,不制造服务端 no-op)
|
||||
*/
|
||||
|
||||
/** 嵌入组件部署基址(缺省兜底本机 viewer dev;loader 版本路径 /embed/v1/) */
|
||||
export const EMBED_ORIGIN_FALLBACK = 'http://127.0.0.1:5175'
|
||||
|
||||
/** 契约 EmbedSlotSettings.intervalMs 边界 */
|
||||
export const EMBED_INTERVAL_MIN_MS = 3000
|
||||
export const EMBED_INTERVAL_MAX_MS = 30000
|
||||
|
||||
/** 设置表单草稿(与契约 Settings 同形;创建对话框初始值即契约缺省值) */
|
||||
export type EmbedSettingsDraft = Api.EmbedSlot.Settings
|
||||
|
||||
/** 契约缺省设置(创建对话框初始值;服务端 settings 整体缺省时同值落库) */
|
||||
export const DEFAULT_EMBED_SETTINGS: EmbedSettingsDraft = {
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
intervalMs: 5000,
|
||||
lang: 'auto',
|
||||
collapsedDefault: false
|
||||
}
|
||||
|
||||
/** 环境嵌入基址(VITE_EMBED_ORIGIN 缺省/空白兜底本机 dev;尾斜杠容忍) */
|
||||
export function embedOriginFromEnv(env: { VITE_EMBED_ORIGIN?: string }): string {
|
||||
const origin = env.VITE_EMBED_ORIGIN?.trim()
|
||||
return origin && origin !== '' ? origin.replace(/\/+$/, '') : EMBED_ORIGIN_FALLBACK
|
||||
}
|
||||
|
||||
/** 部署基址 + 展示位令牌 → 嵌入代码(宿主页粘贴即用;token 不可枚举) */
|
||||
export function buildEmbedSnippet(origin: string, slotToken: string): string {
|
||||
return `<script src="${origin.replace(/\/+$/, '')}/embed/v1/loader.js" data-slot="${slotToken}" defer></script>`
|
||||
}
|
||||
|
||||
/** intervalMs 夹取到契约区间(非有限值回退缺省 5000;四舍五入整毫秒) */
|
||||
export function clampIntervalMs(value: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.intervalMs
|
||||
return Math.min(EMBED_INTERVAL_MAX_MS, Math.max(EMBED_INTERVAL_MIN_MS, Math.round(value)))
|
||||
}
|
||||
|
||||
/** 表单草稿 → 完整 settings 载荷(整体提交:字段齐全 + intervalMs 夹取) */
|
||||
export function buildSettingsPayload(draft: EmbedSettingsDraft): Api.EmbedSlot.Settings {
|
||||
return { ...draft, intervalMs: clampIntervalMs(draft.intervalMs) }
|
||||
}
|
||||
|
||||
/** settings 是否有任一字段变化(编辑载荷只送变更面) */
|
||||
function settingsChanged(current: Api.EmbedSlot.Settings, next: EmbedSettingsDraft): boolean {
|
||||
return (
|
||||
current.position !== next.position ||
|
||||
current.size !== next.size ||
|
||||
current.intervalMs !== clampIntervalMs(next.intervalMs) ||
|
||||
current.lang !== next.lang ||
|
||||
current.collapsedDefault !== next.collapsedDefault
|
||||
)
|
||||
}
|
||||
|
||||
/** 编辑提交载荷:name/settings 只送变更字段;settings 送则整体替换 */
|
||||
export function buildSlotEditPayload(
|
||||
current: Api.EmbedSlot.Item,
|
||||
name: string,
|
||||
draft: EmbedSettingsDraft
|
||||
): Api.EmbedSlot.UpdatePayload {
|
||||
const payload: Api.EmbedSlot.UpdatePayload = {}
|
||||
const trimmed = name.trim()
|
||||
if (trimmed !== '' && trimmed !== current.name) payload.name = trimmed
|
||||
if (settingsChanged(current.settings, draft)) {
|
||||
payload.settings = buildSettingsPayload(draft)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
/** 绑定变更分类(确认文案与提交动作共用;none=不提交) */
|
||||
export type BindChangeKind = 'bind' | 'rebind' | 'unbind' | 'none'
|
||||
|
||||
/** 当前绑定 → 下一步绑定 的变更分类(值不变 none,不制造服务端 no-op) */
|
||||
export function bindChangeKind(
|
||||
currentAlbumId: string | null,
|
||||
nextAlbumId: string | null
|
||||
): BindChangeKind {
|
||||
if (nextAlbumId === currentAlbumId) return 'none'
|
||||
if (nextAlbumId === null) return 'unbind'
|
||||
return currentAlbumId === null ? 'bind' : 'rebind'
|
||||
}
|
||||
Reference in New Issue
Block a user