// 展示位管理 e2e(P4b Task 5,真实后端:cn 8080 + ov 8081 + admin dev 5176 // 由 playwright.config webServer 拉起或复用)。旅程:新建(设置表单)→ 行 // 回显(未绑定/v1)→ 绑定相册(搜索选中 → A22 确认文案 → v2 + 绑定回显)→ // 嵌入代码卡(token + /embed/v1/loader.js 引用)→ ov 公开 feed 返回相册照片 // (seedFixture gov-photos 直种 cn + sync-flush 经 bootstrap/真实发送管线投影 // photos_ov)→ 换绑另一相册(configVersion+1,feed 切换新相册照片——A22 换绑 // 不改官网)→ 停用(feed 404)。选择器一律 data-test;文案断言仅用稳定短语。 import { expect, test, type Page } from '@playwright/test' import { adminCredentials, loginAsAdmin, ovBaseUrl, seedFixture } 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 V2_SLOT_NAME = `E2E展示位v2 ${SUFFIX}` const ALBUM_A_TITLE = `E2E嵌入相册A ${SUFFIX}` const ALBUM_B_TITLE = `E2E嵌入相册B ${SUFFIX}` const ALBUM_C_TITLE = `E2E嵌入相册C ${SUFFIX}` interface SeededAlbum { id: string title: string photoCount: number } /** API 建相册(zh 标题)+ gov-photos 直种已发布照片(含 thumb 变体行—— * ov 公开 feed photos 的必要形状)+ bootstrap 投影到 ov 副本。 */ async function seedOvVisibleAlbum( page: Page, title: string, photoCount: number ): Promise { const { account, password } = adminCredentials() const login = await page.request.post('/v1/auth/password', { data: { account, password } }) expect(login.ok()).toBeTruthy() const sessionToken = ((await login.json()) as { sessionToken: string }).sessionToken const created = await page.request.post('/v1/albums', { headers: { Authorization: `Bearer ${sessionToken}` }, data: { defaultLocale: 'zh-CN', startAt: '2026-10-01T10:00:00.000Z', translations: [{ locale: 'zh-CN', title }] } }) expect(created.ok()).toBeTruthy() const { id } = (await created.json()) as { id: string } seedFixture('gov-photos', id, account, String(photoCount)) // bootstrap(当前态快照入 outbox)+ 真实发送管线排空 → albums_ov/photos_ov 就位 seedFixture('sync-flush', id) return { id, title, photoCount } } /** 列表内定位展示位行 */ function slotRow(page: Page, name: string) { return page.getByTestId('embed-slots-table').locator('tr', { hasText: name }) } /** ov 公开嵌入 feed 形状(断言关心的子集;settings 为 UI-04 v2 投影断言) */ interface FeedBody { album: { publicToken: string; title: string } photos: Array<{ photoId: string }> settings: { displayMode: string; scrollSpeed: number; reactionsEnabled: boolean } } /** ov 公开嵌入 feed(观众侧权威断言;断言侧直接读 ov 副本端点) */ async function ovFeed( page: Page, slotToken: string ): Promise<{ status: number; body: FeedBody | null }> { const res = await page.request.get(`${ovBaseUrl()}/v1/public/embed-slots/${slotToken}`) return { status: res.status(), body: res.ok() ? ((await res.json()) as FeedBody) : null } } test.describe.serial('embed slot management (P4b Task 5)', () => { let slotToken = '' let configVersion = 1 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() await page.getByTestId('slot-form-name').fill(SLOT_NAME) // UI-04:新建默认「照片上滚」(v2)——本用例回归旧版轮播路径,先显式切换 await page.getByTestId('slot-form-mode-carousel').click() // 设置表单:位置/间隔(其余取缺省值;编辑载荷映射由 vitest 覆盖) await page.getByTestId('slot-form-position').click() await page.getByTestId('slot-form-position-left').click() await page.getByTestId('slot-form-interval').locator('input').fill('8000') await page.getByTestId('slot-form-submit').click() await expect(page.getByTestId('slot-form-dialog')).toBeHidden() const row = slotRow(page, SLOT_NAME) await expect(row).toBeVisible() await expect(row.getByTestId('slot-album-unbound')).toBeVisible() await expect(row.getByTestId('slot-config-version')).toHaveText('v1') // 嵌入代码卡:token + loader 引用(EMBED_ORIGIN 缺省本机 viewer dev) await row.getByTestId('slot-code-btn').click() await expect(page.getByTestId('embed-code-dialog')).toBeVisible() slotToken = ((await page.getByTestId('embed-code-token').textContent()) ?? '').trim() expect(slotToken).toMatch(/^[0-9a-z]{20,32}$/) await expect(page.getByTestId('embed-code-snippet')).toContainText( `http://127.0.0.1:5175/embed/v1/loader.js` ) await expect(page.getByTestId('embed-code-snippet')).toContainText(`data-slot="${slotToken}"`) await page.getByTestId('embed-code-close').click() await expect(page.getByTestId('embed-code-dialog')).toBeHidden() }) test('绑定相册:搜索选中 → A22 确认文案 → v2 + ov 公开 feed 返回相册照片', async ({ page }) => { const albumA = await seedOvVisibleAlbum(page, ALBUM_A_TITLE, 3) await loginAsAdmin(page) await page.goto('/#/system/embed-slots') const row = slotRow(page, SLOT_NAME) await expect(row).toBeVisible() await row.getByTestId('slot-bind-btn').click() await expect(page.getByTestId('slot-bind-dialog')).toBeVisible() await expect(page.getByTestId('slot-bind-current')).toHaveText('未绑定') const select = page.getByTestId('slot-bind-select') await select.click() await select.locator('input').fill(SUFFIX) await page.getByTestId(`slot-bind-option-${albumA.id}`).click() // 提交 → 确认框携带 A22 文案(configVersion 将递增,官网无需改动) await page.getByTestId('slot-bind-submit').click() const confirmBox = page.locator('.el-message-box') await expect(confirmBox).toContainText('configVersion 将递增') await expect(confirmBox).toContainText('官网无需改动') await page.getByRole('button', { name: '确定' }).click() await expect(page.getByTestId('slot-bind-dialog')).toBeHidden() await expect( page.locator('.el-message').filter({ hasText: 'configVersion 已递增至 2' }) ).toBeVisible() await expect(row.getByTestId('slot-album')).toContainText(ALBUM_A_TITLE) await expect(row.getByTestId('slot-config-version')).toHaveText('v2') configVersion = 2 // slot.updated 事件不随 bootstrap 走:单独排空 → ov 副本就位 seedFixture('sync-flush') const feed = await ovFeed(page, slotToken) expect(feed.status).toBe(200) expect(feed.body.album.publicToken).toBeTruthy() expect(feed.body.photos).toHaveLength(albumA.photoCount) }) test('换绑相册:configVersion+1 → ov feed 切换新相册照片(A22 换绑不改官网)', async ({ page }) => { const albumB = await seedOvVisibleAlbum(page, ALBUM_B_TITLE, 2) await loginAsAdmin(page) await page.goto('/#/system/embed-slots') const row = slotRow(page, SLOT_NAME) await expect(row.getByTestId('slot-config-version')).toHaveText(`v${configVersion}`) await row.getByTestId('slot-bind-btn').click() await expect(page.getByTestId('slot-bind-dialog')).toBeVisible() await expect(page.getByTestId('slot-bind-current')).toContainText(ALBUM_A_TITLE) const select = page.getByTestId('slot-bind-select') await select.click() await select.locator('input').fill(SUFFIX) await page.getByTestId(`slot-bind-option-${albumB.id}`).click() await page.getByTestId('slot-bind-submit').click() await expect(page.locator('.el-message-box')).toContainText('configVersion 将递增') await page.getByRole('button', { name: '确定' }).click() await expect(page.getByTestId('slot-bind-dialog')).toBeHidden() configVersion += 1 await expect(row.getByTestId('slot-config-version')).toHaveText(`v${configVersion}`) await expect(row.getByTestId('slot-album')).toContainText(ALBUM_B_TITLE) seedFixture('sync-flush') // 同一 token(嵌入代码不变):feed 反映新相册照片集 const feed = await ovFeed(page, slotToken) expect(feed.status).toBe(200) expect(feed.body.photos).toHaveLength(albumB.photoCount) }) test('停用:开关 → configVersion 再增 → ov feed 404(软下线)', async ({ page }) => { await loginAsAdmin(page) await page.goto('/#/system/embed-slots') const row = slotRow(page, SLOT_NAME) await expect(row.getByTestId('slot-enabled-switch')).toHaveClass(/is-checked/) await row.getByTestId('slot-enabled-switch').click() await expect( page.locator('.el-message').filter({ hasText: 'configVersion 已递增至' }) ).toBeVisible() configVersion += 1 await expect(row.getByTestId('slot-config-version')).toHaveText(`v${configVersion}`) await expect(row.getByTestId('slot-enabled-switch')).not.toHaveClass(/is-checked/) seedFixture('sync-flush') expect((await ovFeed(page, slotToken)).status).toBe(404) }) test('UI-04 v2 新建:照片上滚表单/真实组件预览/模拟按钮 → v2 代码卡 → ov settings 投影', async ({ page }) => { const albumC = await seedOvVisibleAlbum(page, ALBUM_C_TITLE, 2) 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() await page.getByTestId('slot-form-name').fill(V2_SLOT_NAME) // 默认即「照片上滚」:v2 字段组可见、v1 布局字段隐藏(§7.2 不偷偷生效)。 await expect(page.getByTestId('slot-form-mode-stream')).toBeVisible() await expect(page.getByTestId('slot-form-fixed-size')).toContainText('195') await expect(page.getByTestId('slot-form-speed')).toBeVisible() await expect(page.getByTestId('slot-form-reactions')).toBeVisible() await expect(page.getByTestId('slot-form-interval')).toHaveCount(0) await expect(page.getByTestId('slot-form-size')).toHaveCount(0) // 真实组件预览(§8.2):iframe 挂载 + 模拟点赞按钮在工具区;预览帧内为 // 真实 EmbedStream(.stream-viewport)且模拟点赞出心形(WYSIWYG 证据)。 await expect(page.getByTestId('embed-preview-v2-iframe')).toBeVisible() await expect(page.getByTestId('embed-preview-sim-like')).toBeVisible() const previewFrame = page.frames().find((f) => f.url().includes('/embed-preview')) expect(previewFrame).toBeDefined() await expect(previewFrame!.locator('.stream-viewport')).toHaveCount(1) await page.getByTestId('embed-preview-sim-like').click() await expect(previewFrame!.locator('.heart').first()).toBeVisible({ timeout: 5_000 }) // 速度调整(24 px/s):滑轨点击定位((24-8)/(32-8)*180px ≈ 120)后断言值。 const runway = page.getByTestId('slot-form-speed').locator('.el-slider__runway') await runway.click({ position: { x: 120, y: 4 } }) await expect(page.getByTestId('slot-form-speed-value')).toContainText('24') await page.getByTestId('slot-form-submit').click() await expect(page.getByTestId('slot-form-dialog')).toBeHidden() const row = slotRow(page, V2_SLOT_NAME) await expect(row).toBeVisible() // 嵌入代码卡:v2 入口(§8.2 生成的接入代码使用 v2)。 await row.getByTestId('slot-code-btn').click() await expect(page.getByTestId('embed-code-dialog')).toBeVisible() const v2Token = ((await page.getByTestId('embed-code-token').textContent()) ?? '').trim() expect(v2Token).toMatch(/^[0-9a-z]{20,32}$/) await expect(page.getByTestId('embed-code-snippet')).toContainText( `http://127.0.0.1:5175/embed/v2/loader.js` ) await expect(page.getByTestId('embed-code-snippet')).toContainText(`data-slot="${v2Token}"`) await page.getByTestId('embed-code-close').click() // 绑定相册 + 排空 → ov 公开 feed settings 携带 v2 投影(保存与真实效果一致)。 await row.getByTestId('slot-bind-btn').click() await page.getByTestId('slot-bind-select').click() await page.getByTestId('slot-bind-select').locator('input').fill(SUFFIX) await page.getByTestId(`slot-bind-option-${albumC.id}`).click() await page.getByTestId('slot-bind-submit').click() await page.locator('.el-message-box').getByRole('button', { name: '确定' }).click() await expect(page.getByTestId('slot-bind-dialog')).toBeHidden() seedFixture('sync-flush') const feed = await ovFeed(page, v2Token) expect(feed.status).toBe(200) expect(feed.body.settings.displayMode).toBe('vertical-stream') expect(feed.body.settings.scrollSpeed).toBe(24) expect(feed.body.settings.reactionsEnabled).toBe(true) }) })