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:
浅念
2026-09-20 23:25:59 +08:00
parent 8f2c21d25a
commit 0238f505c4
17 changed files with 1452 additions and 8 deletions
+65 -1
View File
@@ -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}"`)
}