feat(admin): embed slot management with code snippet
P4b Task 5:/system/embed-slots 管理页(Task 8/9 页面模式:useCursorList
游标累积 + 对话框治理)。列表(名称/绑定相册/启停/configVersion/更新时间,
启停开关行内 PATCH → configVersion+1 toast 提示)+ 新建/编辑对话框
(设置表单位置/尺寸/间隔/语言/默认收起,intervalMs 契约区间夹取;编辑按
整体替换语义提交完整 settings)+ 绑定相册对话框(相册标题/ID 搜索选中 →
确认框携带 A22 文案「configVersion 将递增,官网无需改动」;清空即解绑)+
嵌入代码卡({EMBED_ORIGIN}/embed/v1/loader.js + data-slot 令牌,一键复制;
EMBED_ORIGIN=VITE_EMBED_ORIGIN 缺省本机 viewer dev,.env.example 登记)。
- API 层 src/api/embedSlots.ts + types/api/embedSlot.d.ts(契约镜像)
- vitest 9 例:表单→载荷(缺省+夹取)、编辑载荷收敛、snippet 拼接、
环境基址兜底、绑定变更分类、zh/en 键一致 + A22 短语断言
- Playwright e2e(真实 cn+ov 后端):新建→绑定(gov-photos 直种 +
seed-fixture 新增 sync-flush 模式经 bootstrap/真实发送管线投影 photos_ov)
→嵌入代码卡含 token→ov 公开 feed 返回相册照片→换绑 configVersion+1 +
feed 切换(A22)→停用 feed 404;admin playwright 配置补 ov webServer
- i18n zh/en(embedSlotsManage.* + menus.system.embedSlots)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
// 展示位管理 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 ALBUM_A_TITLE = `E2E嵌入相册A ${SUFFIX}`
|
||||
const ALBUM_B_TITLE = `E2E嵌入相册B ${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<SeededAlbum> {
|
||||
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 形状(断言关心的子集) */
|
||||
interface FeedBody {
|
||||
album: { publicToken: string; title: string }
|
||||
photos: Array<{ photoId: string }>
|
||||
}
|
||||
|
||||
/** 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)
|
||||
// 设置表单:位置/间隔(其余取缺省值;编辑载荷映射由 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)
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,11 @@ export function adminCredentials(): AdminCredentials {
|
||||
return { account, password }
|
||||
}
|
||||
|
||||
/** ov 入口基址(公开嵌入 feed 断言;缺省同根 .env.example PORT_OV=8081) */
|
||||
export function ovBaseUrl(): string {
|
||||
return (rootEnv().get('OV_API_URL') ?? 'http://127.0.0.1:8081').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/** UI 登录(真实后端):填账号密码 → 落到 /albums/manage */
|
||||
export async function loginAsAdmin(page: Page): Promise<void> {
|
||||
const { account, password } = adminCredentials()
|
||||
|
||||
@@ -23,13 +23,25 @@
|
||||
// 常驻 worker,同 services/
|
||||
// api/test/watermark.e2e-
|
||||
// spec.ts 直驱模式)
|
||||
// sync-flush [albumId] cn→ov 种子同步(P4b Task 5
|
||||
// 展示位 e2e):albumId 提供
|
||||
// 时先打 cn bootstrap(当前
|
||||
// 态快照写回 outbox,直种
|
||||
// photos 行入公开 feed 的
|
||||
// 必经通道),再经 services/
|
||||
// sync dist 的
|
||||
// deliverPendingOnce 排空
|
||||
// outbox(真实 HMAC 发送
|
||||
// 管线;slot.updated 事件不
|
||||
// 随 bootstrap 走,须此排空;
|
||||
// albumId 省略或 '-' = 仅排空)
|
||||
// 幂等:账号存在即跳过;照片按确定性 photoId/objectKey upsert(同相册重复种
|
||||
// 不重复建行);watermark-run 对已 done 的任务返回 skipped 视为成功。
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const path = require('node:path')
|
||||
const { execSync } = require('node:child_process')
|
||||
const { existsSync, statSync } = require('node:fs')
|
||||
const { createHash } = require('node:crypto')
|
||||
const { createHash, createHmac } = require('node:crypto')
|
||||
|
||||
const dbCn = process.env.PHOTO_DB_CN
|
||||
const databaseUrl = process.env.DATABASE_URL_CN
|
||||
@@ -303,6 +315,56 @@ async function runWatermark(albumId) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sync-flush:cn→ov 种子同步(apps/viewer e2e helpers flushSync/flushOutbox
|
||||
* 的 execSync 版——apps/admin 不依赖 @photo/db-cn/@photo/contracts,经
|
||||
* services/{api,sync} 的 node_modules/dist 借道)。bootstrap 为 cn 内部
|
||||
* HMAC 端点(签名 = HMAC-SHA256(key, rawBody).hex,同 contracts
|
||||
* signSyncBody);发送器 dist 过期则先 tsc 构建(同 watermark-run 口径)。
|
||||
*/
|
||||
async function syncFlush(albumId) {
|
||||
const internalKey = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me'
|
||||
const cnBase = (process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '')
|
||||
|
||||
if (albumId && albumId !== '-') {
|
||||
const rawBody = JSON.stringify({ albumId })
|
||||
const bootstrap = await fetch(`${cnBase}/v1/internal/sync/bootstrap`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-sync-timestamp': String(Math.floor(Date.now() / 1000)),
|
||||
'x-sync-signature': createHmac('sha256', internalKey).update(rawBody).digest('hex')
|
||||
},
|
||||
body: rawBody
|
||||
})
|
||||
if (!bootstrap.ok) {
|
||||
throw new Error(
|
||||
`seed-fixture: bootstrap 失败(${bootstrap.status}):${(await bootstrap.text()).slice(0, 300)}`
|
||||
)
|
||||
}
|
||||
console.log(`seed-fixture: bootstrap ok for album ${albumId}`)
|
||||
}
|
||||
|
||||
const syncRoot = path.resolve(__dirname, '..', '..', '..', 'services', 'sync')
|
||||
const distSender = path.join(syncRoot, 'dist', 'sender.js')
|
||||
const srcSender = path.join(syncRoot, 'src', 'sender.ts')
|
||||
const stale =
|
||||
!existsSync(distSender) ||
|
||||
(existsSync(srcSender) && statSync(srcSender).mtimeMs > statSync(distSender).mtimeMs)
|
||||
if (stale) {
|
||||
console.log('seed-fixture: building services/sync dist (stale or missing)...')
|
||||
execSync('npx tsc -p tsconfig.build.json', { cwd: syncRoot, stdio: 'inherit' })
|
||||
}
|
||||
|
||||
const { deliverPendingOnce } = require(distSender)
|
||||
await deliverPendingOnce({
|
||||
prisma,
|
||||
ovApiUrl: process.env.OV_API_URL ?? 'http://127.0.0.1:8081',
|
||||
internalKey
|
||||
})
|
||||
console.log('seed-fixture: outbox drained to ov')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [mode, argA, argB, argC, argD] = process.argv.slice(2)
|
||||
if (mode === 'staff') {
|
||||
@@ -315,6 +377,8 @@ async function main() {
|
||||
await seedWmPhotos(argA, argB, argC)
|
||||
} else if (mode === 'watermark-run') {
|
||||
await runWatermark(argA)
|
||||
} else if (mode === 'sync-flush') {
|
||||
await syncFlush(argA)
|
||||
} else {
|
||||
throw new Error(`seed-fixture: unknown mode "${mode}"`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user