feat(admin): UI-01 embed slot config form and live preview
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
/** 展示位条目(管理视角) */
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user