feat(admin): UI-01 embed slot config form and live preview

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 19:26:48 +08:00
parent ddf26a177f
commit ac23a4c40e
9 changed files with 1362 additions and 96 deletions
+233
View File
@@ -0,0 +1,233 @@
// 展示位配置表单 + 本地实时预览 e2e(UI-01 Task 7,真实后端:cn 8080 +
// admin dev 5176 由 playwright.config webServer 拉起或复用)。旅程:新建表单
// (UI-01 新字段 imageFit/offsetX/offsetBottom/zIndex)→ 预览即时随动 +
// 「未保存」脏态标记 → 保存 → GET /v1/admin/embed-slots 断言新字段持久化 →
// 嵌入代码卡只含显式非默认 data-* 覆盖(全默认 slot = 裸 loader 引用)→
// 表单交互窗口零 /v1/ 请求(预览纯本地,不写展示位不上报浏览)。
// 选择器一律 data-test;文案断言仅用稳定短语。
import { expect, test, type Page, type Request } from '@playwright/test'
import { adminCredentials, loginAsAdmin } from './helpers'
test.use({ testIdAttribute: 'data-test' })
const SUFFIX = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
const SLOT_NAME = `E2E配置展示位 ${SUFFIX}`
const DEFAULT_SLOT_NAME = `E2E默认展示位 ${SUFFIX}`
/** 列表内定位展示位行 */
function slotRow(page: Page, name: string) {
return page.getByTestId('embed-slots-table').locator('tr', { hasText: name })
}
/** 管理端会话令牌(GET 断言持久化用) */
async function sessionToken(page: Page): Promise<string> {
const { account, password } = adminCredentials()
const login = await page.request.post('/v1/auth/password', { data: { account, password } })
expect(login.ok()).toBeTruthy()
return ((await login.json()) as { sessionToken: string }).sessionToken
}
/** 管理端列表按名称取条目(createdAt 倒序首页即可命中新建条目) */
async function findSlotByName(page: Page, name: string): Promise<ApiShapeSlotItem> {
const token = await sessionToken(page)
const res = await page.request.get('/v1/admin/embed-slots', {
params: { limit: 20 },
headers: { Authorization: `Bearer ${token}` }
})
expect(res.ok()).toBeTruthy()
const body = (await res.json()) as { items: ApiShapeSlotItem[] }
const item = body.items.find((slot) => slot.name === name)
expect(item).toBeTruthy()
return item as ApiShapeSlotItem
}
/** 断言关心的响应子集(settings 恒为归一化全集) */
interface ApiShapeSlotItem {
id: string
name: string
settings: {
position: string
size: string
intervalMs: number
lang: string
collapsedDefault: boolean
imageFit: string
offsetX: number
offsetBottom: number
zIndex: number
}
}
test.describe.serial('embed slot config form + live preview (UI-01 Task 7)', () => {
test('新字段表单:预览即时随动 + 脏态标记 → 保存 → GET 返回新值', async ({ page }) => {
await loginAsAdmin(page)
await page.goto('/#/system/embed-slots')
await expect(page.getByTestId('embed-slots-table')).toBeVisible()
await page.getByTestId('slot-create-btn').click()
await expect(page.getByTestId('slot-form-dialog')).toBeVisible()
// 打开即见双框预览;初始缺省不脏(新建空名 + 契约缺省设置)
const panel = page.getByTestId('embed-preview-panel')
await expect(panel).toBeVisible()
const desktop = page.getByTestId('embed-preview-desktop')
const mobile = page.getByTestId('embed-preview-mobile')
await expect(desktop).toBeVisible()
await expect(mobile).toBeVisible()
await expect(page.getByTestId('slot-form-dirty')).toBeHidden()
// 默认桌面展开(4:3 舞台 + 底栏)、手机窄屏收起为把手(§5.4)
await expect(desktop.getByTestId('embed-preview-stage')).toBeVisible()
await expect(desktop.getByTestId('embed-preview-indicator')).toHaveText('01 / 03')
await expect(mobile.getByTestId('embed-preview-handle')).toBeVisible()
await expect(mobile.getByTestId('embed-preview-stage')).toBeHidden()
// 输入名称 → 脏态标记出现
await page.getByTestId('slot-form-name').fill(SLOT_NAME)
await expect(page.getByTestId('slot-form-dirty')).toBeVisible()
// UI-01 新字段:图片适配 cover → 桌面预览舞台即时切 cover(未保存已随动)
await page.getByTestId('slot-form-image-fit-cover').click()
await expect(desktop.getByTestId('embed-preview-stage')).toHaveClass(/is-cover/)
// 位置 left + 水平偏移 40 → 停靠几何即时反映(默认 offset 16 → 40)
await page.getByTestId('slot-form-position').click()
await page.getByTestId('slot-form-position-left').click()
await expect(desktop.getByTestId('embed-preview-widget')).toHaveCSS('left', '16px')
await page.getByTestId('slot-form-offset-x').locator('input').fill('40')
await page.keyboard.press('Tab')
await expect(desktop.getByTestId('embed-preview-widget')).toHaveCSS('left', '40px')
// 底部偏移 80 → 距底即时反映
await page.getByTestId('slot-form-offset-bottom').locator('input').fill('80')
await page.keyboard.press('Tab')
await expect(desktop.getByTestId('embed-preview-widget')).toHaveCSS('bottom', '80px')
// 层级 2000 → z-index 徽标即时反映
await page.getByTestId('slot-form-z-index').locator('input').fill('2000')
await page.keyboard.press('Tab')
await expect(desktop.getByTestId('embed-preview-zindex')).toHaveText('z-index 2000')
// 尺寸 sm(280)+ 间隔 8000
await page.getByTestId('slot-form-size').click()
await page.getByTestId('slot-form-size-sm').click()
await expect(desktop.getByTestId('embed-preview-widget')).toHaveCSS('width', '280px')
await page.getByTestId('slot-form-interval').locator('input').fill('8000')
await page.keyboard.press('Tab')
// 保存 → 对话框关闭;GET 管理端断言新字段持久化(settings 归一化全集)
await page.getByTestId('slot-form-submit').click()
await expect(page.getByTestId('slot-form-dialog')).toBeHidden()
const saved = await findSlotByName(page, SLOT_NAME)
expect(saved.settings).toMatchObject({
position: 'left',
size: 'sm',
intervalMs: 8000,
lang: 'auto',
collapsedDefault: false,
imageFit: 'cover',
offsetX: 40,
offsetBottom: 80,
zIndex: 2000
})
})
test('嵌入代码卡:只含显式非默认 data-* 覆盖;全默认 slot 为裸 loader 引用', async ({ page }) => {
// 全默认 slot 经 API 创建(settings 整体缺省 → 归一化缺省落库)
const token = await sessionToken(page)
const created = await page.request.post('/v1/admin/embed-slots', {
headers: { Authorization: `Bearer ${token}` },
data: { name: DEFAULT_SLOT_NAME }
})
expect(created.ok()).toBeTruthy()
await loginAsAdmin(page)
await page.goto('/#/system/embed-slots')
// 显式覆盖 slot:position/size/interval/imageFit/offsetX/offsetBottom/zIndex
// 非默认 → 各自 data-*;lang=auto 与 collapsedDefault(无 data-* 支持)省略
const overrideRow = slotRow(page, SLOT_NAME)
await expect(overrideRow).toBeVisible()
await overrideRow.getByTestId('slot-code-btn').click()
await expect(page.getByTestId('embed-code-dialog')).toBeVisible()
const overrideSnippet = page.getByTestId('embed-code-snippet')
await expect(overrideSnippet).toContainText('data-slot=')
await expect(overrideSnippet).toContainText('data-position="left"')
await expect(overrideSnippet).toContainText('data-size="sm"')
await expect(overrideSnippet).toContainText('data-interval="8000"')
await expect(overrideSnippet).toContainText('data-image-fit="cover"')
await expect(overrideSnippet).toContainText('data-offset-x="40"')
await expect(overrideSnippet).toContainText('data-offset-bottom="80"')
await expect(overrideSnippet).toContainText('data-z-index="2000"')
await expect(overrideSnippet).not.toContainText('data-lang=')
// 覆盖说明可见(缺省字段让后台设置生效)
await expect(page.getByTestId('embed-code-override-hint')).toBeVisible()
await page.getByTestId('embed-code-close').click()
await expect(page.getByTestId('embed-code-dialog')).toBeHidden()
// 全默认 slot:仅 data-slot(缺省字段全部省略 → 后台配置生效)
const defaultRow = slotRow(page, DEFAULT_SLOT_NAME)
await expect(defaultRow).toBeVisible()
await defaultRow.getByTestId('slot-code-btn').click()
await expect(page.getByTestId('embed-code-dialog')).toBeVisible()
const defaultSnippet = page.getByTestId('embed-code-snippet')
const defaultToken = ((await page.getByTestId('embed-code-token').textContent()) ?? '').trim()
await expect(defaultSnippet).toHaveText(
`<script src="http://127.0.0.1:5175/embed/v1/loader.js" data-slot="${defaultToken}" defer></script>`
)
await page.getByTestId('embed-code-close').click()
})
test('预览纯本地:表单交互窗口零 /v1/ 请求(不写展示位、不上报浏览)', async ({ page }) => {
await loginAsAdmin(page)
await page.goto('/#/system/embed-slots')
await expect(page.getByTestId('embed-slots-table')).toBeVisible()
await page.getByTestId('slot-create-btn').click()
await expect(page.getByTestId('slot-form-dialog')).toBeVisible()
const desktop = page.getByTestId('embed-preview-desktop')
// 交互窗口内记录全部 /v1/ 请求(表单/预览均为纯本地)
const apiCalls: string[] = []
const onRequest = (req: Request): void => {
const path = new URL(req.url()).pathname
if (path.startsWith('/v1/')) apiCalls.push(path)
}
page.on('request', onRequest)
try {
await page.getByTestId('slot-form-name').fill('零接口预览')
await page.getByTestId('slot-form-position').click()
await page.getByTestId('slot-form-position-left').click()
await page.getByTestId('slot-form-size').click()
await page.getByTestId('slot-form-size-sm').click()
await page.getByTestId('slot-form-lang').click()
await page.getByTestId('slot-form-lang-en').click()
await page.getByTestId('slot-form-image-fit-cover').click()
await expect(desktop.getByTestId('embed-preview-stage')).toHaveClass(/is-cover/)
// collapsedDefault=true → 桌面预览初始收起为把手(把手点击可本地展开)
await page.getByTestId('slot-form-collapsed').click()
await expect(desktop.getByTestId('embed-preview-handle')).toBeVisible()
await desktop.getByTestId('embed-preview-handle').click()
await expect(desktop.getByTestId('embed-preview-stage')).toBeVisible()
await page.getByTestId('slot-form-offset-x').locator('input').fill('20')
await page.keyboard.press('Tab')
await page.getByTestId('slot-form-offset-bottom').locator('input').fill('48')
await page.keyboard.press('Tab')
await page.getByTestId('slot-form-z-index').locator('input').fill('1500')
await page.keyboard.press('Tab')
await page.getByTestId('slot-form-interval').locator('input').fill('6000')
await page.keyboard.press('Tab')
// 静置收尾:预览随动后无任何后续/防抖请求
await page.waitForTimeout(600)
expect(apiCalls).toEqual([])
} finally {
page.off('request', onRequest)
}
// 取消退出:不保存(零写入旅程闭环)
await page.getByTestId('slot-form-cancel').click()
await expect(page.getByTestId('slot-form-dialog')).toBeHidden()
})
})
+26 -1
View File
@@ -739,7 +739,16 @@
"langZh": "Chinese",
"langEn": "English",
"collapsedLabel": "Collapsed by default",
"collapsedHint": "Show a small handle first; click to expand"
"collapsedHint": "Show a small handle first; click to expand",
"imageFitLabel": "Image fit",
"imageFitContain": "Fit (contain)",
"imageFitCover": "Fill & crop (cover)",
"offsetXLabel": "Horizontal offset",
"offsetBottomLabel": "Bottom offset",
"zIndexLabel": "Layer (z-index)",
"pxUnitOffsetX": "px (0-120)",
"pxUnitOffsetBottom": "px (0-240)",
"dirtyTag": "Unsaved"
},
"create": {
"title": "New Embed Slot",
@@ -775,8 +784,24 @@
"copy": "Copy",
"copied": "Embed code copied",
"hint": "Paste the code into the host page to mount the floating carousel; later rebinding or toggling never requires changing this code.",
"overrideHint": "Only explicitly overridden non-default fields are included (data-*); the rest follow the backend configuration, so backend changes apply without touching this code.",
"close": "Close"
},
"preview": {
"title": "Live preview",
"hint": "Local mock data (no API calls, nothing written to the slot); form edits show up immediately and only take effect online after clicking Save.",
"desktop": "Desktop preview",
"mobile": "Mobile preview (narrow screen)",
"badge": "Preview",
"chatMock": "Live chat",
"collapsedHint": "Narrow screens (<480px) force the small size and collapse by default",
"widgetTitle": "Event photos",
"handleText": "Photos",
"collapseLabel": "Collapse preview",
"viewAlbum": "View album",
"photoLabel": "Sample photo {n}",
"mockAlbumTitle": "Event Album"
},
"toast": {
"enabledOn": "Enabled (configVersion increased to {version})",
"enabledOff": "Disabled (configVersion increased to {version}; public feed returns 404)"
+26 -1
View File
@@ -739,7 +739,16 @@
"langZh": "中文",
"langEn": "英文",
"collapsedLabel": "默认收起",
"collapsedHint": "初始仅展示小把手,点击展开"
"collapsedHint": "初始仅展示小把手,点击展开",
"imageFitLabel": "图片适配",
"imageFitContain": "完整展示(contain)",
"imageFitCover": "裁切填满(cover)",
"offsetXLabel": "水平偏移",
"offsetBottomLabel": "底部偏移",
"zIndexLabel": "层级",
"pxUnitOffsetX": "像素(0-120)",
"pxUnitOffsetBottom": "像素(0-240)",
"dirtyTag": "未保存"
},
"create": {
"title": "新建展示位",
@@ -775,8 +784,24 @@
"copy": "复制",
"copied": "已复制嵌入代码",
"hint": "将代码粘贴到官网页面即可挂载悬浮轮播;后续换绑相册、启停均无需改动该代码。",
"overrideHint": "代码仅包含显式设置的非默认字段(data-* 覆盖);其余字段以后台配置为准,后台修改即生效,无需改动此代码。",
"close": "关闭"
},
"preview": {
"title": "实时预览",
"hint": "本地示例数据(不调用接口、不写展示位);表单修改即时反映,点击「保存」后才更新线上配置。",
"desktop": "桌面预览",
"mobile": "手机预览(窄屏)",
"badge": "预览",
"chatMock": "在线客服",
"collapsedHint": "窄屏(<480px)强制小尺寸并默认收起",
"widgetTitle": "活动照片",
"handleText": "照片",
"collapseLabel": "收起预览",
"viewAlbum": "查看相册",
"photoLabel": "示例照片 {n}",
"mockAlbumTitle": "活动相册"
},
"toast": {
"enabledOn": "已启用(configVersion 已递增至 {version})",
"enabledOff": "已停用(configVersion 已递增至 {version},公开端随即 404)"
+12 -1
View File
@@ -26,7 +26,10 @@ declare namespace Api {
/** 内容语言(auto=按相册 defaultLocale) */
type Lang = 'auto' | 'zh-CN' | 'en'
/** 展示设置(响应中字段恒齐全) */
/** 轮播图片适配方式(UI-01:contain=4:3 舞台完整展示;cover=裁切填满) */
type ImageFit = 'contain' | 'cover'
/** 展示设置(响应中字段恒齐全;UI-01 新增字段为可选扩展,响应恒为归一化全集) */
interface Settings {
position: Position
size: Size
@@ -35,6 +38,14 @@ declare namespace Api {
lang: Lang
/** 初始是否收起(收起后仅展示小把手) */
collapsedDefault: boolean
/** 图片适配(UI-01;缺省 contain) */
imageFit: ImageFit
/** 距宿主页面边缘水平偏移像素(UI-01;0..120,缺省 16) */
offsetX: number
/** 距宿主页面底部垂直偏移像素(UI-01;0..240,缺省 24) */
offsetBottom: number
/** 宿主页面层级(UI-01;1..2147483000,缺省 1000) */
zIndex: number
}
/** 展示位条目(管理视角) */
+187 -2
View File
@@ -4,12 +4,19 @@ import enMessages from '@/locales/langs/en.json'
import {
bindChangeKind,
buildEmbedSnippet,
buildPreviewPlacement,
buildPreviewWidgetStyle,
buildSettingsPayload,
buildSlotEditPayload,
clampIntervalMs,
clampOffsetBottom,
clampOffsetX,
clampZIndex,
DEFAULT_EMBED_SETTINGS,
EMBED_ORIGIN_FALLBACK,
embedOriginFromEnv,
explicitDataAttrs,
isSlotDraftDirty,
type EmbedSettingsDraft
} from './embedSlots'
@@ -27,13 +34,17 @@ const CURRENT_ITEM: Api.EmbedSlot.Item = {
}
describe('buildSettingsPayload 表单 → settings 载荷', () => {
it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全)', () => {
it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全,含 UI-01 新字段)', () => {
expect(buildSettingsPayload({ ...DEFAULT_EMBED_SETTINGS })).toEqual({
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false
collapsedDefault: false,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000
})
})
@@ -46,6 +57,40 @@ describe('buildSettingsPayload 表单 → settings 载荷', () => {
const draft: EmbedSettingsDraft = { ...DEFAULT_EMBED_SETTINGS, intervalMs: 2000 }
expect(buildSettingsPayload(draft).intervalMs).toBe(3000)
})
it('UI-01 新数值字段(offsetX/offsetBottom/zIndex)→ 载荷夹取到契约区间', () => {
expect(clampOffsetX(-5)).toBe(0)
expect(clampOffsetX(999)).toBe(120)
expect(clampOffsetX(Number.NaN)).toBe(16)
expect(clampOffsetBottom(-1)).toBe(0)
expect(clampOffsetBottom(9999)).toBe(240)
expect(clampOffsetBottom(Number.NaN)).toBe(24)
expect(clampZIndex(0)).toBe(1)
expect(clampZIndex(9999999999)).toBe(2147483000)
expect(clampZIndex(Number.NaN)).toBe(1000)
const draft: EmbedSettingsDraft = {
...DEFAULT_EMBED_SETTINGS,
offsetX: 40,
offsetBottom: 80,
zIndex: 2000
}
expect(buildSettingsPayload(draft)).toMatchObject({
offsetX: 40,
offsetBottom: 80,
zIndex: 2000
})
const overflow: EmbedSettingsDraft = {
...DEFAULT_EMBED_SETTINGS,
offsetX: 400,
offsetBottom: -3,
zIndex: 0
}
expect(buildSettingsPayload(overflow)).toMatchObject({
offsetX: 120,
offsetBottom: 0,
zIndex: 1
})
})
})
describe('buildSlotEditPayload 编辑载荷收敛', () => {
@@ -94,6 +139,146 @@ describe('buildEmbedSnippet 嵌入代码拼接', () => {
})
})
describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省让后台设置生效)', () => {
it('全默认 settings → 零 data-* 覆盖(裸 loader 引用)', () => {
expect(explicitDataAttrs({ ...DEFAULT_EMBED_SETTINGS })).toEqual({})
expect(
buildEmbedSnippet('https://view.example.com', 'tok', { ...DEFAULT_EMBED_SETTINGS })
).toBe(
'<script src="https://view.example.com/embed/v1/loader.js" data-slot="tok" defer></script>'
)
expect(buildEmbedSnippet('https://view.example.com', 'tok', null)).toBe(
'<script src="https://view.example.com/embed/v1/loader.js" data-slot="tok" defer></script>'
)
})
it('非默认字段 → 对应 data-*(烤串属性名对齐 loader dataset 键)', () => {
const settings: EmbedSettingsDraft = {
...DEFAULT_EMBED_SETTINGS,
position: 'left',
intervalMs: 8000,
imageFit: 'cover'
}
expect(explicitDataAttrs(settings)).toEqual({
'data-position': 'left',
'data-interval': '8000',
'data-image-fit': 'cover'
})
expect(buildEmbedSnippet('https://view.example.com', 'tok', settings)).toBe(
'<script src="https://view.example.com/embed/v1/loader.js" data-slot="tok" data-position="left" data-interval="8000" data-image-fit="cover" defer></script>'
)
})
it('全部字段非默认 → 全集覆盖(collapsedDefault 无 data-* 支持,恒省略)', () => {
const settings: EmbedSettingsDraft = {
position: 'left',
size: 'sm',
intervalMs: 3000,
lang: 'en',
collapsedDefault: true,
imageFit: 'cover',
offsetX: 40,
offsetBottom: 80,
zIndex: 2000
}
expect(Object.keys(explicitDataAttrs(settings))).toEqual([
'data-position',
'data-size',
'data-interval',
'data-lang',
'data-image-fit',
'data-offset-x',
'data-offset-bottom',
'data-z-index'
])
})
})
describe('buildPreviewPlacement 本地预览停靠几何(草稿即时反映,纯本地)', () => {
it('desktop:按配置展开(collapsed=collapsedDefault),几何随草稿字段变化', () => {
const placement = buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'desktop')
expect(placement).toEqual({
side: 'right',
size: 'md',
width: 320,
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
imageFit: 'contain',
collapsed: false
})
// 表单字段变化 → 预览几何即时跟随(未保存修改即时反映的纯逻辑基础)
const changed = buildPreviewPlacement(
{ ...DEFAULT_EMBED_SETTINGS, position: 'left', size: 'sm', offsetX: 40, zIndex: 2000 },
'desktop'
)
expect(changed.side).toBe('left')
expect(changed.width).toBe(280)
expect(changed.offsetX).toBe(40)
expect(changed.zIndex).toBe(2000)
expect(buildPreviewWidgetStyle(changed)).toMatchObject({
position: 'absolute',
left: '40px',
bottom: '24px',
width: '280px',
zIndex: '2000'
})
// collapsedDefault 仅作用于桌面预览初始形态
expect(
buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS, collapsedDefault: true }, 'desktop')
.collapsed
).toBe(true)
})
it('mobile:窄屏强制 sm + 默认收起(§5.4 <480px);宽度按剩余宽收缩防溢出', () => {
const placement = buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'mobile')
expect(placement.size).toBe('sm')
expect(placement.width).toBe(280)
expect(placement.collapsed).toBe(true) // 窄屏强制收起(与 collapsedDefault 无关)
expect(
buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS, collapsedDefault: false }, 'mobile')
.collapsed
).toBe(true)
// 偏移挤占剩余宽 → 宽度取小(375 - 120 - 8 = 247)
expect(buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS, offsetX: 120 }, 'mobile').width).toBe(
247
)
})
})
describe('isSlotDraftDirty 表单脏态(「未保存」标记)', () => {
it('编辑态与已保存一致 → 不脏;仅新字段/名称变化 → 脏', () => {
expect(isSlotDraftDirty(CURRENT_ITEM, CURRENT_ITEM.name, { ...DEFAULT_EMBED_SETTINGS })).toBe(
false
)
expect(
isSlotDraftDirty(CURRENT_ITEM, CURRENT_ITEM.name, {
...DEFAULT_EMBED_SETTINGS,
imageFit: 'cover'
})
).toBe(true)
expect(
isSlotDraftDirty(CURRENT_ITEM, CURRENT_ITEM.name, {
...DEFAULT_EMBED_SETTINGS,
zIndex: 2000
})
).toBe(true)
expect(
isSlotDraftDirty(CURRENT_ITEM, ` ${CURRENT_ITEM.name} `, {
...DEFAULT_EMBED_SETTINGS
})
).toBe(false) // 等价空白修剪不算变化
expect(isSlotDraftDirty(CURRENT_ITEM, '新名称', { ...DEFAULT_EMBED_SETTINGS })).toBe(true)
})
it('新建态:名称与设置均为缺省 → 不脏;任一输入 → 脏', () => {
expect(isSlotDraftDirty(null, '', { ...DEFAULT_EMBED_SETTINGS })).toBe(false)
expect(isSlotDraftDirty(null, ' ', { ...DEFAULT_EMBED_SETTINGS })).toBe(false)
expect(isSlotDraftDirty(null, '新展示位', { ...DEFAULT_EMBED_SETTINGS })).toBe(true)
expect(isSlotDraftDirty(null, '', { ...DEFAULT_EMBED_SETTINGS, offsetBottom: 100 })).toBe(true)
})
})
describe('bindChangeKind 绑定变更分类', () => {
it('null→id 绑定 / id→id 换绑 / id→null 解绑 / 值不变 none', () => {
expect(bindChangeKind(null, 'album-1')).toBe('bind')
+179 -13
View File
@@ -1,14 +1,21 @@
/**
* 展示位管理页纯逻辑(无组件依赖,vitest 直接覆盖;P4b Task 5)
* 展示位管理页纯逻辑(无组件依赖,vitest 直接覆盖;P4b Task 5,UI-01 Task 7 扩展)
*
* - 嵌入代码:`<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 之外的键盘输入兜底)
* 即 viewer 部署基址,经 VITE_EMBED_ORIGIN 配置,缺省兜底本机 dev 5175);
* UI-01:可选携带 settings → 仅非默认字段生成 data-* 覆盖(缺省字段省略,
* 后台设置生效——loader 侧 explicit 表语义,方案 §5.4)
* - settings 表单 → 载荷:整体提交语义(提供时字段齐全),intervalMs 及
* UI-01 新增数值字段(offsetX/offsetBottom/zIndex)在契约区间内夹取
* (ElInputNumber min/max 之外的键盘输入兜底)
* - 编辑载荷收敛:只提交变更字段(name/settings 任一变化才送;settings
* 一旦送即为完整对象——契约整体替换语义);绑定变更(albumId)走独立
* 绑定对话框,不经编辑载荷
* - 表单脏态(isSlotDraftDirty):编辑态与已保存条目比对、新建态与缺省比对,
* 驱动「未保存」标记(保存按钮以外唯一持久化入口)
* - 本地预览(buildPreviewPlacement/buildPreviewWidgetStyle):草稿 → 预览
* 框内悬浮组件的停靠几何(纯本地计算,不调用接口不写展示位)
* - 绑定变更分类:bind(未绑定→绑定)/ rebind(换绑)/ unbind(解绑)/
* none(未变化不提交,不制造服务端 no-op)
*/
@@ -20,6 +27,17 @@ export const EMBED_ORIGIN_FALLBACK = 'http://127.0.0.1:5175'
export const EMBED_INTERVAL_MIN_MS = 3000
export const EMBED_INTERVAL_MAX_MS = 30000
/** 契约 EmbedSlotSettings UI-01 新增偏移/层级边界(对齐 loader params.js) */
export const EMBED_OFFSET_X_MIN = 0
export const EMBED_OFFSET_X_MAX = 120
export const EMBED_OFFSET_BOTTOM_MIN = 0
export const EMBED_OFFSET_BOTTOM_MAX = 240
export const EMBED_Z_INDEX_MIN = 1
export const EMBED_Z_INDEX_MAX = 2147483000
/** 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
@@ -29,7 +47,11 @@ export const DEFAULT_EMBED_SETTINGS: EmbedSettingsDraft = {
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false
collapsedDefault: false,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000
}
/** 环境嵌入基址(VITE_EMBED_ORIGIN 缺省/空白兜底本机 dev;尾斜杠容忍) */
@@ -38,9 +60,47 @@ export function embedOriginFromEnv(env: { VITE_EMBED_ORIGIN?: string }): string
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>`
/** 部署基址 + 展示位令牌 → 嵌入代码(宿主页粘贴即用;token 不可枚举)。
* UI-01:可选携带 settings——仅与契约默认不同的字段生成 data-* 覆盖
* (缺省字段省略 → 后台设置生效;loader 侧 explicit 表只记真正提供的字段)。 */
export function buildEmbedSnippet(
origin: string,
slotToken: string,
settings?: Api.EmbedSlot.Settings | null
): string {
const attrs = 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>`
}
/** settings 字段 → loader data-* 属性名(dataset 驼峰 ↔ HTML 烤串) */
const DATA_ATTR_NAMES: Partial<Record<keyof Api.EmbedSlot.Settings, string>> = {
position: 'data-position',
size: 'data-size',
intervalMs: 'data-interval',
lang: 'data-lang',
imageFit: 'data-image-fit',
offsetX: 'data-offset-x',
offsetBottom: 'data-offset-bottom',
zIndex: 'data-z-index'
// collapsedDefault 无 data-* 支持(loader 不读取;窄屏强制收起另有规则)
}
/** settings → 显式覆盖的 data-* 属性表(仅非默认字段;顺序稳定便于断言/快照) */
export function explicitDataAttrs(
settings?: Api.EmbedSlot.Settings | null
): Record<string, string> {
const attrs: Record<string, string> = {}
if (settings === null || settings === undefined) return attrs
for (const [field, attr] of Object.entries(DATA_ATTR_NAMES) as Array<
[keyof Api.EmbedSlot.Settings, string]
>) {
const value = settings[field]
if (value !== DEFAULT_EMBED_SETTINGS[field]) attrs[attr] = String(value)
}
return attrs
}
/** intervalMs 夹取到契约区间(非有限值回退缺省 5000;四舍五入整毫秒) */
@@ -49,19 +109,47 @@ export function clampIntervalMs(value: number): number {
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) }
/** offsetX 夹取到契约区间(非有限值回退缺省 16;四舍五入整像素) */
export function clampOffsetX(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.offsetX
return Math.min(EMBED_OFFSET_X_MAX, Math.max(EMBED_OFFSET_X_MIN, Math.round(value)))
}
/** settings 是否有任一字段变化(编辑载荷只送变更面) */
/** offsetBottom 夹取到契约区间(非有限值回退缺省 24;四舍五入整像素) */
export function clampOffsetBottom(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.offsetBottom
return Math.min(EMBED_OFFSET_BOTTOM_MAX, Math.max(EMBED_OFFSET_BOTTOM_MIN, Math.round(value)))
}
/** zIndex 夹取到契约区间(非有限值回退缺省 1000;四舍五入整数) */
export function clampZIndex(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.zIndex
return Math.min(EMBED_Z_INDEX_MAX, Math.max(EMBED_Z_INDEX_MIN, Math.round(value)))
}
/** 表单草稿 → 完整 settings 载荷(整体提交:字段齐全 + 数值字段夹取) */
export function buildSettingsPayload(draft: EmbedSettingsDraft): Api.EmbedSlot.Settings {
return {
...draft,
intervalMs: clampIntervalMs(draft.intervalMs),
offsetX: clampOffsetX(draft.offsetX),
offsetBottom: clampOffsetBottom(draft.offsetBottom),
zIndex: clampZIndex(draft.zIndex)
}
}
/** 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
current.collapsedDefault !== next.collapsedDefault ||
current.imageFit !== next.imageFit ||
current.offsetX !== clampOffsetX(next.offsetX) ||
current.offsetBottom !== clampOffsetBottom(next.offsetBottom) ||
current.zIndex !== clampZIndex(next.zIndex)
)
}
@@ -80,6 +168,84 @@ export function buildSlotEditPayload(
return payload
}
/** 表单脏态(驱动「未保存」标记):编辑态与已保存条目逐字段比对;
* 新建态(current=null)名称非空或设置偏离缺省即脏(有未保存内容)。 */
export function isSlotDraftDirty(
current: Api.EmbedSlot.Item | null,
name: string,
draft: EmbedSettingsDraft
): boolean {
if (current === null) {
return name.trim() !== '' || settingsChanged(DEFAULT_EMBED_SETTINGS, draft)
}
return Object.keys(buildSlotEditPayload(current, name, draft)).length > 0
}
/** 本地预览停靠几何(buildPreviewPlacement 产物;纯本地渲染输入,不入载荷) */
export interface EmbedPreviewPlacement {
/** 停靠边(宿主左右侧) */
side: Api.EmbedSlot.Position
/** 尺寸档(mobile 窄屏强制 sm) */
size: Api.EmbedSlot.Size
/** 组件宽度 px(配置宽与视口剩余宽取小,§5.4 无溢出;下限 180) */
width: number
/** 距预览框边缘水平偏移 px(夹取后) */
offsetX: number
/** 距预览框底部垂直偏移 px(夹取后) */
offsetBottom: number
/** 层级(夹取后;预览框内真实生效) */
zIndex: number
/** 图片适配(舞台 contain/cover 直观反映) */
imageFit: Api.EmbedSlot.ImageFit
/** 初始是否收起:desktop=collapsedDefault;mobile=true(<480px 强制,§5.4) */
collapsed: boolean
}
/** 极窄视口防溢出下限(对齐 loader MIN_WRAPPER_WIDTH) */
export const EMBED_PREVIEW_MIN_WIDTH = 180
/** 预览框视口宽度 px(组件帧常量:desktop 440 / mobile 375) */
export const EMBED_PREVIEW_FRAME_WIDTH: Record<'desktop' | 'mobile', number> = {
desktop: 440,
mobile: 375
}
/** 表单草稿 → 预览停靠几何(desktop=按配置;mobile=窄屏强制 sm+收起)。
* 数值全部经契约夹取——表单输入的中间态(空/越界)预览不抖动;宽度按
* loader applyWrapperBox 同规则取配置宽与视口剩余宽的较小值。 */
export function buildPreviewPlacement(
draft: EmbedSettingsDraft,
mode: 'desktop' | 'mobile',
frameWidth: number = EMBED_PREVIEW_FRAME_WIDTH[mode]
): EmbedPreviewPlacement {
const narrow = mode === 'mobile'
const size: Api.EmbedSlot.Size = narrow ? 'sm' : draft.size
const offsetX = clampOffsetX(draft.offsetX)
const available = Math.max(frameWidth - offsetX - 8, EMBED_PREVIEW_MIN_WIDTH)
return {
side: draft.position,
size,
width: Math.min(EMBED_SIZE_WIDTH[size], available),
offsetX,
offsetBottom: clampOffsetBottom(draft.offsetBottom),
zIndex: clampZIndex(draft.zIndex),
imageFit: draft.imageFit,
collapsed: narrow ? true : draft.collapsedDefault
}
}
/** 停靠几何 → 预览框内悬浮组件 wrapper 样式(对齐 loader wrapper:固定停靠
* 边 + offset + 宽度 + zIndex;预览框内以 absolute 模拟宿主 fixed)。 */
export function buildPreviewWidgetStyle(placement: EmbedPreviewPlacement): Record<string, string> {
return {
position: 'absolute',
[placement.side]: `${placement.offsetX}px`,
bottom: `${placement.offsetBottom}px`,
width: `${placement.width}px`,
zIndex: String(placement.zIndex)
}
}
/** 绑定变更分类(确认文案与提交动作共用;none=不提交) */
export type BindChangeKind = 'bind' | 'rebind' | 'unbind' | 'none'
@@ -1,6 +1,8 @@
<!-- 嵌入代码卡(P4b Task 5):展示位公开令牌 + 宿主页可粘贴的 loader 引用
代码(EMBED_ORIGIN 来自 VITE_EMBED_ORIGIN,缺省本机 viewer dev)+ 一键
复制。代码只认 token:后续换绑/启停均无需改动宿主页(A22)。 -->
<!-- 嵌入代码卡(P4b Task 5;UI-01 Task 7 增强):展示位公开令牌 + 宿主页可
粘贴的 loader 引用代码(EMBED_ORIGIN 来自 VITE_EMBED_ORIGIN,缺省本机
viewer dev)+ 一键复制。代码只认 token:后续换绑/启停均无需改动宿主页
(A22)。UI-01:按展示位当前设置生成 data-* 显式覆盖——仅含非默认字段
(缺省字段省略 → 后台设置生效,loader explicit 表语义 §5.4)。 -->
<template>
<ElDialog
v-model="visible"
@@ -38,6 +40,12 @@
:title="$t('embedSlotsManage.code.hint')"
data-test="embed-code-hint"
/>
<ElAlert
type="warning"
:closable="false"
:title="$t('embedSlotsManage.code.overrideHint')"
data-test="embed-code-override-hint"
/>
</div>
<template #footer>
@@ -55,7 +63,7 @@
import { buildEmbedSnippet, embedOriginFromEnv } from '@/utils/system/embedSlots'
const props = defineProps<{
/** 目标展示位(publicToken 拼入 data-slot) */
/** 目标展示位(publicToken 拼入 data-slot;settings 生成显式 data-* 覆盖) */
embedSlot: Api.EmbedSlot.Item | null
}>()
@@ -63,7 +71,9 @@
const { t } = useI18n()
const snippet = computed(() =>
props.embedSlot === null ? '' : buildEmbedSnippet(embedOrigin(), props.embedSlot.publicToken)
props.embedSlot === null
? ''
: buildEmbedSnippet(embedOrigin(), props.embedSlot.publicToken, props.embedSlot.settings)
)
function embedOrigin(): string {
@@ -0,0 +1,507 @@
<!-- 展示位本地实时预览(UI-01 Task 7):表单草稿 → 桌面/手机双框预览。
· 纯本地:不调用任何接口、不写展示位、不上报浏览——数据为内置示例
(3 张渐变示意照片 + 相册标题占位),未保存修改经 props 即时反映;
· 与生产组件共享展示结构:类名/结构镜像 viewer 嵌入页(embed-root/
embed-head/embed-stage/embed-foot + loader 收起把手),样式随
viewer 同步维护,非另起一套视觉;停靠几何由 buildPreviewPlacement
纯函数产出(与 loader wrapper 同规则);
· mobile 框模拟 <480px 窄屏:强制 sm + 默认收起(§5.4),把手可点击
本地展开演示(用户选择优先于默认值——仅预览内状态);
· zIndex 徽标 + 框内低层级示意元素(z-index 900)直观反映层级配置;
· 暂停/查看相册等控件为装饰(disabled),不触发任何动作。 -->
<template>
<div class="embed-preview" :class="`is-${mode}`">
<div
class="embed-preview-frame"
:data-test="mode === 'desktop' ? 'embed-preview-desktop' : 'embed-preview-mobile'"
>
<div v-if="mode === 'desktop'" class="embed-preview-chrome" aria-hidden="true">
<span class="embed-preview-dot"></span>
<span class="embed-preview-dot"></span>
<span class="embed-preview-dot"></span>
<span class="embed-preview-url">www.example.com</span>
</div>
<div v-else class="embed-preview-chrome embed-preview-chrome--phone" aria-hidden="true">
<span class="embed-preview-notch"></span>
</div>
<div class="embed-preview-body">
<!-- 宿主页面示意内容(纯装饰) -->
<div class="embed-preview-page" aria-hidden="true">
<span class="embed-preview-page-title"></span>
<span class="embed-preview-page-line"></span>
<span class="embed-preview-page-line is-short"></span>
<span class="embed-preview-page-banner"></span>
<span class="embed-preview-page-line"></span>
</div>
<!-- 低层级宿主元素示意(z-index 900):层级配置直观可见 -->
<span
v-if="mode === 'desktop'"
class="embed-preview-chat"
aria-hidden="true"
data-test="embed-preview-chat"
>
{{ $t('embedSlotsManage.preview.chatMock') }}
</span>
<span class="embed-preview-badge" data-test="embed-preview-badge">
{{ $t('embedSlotsManage.preview.badge') }}
</span>
<span class="embed-preview-zindex" data-test="embed-preview-zindex">
z-index {{ placement.zIndex }}
</span>
<!-- 悬浮组件本体(几何随草稿即时变化) -->
<div
class="embed-preview-dock"
:class="`is-${placement.side}`"
:style="widgetStyle"
data-test="embed-preview-widget"
>
<div
v-if="isCollapsed"
class="embed-handle"
data-test="embed-preview-handle"
role="button"
tabindex="0"
@click="expand"
@keydown.enter.prevent="expand"
@keydown.space.prevent="expand"
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<path
fill="#ffffff"
d="M9 2 7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"
/>
</svg>
<span>{{ $t('embedSlotsManage.preview.handleText') }}</span>
</div>
<!-- 镜像 viewer EmbedPage 结构:embed-root > embed-head + embed-carousel -->
<div v-else class="embed-root">
<div class="embed-head">
<span class="embed-heading">{{ $t('embedSlotsManage.preview.widgetTitle') }}</span>
<button
type="button"
class="embed-close"
:aria-label="$t('embedSlotsManage.preview.collapseLabel')"
data-test="embed-preview-collapse"
@click="collapse"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M18.3 5.71 12 12.01l-6.3-6.3-1.4 1.41 6.29 6.3-6.3 6.3 1.42 1.4 6.29-6.29 6.3 6.3 1.4-1.42-6.3-6.29 6.3-6.3z"
/>
</svg>
</button>
</div>
<div class="embed-carousel">
<div
class="embed-stage"
:class="placement.imageFit === 'cover' ? 'is-cover' : 'is-contain'"
data-test="embed-preview-stage"
>
<!-- 示例照片为 1:1 渐变色块:contain 完整展示(衬中性深色底)、
cover 裁切填满——适配配置即时可见 -->
<div class="embed-preview-photo" :style="mockSlides[0].gradient">
<span>{{ $t('embedSlotsManage.preview.photoLabel', { n: 1 }) }}</span>
</div>
</div>
<footer class="embed-foot">
<p class="embed-title" data-test="embed-preview-title">{{ albumTitle }}</p>
<div class="embed-foot-row">
<span class="embed-indicator" data-test="embed-preview-indicator">01 / 03</span>
<div class="embed-foot-actions">
<button type="button" class="embed-foot-btn" disabled aria-disabled="true">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
aria-hidden="true"
focusable="false"
>
<path fill="currentColor" d="M6 5h4v14H6zm8 0h4v14h-4z" />
</svg>
</button>
<span class="embed-foot-link is-disabled" data-test="embed-preview-view">
{{ $t('embedSlotsManage.preview.viewAlbum') }}
</span>
</div>
</div>
</footer>
</div>
</div>
</div>
</div>
</div>
<div class="embed-preview-caption">
<span>{{
mode === 'desktop'
? $t('embedSlotsManage.preview.desktop')
: $t('embedSlotsManage.preview.mobile')
}}</span>
<span v-if="mode === 'mobile'" class="embed-preview-caption-hint">
{{ $t('embedSlotsManage.preview.collapsedHint') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
buildPreviewPlacement,
buildPreviewWidgetStyle,
type EmbedSettingsDraft
} from '@/utils/system/embedSlots'
const props = defineProps<{
/** 表单草稿(未保存修改即时反映) */
draft: EmbedSettingsDraft
/** 桌面(按配置)/ 手机(<480px 窄屏行为) */
mode: 'desktop' | 'mobile'
/** 相册标题占位(编辑已绑定相册时传入,否则词条占位) */
albumTitle: string
}>()
/** 示例照片(1:1 渐变色块;仅第 1 帧上屏,指示器按 3 帧示意) */
const mockSlides = [
{ gradient: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 60%, #d946ef 100%)' },
{ gradient: 'linear-gradient(135deg, #0ea5e9 0%, #2563eb 60%, #1d4ed8 100%)' },
{ gradient: 'linear-gradient(135deg, #f59e0b 0%, #ef4444 60%, #db2777 100%)' }
]
const placement = computed(() => buildPreviewPlacement(props.draft, props.mode))
const widgetStyle = computed(() => buildPreviewWidgetStyle(placement.value))
/** 把手/关闭仅切换预览内展开态(用户选择优先于默认值;不持久化) */
const expanded = ref(false)
const isCollapsed = computed(() => placement.value.collapsed && !expanded.value)
watch(
() => placement.value.collapsed,
() => {
expanded.value = false
}
)
function expand(): void {
expanded.value = true
}
function collapse(): void {
expanded.value = false
}
</script>
<style scoped>
.embed-preview {
display: flex;
flex-direction: column;
gap: 4px;
}
.embed-preview-frame {
position: relative;
display: flex;
flex-direction: column;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
background: #f9fafb;
}
.is-desktop .embed-preview-frame {
width: 440px; /* EMBED_PREVIEW_FRAME_WIDTH.desktop(buildPreviewPlacement 视口宽) */
height: 380px;
}
.is-mobile .embed-preview-frame {
width: 375px; /* EMBED_PREVIEW_FRAME_WIDTH.mobile */
height: 320px;
margin: 0 auto;
}
.embed-preview-chrome {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: #e5e7eb;
border-bottom: 1px solid #d1d5db;
}
.embed-preview-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #9ca3af;
}
.embed-preview-url {
margin-left: 8px;
padding: 1px 10px;
border-radius: 999px;
background: #f9fafb;
color: #6b7280;
font-size: 10px;
line-height: 16px;
}
.embed-preview-chrome--phone {
justify-content: center;
}
.embed-preview-notch {
width: 90px;
height: 12px;
border-radius: 0 0 10px 10px;
background: #d1d5db;
}
.embed-preview-body {
position: relative;
flex: 1;
overflow: hidden;
background: linear-gradient(180deg, #ffffff 0%, #f3f4f6 100%);
}
/* 宿主页面示意内容(纯装饰灰度块) */
.embed-preview-page {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
}
.embed-preview-page-title {
width: 40%;
height: 12px;
border-radius: 4px;
background: #d1d5db;
}
.embed-preview-page-line {
width: 88%;
height: 8px;
border-radius: 4px;
background: #e5e7eb;
}
.embed-preview-page-line.is-short {
width: 62%;
}
.embed-preview-page-banner {
width: 100%;
height: 56px;
border-radius: 6px;
background: linear-gradient(90deg, #e0e7ff, #ede9fe);
}
/* 低层级宿主元素示意:z-index 900(组件层级低于它则被盖住) */
.embed-preview-chat {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 900;
padding: 8px 12px;
border-radius: 999px;
background: #10b981;
color: #fff;
font-size: 11px;
box-shadow: 0 2px 8px rgb(0 0 0 / 0.18);
}
.embed-preview-badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
padding: 2px 8px;
border-radius: 4px;
background: #fef3c7;
border: 1px solid #f59e0b;
color: #92400e;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
}
.embed-preview-zindex {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
padding: 2px 8px;
border-radius: 4px;
background: #e0e7ff;
border: 1px solid #6366f1;
color: #3730a3;
font-size: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.embed-preview-caption {
display: flex;
align-items: center;
gap: 8px;
color: #6b7280;
font-size: 12px;
text-align: center;
}
.is-desktop .embed-preview-caption {
justify-content: center;
}
.is-mobile .embed-preview-caption {
justify-content: center;
}
.embed-preview-caption-hint {
color: #9ca3af;
}
/* ── 悬浮组件本体:镜像 viewer 嵌入页类名(embed-root/head/stage/foot)与
loader wrapper(圆角 12 + 阴影)——视觉随 viewer 同步,不另起一套 ── */
.embed-preview-dock {
display: flex;
border-radius: 12px;
box-shadow: 0 4px 16px rgb(0 0 0 / 0.18);
overflow: hidden;
}
.embed-root {
background: #fff;
font-family:
system-ui,
-apple-system,
'Segoe UI',
'PingFang SC',
sans-serif;
color: #1f2328;
}
.embed-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 6px 6px 10px;
}
.embed-heading {
font-size: 12px;
font-weight: 600;
color: #5e6878;
letter-spacing: 0.02em;
}
.embed-close {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: #6b7280;
cursor: pointer;
}
.embed-close:hover {
background: #f3f4f6;
color: #374151;
}
.embed-stage {
position: relative;
aspect-ratio: 4 / 3;
overflow: hidden;
background: #1a1a2e; /* 中性深色舞台(viewer EmbedStage 同值) */
display: flex;
}
.embed-stage.is-contain {
align-items: center;
justify-content: center;
}
/* 示例照片 1:1:contain 完整展示(max 约束+居中),cover 铺满裁切 */
.embed-preview-photo {
display: flex;
align-items: center;
justify-content: center;
color: rgb(255 255 255 / 0.85);
font-size: 12px;
font-weight: 600;
}
.is-contain .embed-preview-photo {
aspect-ratio: 1 / 1;
max-width: 100%;
max-height: 100%;
}
.is-cover .embed-preview-photo {
width: 100%;
height: 100%;
}
.embed-foot {
padding: 8px 10px 10px;
}
.embed-title {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
line-height: 1.3;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.embed-foot-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 26px;
}
.embed-indicator {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: #5e6878;
white-space: nowrap;
}
.embed-foot-actions {
display: flex;
align-items: center;
gap: 4px;
}
.embed-foot-btn,
.embed-foot-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 26px;
padding: 0 6px;
border: 0;
border-radius: 6px;
background: transparent;
color: #1f2937;
font-size: 12px;
text-decoration: none;
}
.embed-foot-btn:disabled {
cursor: default;
opacity: 0.6;
}
.embed-foot-link.is-disabled {
cursor: default;
opacity: 0.6;
color: #1f2937;
}
/* 收起把手(镜像 loader createHandle:胶囊 44px + 深色底 + 白字) */
.embed-handle {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 44px;
padding: 0 14px;
border-radius: 22px;
background: rgb(15 23 42 / 0.88);
box-shadow: 0 2px 10px rgb(0 0 0 / 0.3);
color: #fff;
font-size: 13px;
font-weight: 500;
line-height: 1;
white-space: nowrap;
cursor: pointer;
user-select: none;
}
.is-left .embed-handle {
margin-right: auto;
}
.is-right .embed-handle {
margin-left: auto;
}
</style>
@@ -1,79 +1,148 @@
<!-- 展示位新建/编辑对话框(P4b Task 5):名称 + 设置表单(位置/尺寸/间隔/
语言/默认收起)。slot 为 null 即新建(表单取契约缺省值);编辑按契约
整体替换语义提交完整 settings(intervalMs 夹取兜底 ElInputNumber 边界
之外的键盘输入)。仅提交变更字段,无变更不制造服务端 no-op。 -->
<!-- 展示位新建/编辑对话框(P4b Task 5;UI-01 Task 7 增强):名称 + 设置表单
(位置/尺寸/间隔/语言/默认收起 + 图片适配/水平偏移/底部偏移/层级)+
本地实时预览(桌面/手机双框,未保存修改即时反映——EmbedSlotPreview)。
slot 为 null 即新建(表单取契约缺省值);编辑按契约整体替换语义提交完整
settings(数值字段夹取兜底 ElInputNumber 边界之外的键盘输入)。仅提交
变更字段,无变更不制造服务端 no-op;「未保存」标记 = 脏态可视(保存以
外不持久化,预览亦纯本地零接口)。 -->
<template>
<ElDialog
v-model="visible"
:title="
embedSlot === null ? $t('embedSlotsManage.create.title') : $t('embedSlotsManage.edit.title')
"
width="520px"
destroy-on-close
data-test="slot-form-dialog"
>
<ElForm label-width="130px">
<ElFormItem :label="$t('embedSlotsManage.form.nameLabel')" :error="nameError">
<ElInput
v-model="draftName"
:placeholder="$t('embedSlotsManage.form.namePlaceholder')"
maxlength="64"
show-word-limit
data-test="slot-form-name"
/>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.positionLabel')">
<ElSelect v-model="draft.position" style="width: 200px" data-test="slot-form-position">
<ElOption
v-for="opt in positionOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
:data-test="`slot-form-position-${opt.value}`"
<ElDialog v-model="visible" width="980px" top="4vh" destroy-on-close data-test="slot-form-dialog">
<template #header>
<div class="flex items-center gap-2">
<span>{{
embedSlot === null
? $t('embedSlotsManage.create.title')
: $t('embedSlotsManage.edit.title')
}}</span>
<ElTag v-if="dirty" type="warning" effect="light" data-test="slot-form-dirty">
{{ $t('embedSlotsManage.form.dirtyTag') }}
</ElTag>
</div>
</template>
<div class="flex items-start gap-6">
<ElForm label-width="130px" class="slot-form-dialog__form">
<ElFormItem :label="$t('embedSlotsManage.form.nameLabel')" :error="nameError">
<ElInput
v-model="draftName"
:placeholder="$t('embedSlotsManage.form.namePlaceholder')"
maxlength="64"
show-word-limit
data-test="slot-form-name"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.sizeLabel')">
<ElSelect v-model="draft.size" style="width: 200px" data-test="slot-form-size">
<ElOption
v-for="opt in sizeOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
:data-test="`slot-form-size-${opt.value}`"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.intervalLabel')">
<div class="flex items-center gap-2">
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.positionLabel')">
<ElSelect v-model="draft.position" style="width: 200px" data-test="slot-form-position">
<ElOption
v-for="opt in positionOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
:data-test="`slot-form-position-${opt.value}`"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.sizeLabel')">
<ElSelect v-model="draft.size" style="width: 200px" data-test="slot-form-size">
<ElOption
v-for="opt in sizeOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
:data-test="`slot-form-size-${opt.value}`"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.intervalLabel')">
<div class="flex items-center gap-2">
<ElInputNumber
v-model="draft.intervalMs"
:min="EMBED_INTERVAL_MIN_MS"
:max="EMBED_INTERVAL_MAX_MS"
:step="1000"
data-test="slot-form-interval"
/>
<span class="text-gray-400 text-sm">{{
$t('embedSlotsManage.form.intervalUnit')
}}</span>
</div>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.langLabel')">
<ElSelect v-model="draft.lang" style="width: 200px" data-test="slot-form-lang">
<ElOption
v-for="opt in langOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
:data-test="`slot-form-lang-${opt.value}`"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.collapsedLabel')">
<div class="flex items-center gap-2">
<ElSwitch v-model="draft.collapsedDefault" data-test="slot-form-collapsed" />
<span class="text-gray-400 text-sm">{{
$t('embedSlotsManage.form.collapsedHint')
}}</span>
</div>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.imageFitLabel')">
<ElRadioGroup v-model="draft.imageFit" data-test="slot-form-image-fit">
<ElRadio value="contain" data-test="slot-form-image-fit-contain">
{{ $t('embedSlotsManage.form.imageFitContain') }}
</ElRadio>
<ElRadio value="cover" data-test="slot-form-image-fit-cover">
{{ $t('embedSlotsManage.form.imageFitCover') }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.offsetXLabel')">
<div class="flex items-center gap-2">
<ElInputNumber
v-model="draft.offsetX"
:min="EMBED_OFFSET_X_MIN"
:max="EMBED_OFFSET_X_MAX"
:step="4"
data-test="slot-form-offset-x"
/>
<span class="text-gray-400 text-sm">{{
$t('embedSlotsManage.form.pxUnitOffsetX')
}}</span>
</div>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.offsetBottomLabel')">
<div class="flex items-center gap-2">
<ElInputNumber
v-model="draft.offsetBottom"
:min="EMBED_OFFSET_BOTTOM_MIN"
:max="EMBED_OFFSET_BOTTOM_MAX"
:step="4"
data-test="slot-form-offset-bottom"
/>
<span class="text-gray-400 text-sm">{{
$t('embedSlotsManage.form.pxUnitOffsetBottom')
}}</span>
</div>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.zIndexLabel')">
<ElInputNumber
v-model="draft.intervalMs"
:min="EMBED_INTERVAL_MIN_MS"
:max="EMBED_INTERVAL_MAX_MS"
:step="1000"
data-test="slot-form-interval"
v-model="draft.zIndex"
:min="EMBED_Z_INDEX_MIN"
:max="EMBED_Z_INDEX_MAX"
:step="100"
data-test="slot-form-z-index"
/>
<span class="text-gray-400 text-sm">{{ $t('embedSlotsManage.form.intervalUnit') }}</span>
</div>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.langLabel')">
<ElSelect v-model="draft.lang" style="width: 200px" data-test="slot-form-lang">
<ElOption
v-for="opt in langOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
:data-test="`slot-form-lang-${opt.value}`"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.collapsedLabel')">
<div class="flex items-center gap-2">
<ElSwitch v-model="draft.collapsedDefault" data-test="slot-form-collapsed" />
<span class="text-gray-400 text-sm">{{ $t('embedSlotsManage.form.collapsedHint') }}</span>
</div>
</ElFormItem>
</ElForm>
</ElFormItem>
</ElForm>
<!-- 本地实时预览:纯示例数据(零接口),未保存修改即时反映 -->
<div class="slot-form-dialog__preview" data-test="embed-preview-panel">
<div class="font-medium">{{ $t('embedSlotsManage.preview.title') }}</div>
<div class="text-gray-400 text-xs mb-3">{{ $t('embedSlotsManage.preview.hint') }}</div>
<EmbedSlotPreview :draft="draft" mode="desktop" :album-title="previewAlbumTitle" />
<EmbedSlotPreview :draft="draft" mode="mobile" :album-title="previewAlbumTitle" />
</div>
</div>
<template #footer>
<ElButton data-test="slot-form-cancel" @click="visible = false">
@@ -90,13 +159,21 @@
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import EmbedSlotPreview from './EmbedSlotPreview.vue'
import { fetchCreateEmbedSlot, fetchUpdateEmbedSlot } from '@/api/embedSlots'
import {
DEFAULT_EMBED_SETTINGS,
EMBED_INTERVAL_MAX_MS,
EMBED_INTERVAL_MIN_MS,
buildSettingsPayload,
EMBED_OFFSET_BOTTOM_MAX,
EMBED_OFFSET_BOTTOM_MIN,
EMBED_OFFSET_X_MAX,
EMBED_OFFSET_X_MIN,
EMBED_Z_INDEX_MAX,
EMBED_Z_INDEX_MIN,
buildSlotEditPayload,
buildSettingsPayload,
isSlotDraftDirty,
type EmbedSettingsDraft
} from '@/utils/system/embedSlots'
@@ -120,6 +197,14 @@
const nameError = ref('')
const submitting = ref(false)
// 脏态:编辑态与已保存条目比对 / 新建态与缺省比对 →「未保存」标记
const dirty = computed(() => isSlotDraftDirty(props.embedSlot, draftName.value, draft))
// 预览相册标题:编辑已绑定相册用真实标题,否则词条占位(纯展示)
const previewAlbumTitle = computed(
() => props.embedSlot?.albumTitle ?? t('embedSlotsManage.preview.mockAlbumTitle')
)
// 过滤/下拉选项(computed:语言切换时标签随 i18n 响应式刷新)
const positionOptions = computed(() => [
{ value: 'left' as const, label: t('embedSlotsManage.form.positionLeft') },
@@ -183,3 +268,22 @@
}
}
</script>
<style scoped>
.slot-form-dialog__form {
flex: 1;
min-width: 0;
}
.slot-form-dialog__preview {
flex: none;
width: 470px;
display: flex;
flex-direction: column;
gap: 12px;
padding-left: 20px;
border-left: 1px solid var(--el-border-color-lighter);
/* 高配置密度对话框:预览列内部滚动,对话框整体不超出视口 */
max-height: calc(96vh - 200px);
overflow-y: auto;
}
</style>