diff --git a/apps/admin/e2e/embed-slot-config.spec.ts b/apps/admin/e2e/embed-slot-config.spec.ts new file mode 100644 index 0000000..e25b4a4 --- /dev/null +++ b/apps/admin/e2e/embed-slot-config.spec.ts @@ -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 { + 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 { + 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( + `` + ) + 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() + }) +}) diff --git a/apps/admin/src/locales/langs/en.json b/apps/admin/src/locales/langs/en.json index 03b63c6..dea937b 100644 --- a/apps/admin/src/locales/langs/en.json +++ b/apps/admin/src/locales/langs/en.json @@ -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)" diff --git a/apps/admin/src/locales/langs/zh.json b/apps/admin/src/locales/langs/zh.json index 7d236ee..db4689e 100644 --- a/apps/admin/src/locales/langs/zh.json +++ b/apps/admin/src/locales/langs/zh.json @@ -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)" diff --git a/apps/admin/src/types/api/embedSlot.d.ts b/apps/admin/src/types/api/embedSlot.d.ts index 2a8180a..5787937 100644 --- a/apps/admin/src/types/api/embedSlot.d.ts +++ b/apps/admin/src/types/api/embedSlot.d.ts @@ -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 } /** 展示位条目(管理视角) */ diff --git a/apps/admin/src/utils/system/embedSlots.spec.ts b/apps/admin/src/utils/system/embedSlots.spec.ts index cd6f72b..dcd8419 100644 --- a/apps/admin/src/utils/system/embedSlots.spec.ts +++ b/apps/admin/src/utils/system/embedSlots.spec.ts @@ -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( + '' + ) + expect(buildEmbedSnippet('https://view.example.com', 'tok', null)).toBe( + '' + ) + }) + + 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( + '' + ) + }) + + 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') diff --git a/apps/admin/src/utils/system/embedSlots.ts b/apps/admin/src/utils/system/embedSlots.ts index 0b3e110..97e3b5f 100644 --- a/apps/admin/src/utils/system/embedSlots.ts +++ b/apps/admin/src/utils/system/embedSlots.ts @@ -1,14 +1,21 @@ /** - * 展示位管理页纯逻辑(无组件依赖,vitest 直接覆盖;P4b Task 5) + * 展示位管理页纯逻辑(无组件依赖,vitest 直接覆盖;P4b Task 5,UI-01 Task 7 扩展) * * - 嵌入代码:`` +/** 部署基址 + 展示位令牌 → 嵌入代码(宿主页粘贴即用;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 `` +} + +/** settings 字段 → loader data-* 属性名(dataset 驼峰 ↔ HTML 烤串) */ +const DATA_ATTR_NAMES: Partial> = { + 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 { + const attrs: Record = {} + 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 { + 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' diff --git a/apps/admin/src/views/system/embed-slots/EmbedCodeDialog.vue b/apps/admin/src/views/system/embed-slots/EmbedCodeDialog.vue index a11c95b..4a0c385 100644 --- a/apps/admin/src/views/system/embed-slots/EmbedCodeDialog.vue +++ b/apps/admin/src/views/system/embed-slots/EmbedCodeDialog.vue @@ -1,6 +1,8 @@ - +