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
+25
View File
@@ -0,0 +1,25 @@
/**
* 展示位管理接口(对齐 @photo/contracts tags [embed-slots]:listEmbedSlots /
* createEmbedSlot / updateEmbedSlot,P4b Task 5;仅公司 admin 可用)
*
* - 列表为 createdAt+ID 稳定倒序键集游标
* - PATCH 为部分更新:name/enabled/settings/albumId 任一变更均递增
* configVersion 并触发 slot-state 同步(嵌入端配置版本变化即整体重置)
* - settings 整体替换(提供时字段齐全);albumId null=解绑(公开端 404)
*/
import request from '@/utils/http'
/** 展示位列表(游标分页) */
export function fetchEmbedSlots(params: { cursor?: string; limit?: number }) {
return request.get<Api.EmbedSlot.ListResponse>({ url: '/v1/admin/embed-slots', params })
}
/** 创建展示位(publicToken 服务端生成;初始未绑定、configVersion=1) */
export function fetchCreateEmbedSlot(data: Api.EmbedSlot.CreatePayload) {
return request.post<Api.EmbedSlot.Item>({ url: '/v1/admin/embed-slots', data })
}
/** 更新展示位(部分更新;任一管理面变更递增 configVersion) */
export function fetchUpdateEmbedSlot(id: string, data: Api.EmbedSlot.UpdatePayload) {
return request.patch<Api.EmbedSlot.Item>({ url: `/v1/admin/embed-slots/${id}`, data })
}
+2
View File
@@ -4,6 +4,8 @@
interface ImportMetaEnv {
/** viewer 部署基址(分享链接卡;缺省兜底 http://127.0.0.1:5175) */
readonly VITE_VIEWER_BASE?: string
/** 嵌入组件部署基址(展示位嵌入代码卡;缺省兜底同 viewer dev 5175) */
readonly VITE_EMBED_ORIGIN?: string
}
interface ImportMeta {
+83 -1
View File
@@ -234,7 +234,8 @@
"title": "Accounts & Sync",
"users": "Account Management",
"userSync": "User Sync",
"usage": "Usage"
"usage": "Usage",
"embedSlots": "Embed Slots"
},
"albums": {
"title": "Albums",
@@ -700,6 +701,87 @@
"colCreatedAt": "Recorded At"
}
},
"embedSlotsManage": {
"list": {
"title": "Embed Slots (floating carousel mount points)",
"create": "New Slot",
"loadMore": "Load More",
"noMore": "All loaded",
"empty": "No embed slots yet; click \"New Slot\" to create one",
"loadFailed": "Failed to load embed slots",
"retry": "Retry",
"colName": "Name",
"colAlbum": "Bound Album",
"colEnabled": "Enabled",
"colConfigVersion": "Config Version",
"colUpdatedAt": "Updated At",
"colActions": "Actions",
"unbound": "Unbound",
"actionEdit": "Edit",
"actionBind": "Bind Album",
"actionCode": "Embed Code"
},
"form": {
"nameLabel": "Name",
"namePlaceholder": "e.g. Homepage floating slot",
"nameRequired": "Please enter a slot name",
"submit": "Save",
"positionLabel": "Position",
"positionLeft": "Left",
"positionRight": "Right",
"sizeLabel": "Size",
"sizeSm": "Small (narrow-screen tier)",
"sizeMd": "Standard",
"intervalLabel": "Interval",
"intervalUnit": "ms (3000-30000)",
"langLabel": "Language",
"langAuto": "Follow album",
"langZh": "Chinese",
"langEn": "English",
"collapsedLabel": "Collapsed by default",
"collapsedHint": "Show a small handle first; click to expand"
},
"create": {
"title": "New Embed Slot",
"created": "Slot created (embed code available via \"Embed Code\")"
},
"edit": {
"title": "Edit Embed Slot",
"noChanges": "No changes",
"saved": "Saved (configVersion increased to {version})"
},
"bind": {
"title": "Bind Album: {name}",
"currentLabel": "Current",
"targetLabel": "Target album",
"searchPlaceholder": "Search by album title or ID",
"searching": "Searching…",
"noMatch": "No matching album",
"clearHint": "Clear the selection and submit to unbind (public feed returns 404)",
"versionHint": "Binding changes only increase configVersion; no change is needed on the host site (the widget only knows the token)",
"confirmTitle": "Confirm binding change",
"confirmBind": "After binding, configVersion will increase and no change is needed on the host site; the widget will show the album's recent photos.",
"confirmRebind": "After rebinding, configVersion will increase and no change is needed on the host site; the widget will switch to the new album's recent photos.",
"confirmUnbind": "After unbinding, configVersion will increase and no change is needed on the host site; the public feed will go offline immediately (404).",
"actionBind": "Bind",
"actionRebind": "Rebind",
"actionUnbind": "Unbind",
"saved": "Binding updated (configVersion increased to {version})"
},
"code": {
"title": "Embed Code: {name}",
"tokenLabel": "Slot token (data-slot)",
"snippetLabel": "Embed code (paste into the host site page)",
"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.",
"close": "Close"
},
"toast": {
"enabledOn": "Enabled (configVersion increased to {version})",
"enabledOff": "Disabled (configVersion increased to {version}; public feed returns 404)"
}
},
"apiMsg": {
"AUTH_INVALID_CREDENTIALS": "Invalid account or password",
"AUTH_RATE_LIMITED": "Too many failed login attempts; try again later",
+83 -1
View File
@@ -234,7 +234,8 @@
"title": "账号与同步",
"users": "账号管理",
"userSync": "用户同步",
"usage": "用量统计"
"usage": "用量统计",
"embedSlots": "展示位管理"
},
"albums": {
"title": "相册管理",
@@ -700,6 +701,87 @@
"colCreatedAt": "记录时间"
}
},
"embedSlotsManage": {
"list": {
"title": "展示位管理(悬浮轮播组件挂载点)",
"create": "新建展示位",
"loadMore": "加载更多",
"noMore": "已全部加载",
"empty": "暂无展示位,点击「新建展示位」创建",
"loadFailed": "展示位列表加载失败",
"retry": "重试",
"colName": "名称",
"colAlbum": "绑定相册",
"colEnabled": "启用",
"colConfigVersion": "配置版本",
"colUpdatedAt": "更新时间",
"colActions": "操作",
"unbound": "未绑定",
"actionEdit": "编辑",
"actionBind": "绑定相册",
"actionCode": "嵌入代码"
},
"form": {
"nameLabel": "名称",
"namePlaceholder": "如:官网首页悬浮位",
"nameRequired": "请输入展示位名称",
"submit": "保存",
"positionLabel": "悬浮位置",
"positionLeft": "左侧",
"positionRight": "右侧",
"sizeLabel": "组件尺寸",
"sizeSm": "小(窄屏降级档)",
"sizeMd": "标准",
"intervalLabel": "轮播间隔",
"intervalUnit": "毫秒(3000-30000)",
"langLabel": "内容语言",
"langAuto": "跟随相册",
"langZh": "中文",
"langEn": "英文",
"collapsedLabel": "默认收起",
"collapsedHint": "初始仅展示小把手,点击展开"
},
"create": {
"title": "新建展示位",
"created": "展示位已创建(嵌入代码可在「嵌入代码」查看)"
},
"edit": {
"title": "编辑展示位",
"noChanges": "未修改任何内容",
"saved": "已保存(configVersion 已递增至 {version})"
},
"bind": {
"title": "绑定相册:{name}",
"currentLabel": "当前绑定",
"targetLabel": "目标相册",
"searchPlaceholder": "输入相册标题或 ID 搜索",
"searching": "搜索中…",
"noMatch": "无匹配相册",
"clearHint": "清空选择并提交即解绑(公开端随即 404)",
"versionHint": "绑定变更仅递增 configVersion,已嵌入的官网无需改动(组件只认令牌)",
"confirmTitle": "确认绑定变更",
"confirmBind": "绑定相册后 configVersion 将递增,官网无需改动;组件将展示该相册的近期照片。",
"confirmRebind": "换绑相册后 configVersion 将递增,官网无需改动;组件将切换展示新相册的近期照片。",
"confirmUnbind": "解绑后 configVersion 将递增,官网无需改动;组件公开端将立即下线(404)。",
"actionBind": "绑定",
"actionRebind": "换绑",
"actionUnbind": "解绑",
"saved": "绑定已更新(configVersion 已递增至 {version})"
},
"code": {
"title": "嵌入代码:{name}",
"tokenLabel": "展示位令牌(data-slot)",
"snippetLabel": "嵌入代码(粘贴到官网宿主页)",
"copy": "复制",
"copied": "已复制嵌入代码",
"hint": "将代码粘贴到官网页面即可挂载悬浮轮播;后续换绑相册、启停均无需改动该代码。",
"close": "关闭"
},
"toast": {
"enabledOn": "已启用(configVersion 已递增至 {version})",
"enabledOff": "已停用(configVersion 已递增至 {version},公开端随即 404)"
}
},
"apiMsg": {
"AUTH_INVALID_CREDENTIALS": "账号或密码错误",
"AUTH_RATE_LIMITED": "登录失败次数过多,请稍后再试",
+12 -1
View File
@@ -1,5 +1,6 @@
/**
* 账号治理路由(P4a Task 8:账号管理 + 用户同步;Task 9:用量统计)
* 账号治理路由(P4a Task 8:账号管理 + 用户同步;Task 9:用量统计;
* P4b Task 5:展示位管理)
*/
import { AppRouteRecord } from '@/types/router'
@@ -41,6 +42,16 @@ export const systemRoutes: AppRouteRecord = {
icon: 'ri:pie-chart-line',
keepAlive: false
}
},
{
path: 'embed-slots',
name: 'SystemEmbedSlots',
component: '/system/embed-slots',
meta: {
title: 'menus.system.embedSlots',
icon: 'ri:picture-carousel-line',
keepAlive: false
}
}
]
}
+78
View File
@@ -0,0 +1,78 @@
/**
* 展示位域接口类型(对齐 @photo/contracts openapi.yaml tags [embed-slots]:
* listEmbedSlots / createEmbedSlot / updateEmbedSlot,P4b Task 5 后台侧)
*
* 与 api.d.ts 同用全局 Api 命名空间(declare namespace 自动合并)。
* 契约要点:
* - 列表为 createdAt+ID 稳定倒序键集游标(CursorPageMeta)
* - settings 为整体提交语义:创建可整体缺省(各字段按缺省值落库),
* 提供时字段齐全;更新为整体替换(未单独提交则保持不变)
* - albumId 为 readOnly:绑定/解绑经 PATCH albumId(null=解绑,公开端随即
* 404);任一管理面变更递增 configVersion(嵌入端据此整体重置渲染)
* - 无删除端点:enabled=false 即下线(软停用)
*
* @module types/api/embedSlot
*/
declare namespace Api {
/** 展示位域 */
namespace EmbedSlot {
/** 悬浮位置 */
type Position = 'left' | 'right'
/** 组件尺寸(sm=窄屏降级档) */
type Size = 'sm' | 'md'
/** 内容语言(auto=按相册 defaultLocale) */
type Lang = 'auto' | 'zh-CN' | 'en'
/** 展示设置(响应中字段恒齐全) */
interface Settings {
position: Position
size: Size
/** 轮播切换间隔毫秒(3000..30000) */
intervalMs: number
lang: Lang
/** 初始是否收起(收起后仅展示小把手) */
collapsedDefault: boolean
}
/** 展示位条目(管理视角) */
interface Item {
id: string
/** 公开令牌(嵌入代码 data-slot 引用,不可枚举) */
publicToken: string
name: string
enabled: boolean
/** 绑定相册 ID;未绑定为 null(readOnly,经 PATCH albumId 变更) */
albumId: string | null
/** 绑定相册标题(按相册 defaultLocale 解析);未绑定为 null */
albumTitle: string | null
/** 配置版本(任一管理面变更递增;嵌入端整体重置渲染依据) */
configVersion: number
settings: Settings
updatedAt: string
}
/** 展示位列表响应(游标分页) */
interface ListResponse {
items: Item[]
nextCursor: string | null
hasMore: boolean
}
/** 创建载荷(settings 可整体缺省 → 各字段按缺省值落库) */
interface CreatePayload {
name: string
settings?: Settings
}
/** 更新载荷(部分更新;settings 整体替换;albumId null=解绑;空体 400) */
interface UpdatePayload {
name?: string
enabled?: boolean
settings?: Settings
albumId?: string | null
}
}
}
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import zhMessages from '@/locales/langs/zh.json'
import enMessages from '@/locales/langs/en.json'
import {
bindChangeKind,
buildEmbedSnippet,
buildSettingsPayload,
buildSlotEditPayload,
clampIntervalMs,
DEFAULT_EMBED_SETTINGS,
EMBED_ORIGIN_FALLBACK,
embedOriginFromEnv,
type EmbedSettingsDraft
} from './embedSlots'
/** 契约形状的既有条目(编辑载荷断言基准) */
const CURRENT_ITEM: Api.EmbedSlot.Item = {
id: 'slot-1',
publicToken: 'tok0000000000000000000',
name: '官网首页悬浮位',
enabled: true,
albumId: null,
albumTitle: null,
configVersion: 1,
settings: { ...DEFAULT_EMBED_SETTINGS },
updatedAt: '2026-09-20T10:00:00.000Z'
}
describe('buildSettingsPayload 表单 → settings 载荷', () => {
it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全)', () => {
expect(buildSettingsPayload({ ...DEFAULT_EMBED_SETTINGS })).toEqual({
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false
})
})
it('intervalMs 越界/非有限值 → 夹取到契约区间(键盘输入兜底)', () => {
expect(clampIntervalMs(1)).toBe(3000)
expect(clampIntervalMs(2999.6)).toBe(3000)
expect(clampIntervalMs(100000)).toBe(30000)
expect(clampIntervalMs(Number.NaN)).toBe(5000)
expect(clampIntervalMs(Number.POSITIVE_INFINITY)).toBe(5000)
const draft: EmbedSettingsDraft = { ...DEFAULT_EMBED_SETTINGS, intervalMs: 2000 }
expect(buildSettingsPayload(draft).intervalMs).toBe(3000)
})
})
describe('buildSlotEditPayload 编辑载荷收敛', () => {
it('name 与 settings 均未变化 → 空载荷(不制造服务端 no-op)', () => {
expect(
buildSlotEditPayload(CURRENT_ITEM, CURRENT_ITEM.name, { ...DEFAULT_EMBED_SETTINGS })
).toEqual({})
})
it('仅 name 变化 → 只送 name;settings 变化 → 送完整 settings(整体替换)', () => {
expect(
buildSlotEditPayload(CURRENT_ITEM, ' 活动页左侧位 ', { ...DEFAULT_EMBED_SETTINGS })
).toEqual({
name: '活动页左侧位'
})
const changed: EmbedSettingsDraft = {
...DEFAULT_EMBED_SETTINGS,
position: 'left',
size: 'sm',
intervalMs: 8000,
lang: 'zh-CN',
collapsedDefault: true
}
expect(buildSlotEditPayload(CURRENT_ITEM, CURRENT_ITEM.name, changed)).toEqual({
settings: changed
})
})
})
describe('buildEmbedSnippet 嵌入代码拼接', () => {
it('EMBED_ORIGIN + token → /embed/v1/loader.js 引用(尾斜杠容忍)', () => {
expect(buildEmbedSnippet('https://view.example.com', 'abc123def456ghi789jklmno')).toBe(
'<script src="https://view.example.com/embed/v1/loader.js" data-slot="abc123def456ghi789jklmno" defer></script>'
)
expect(buildEmbedSnippet('http://127.0.0.1:5175//', 'tok')).toBe(
'<script src="http://127.0.0.1:5175/embed/v1/loader.js" data-slot="tok" defer></script>'
)
})
it('环境基址:VITE_EMBED_ORIGIN 缺省/空白兜底本机 dev', () => {
expect(embedOriginFromEnv({})).toBe(EMBED_ORIGIN_FALLBACK)
expect(embedOriginFromEnv({ VITE_EMBED_ORIGIN: ' ' })).toBe(EMBED_ORIGIN_FALLBACK)
expect(embedOriginFromEnv({ VITE_EMBED_ORIGIN: ' https://cdn.example.com/ ' })).toBe(
'https://cdn.example.com'
)
})
})
describe('bindChangeKind 绑定变更分类', () => {
it('null→id 绑定 / id→id 换绑 / id→null 解绑 / 值不变 none', () => {
expect(bindChangeKind(null, 'album-1')).toBe('bind')
expect(bindChangeKind('album-1', 'album-2')).toBe('rebind')
expect(bindChangeKind('album-1', null)).toBe('unbind')
expect(bindChangeKind('album-1', 'album-1')).toBe('none')
expect(bindChangeKind(null, null)).toBe('none')
})
})
describe('绑定变更确认文案(A22:configVersion 将递增,官网无需改动)', () => {
const zh = zhMessages.embedSlotsManage as Record<string, unknown>
const en = enMessages.embedSlotsManage as Record<string, unknown>
const zhBind = zh.bind as Record<string, string>
const enBind = en.bind as Record<string, string>
it('zh/en 键集合一致(embedSlotsManage 子树)', () => {
const keysOf = (obj: Record<string, unknown>, prefix = ''): string[] =>
Object.entries(obj).flatMap(([key, value]) =>
typeof value === 'object' && value !== null
? keysOf(value as Record<string, unknown>, `${prefix}${key}.`)
: [`${prefix}${key}`]
)
expect(keysOf(en)).toEqual(keysOf(zh))
})
it('三类确认文案均携带 A22 短语(换绑不改官网:loader 只认 token)', () => {
for (const key of ['confirmBind', 'confirmRebind', 'confirmUnbind']) {
expect(zhBind[key]).toContain('configVersion 将递增')
expect(zhBind[key]).toContain('官网无需改动')
expect(enBind[key]).toContain('configVersion will increase')
expect(enBind[key]).toContain('no change is needed on the host site')
}
})
})
+94
View File
@@ -0,0 +1,94 @@
/**
* 展示位管理页纯逻辑(无组件依赖,vitest 直接覆盖;P4b Task 5)
*
* - 嵌入代码:`<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 之外的键盘输入兜底)
* - 编辑载荷收敛:只提交变更字段(name/settings 任一变化才送;settings
* 一旦送即为完整对象——契约整体替换语义);绑定变更(albumId)走独立
* 绑定对话框,不经编辑载荷
* - 绑定变更分类:bind(未绑定→绑定)/ rebind(换绑)/ unbind(解绑)/
* none(未变化不提交,不制造服务端 no-op)
*/
/** 嵌入组件部署基址(缺省兜底本机 viewer dev;loader 版本路径 /embed/v1/) */
export const EMBED_ORIGIN_FALLBACK = 'http://127.0.0.1:5175'
/** 契约 EmbedSlotSettings.intervalMs 边界 */
export const EMBED_INTERVAL_MIN_MS = 3000
export const EMBED_INTERVAL_MAX_MS = 30000
/** 设置表单草稿(与契约 Settings 同形;创建对话框初始值即契约缺省值) */
export type EmbedSettingsDraft = Api.EmbedSlot.Settings
/** 契约缺省设置(创建对话框初始值;服务端 settings 整体缺省时同值落库) */
export const DEFAULT_EMBED_SETTINGS: EmbedSettingsDraft = {
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false
}
/** 环境嵌入基址(VITE_EMBED_ORIGIN 缺省/空白兜底本机 dev;尾斜杠容忍) */
export function embedOriginFromEnv(env: { VITE_EMBED_ORIGIN?: string }): string {
const origin = env.VITE_EMBED_ORIGIN?.trim()
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>`
}
/** intervalMs 夹取到契约区间(非有限值回退缺省 5000;四舍五入整毫秒) */
export function clampIntervalMs(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.intervalMs
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) }
}
/** 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
)
}
/** 编辑提交载荷:name/settings 只送变更字段;settings 送则整体替换 */
export function buildSlotEditPayload(
current: Api.EmbedSlot.Item,
name: string,
draft: EmbedSettingsDraft
): Api.EmbedSlot.UpdatePayload {
const payload: Api.EmbedSlot.UpdatePayload = {}
const trimmed = name.trim()
if (trimmed !== '' && trimmed !== current.name) payload.name = trimmed
if (settingsChanged(current.settings, draft)) {
payload.settings = buildSettingsPayload(draft)
}
return payload
}
/** 绑定变更分类(确认文案与提交动作共用;none=不提交) */
export type BindChangeKind = 'bind' | 'rebind' | 'unbind' | 'none'
/** 当前绑定 → 下一步绑定 的变更分类(值不变 none,不制造服务端 no-op) */
export function bindChangeKind(
currentAlbumId: string | null,
nextAlbumId: string | null
): BindChangeKind {
if (nextAlbumId === currentAlbumId) return 'none'
if (nextAlbumId === null) return 'unbind'
return currentAlbumId === null ? 'bind' : 'rebind'
}
@@ -0,0 +1,77 @@
<!-- 嵌入代码卡(P4b Task 5):展示位公开令牌 + 宿主页可粘贴的 loader 引用
代码(EMBED_ORIGIN 来自 VITE_EMBED_ORIGIN,缺省本机 viewer dev)+ 一键
复制。代码只认 token:后续换绑/启停均无需改动宿主页(A22)。 -->
<template>
<ElDialog
v-model="visible"
:title="$t('embedSlotsManage.code.title', { name: embedSlot?.name ?? '' })"
width="620px"
destroy-on-close
data-test="embed-code-dialog"
>
<div class="flex flex-col gap-4">
<div>
<div class="text-gray-500 mb-1">{{ $t('embedSlotsManage.code.tokenLabel') }}</div>
<div class="flex items-center gap-2">
<span class="font-mono text-sm break-all" data-test="embed-code-token">
{{ embedSlot?.publicToken }}
</span>
</div>
</div>
<div>
<div class="text-gray-500 mb-1">{{ $t('embedSlotsManage.code.snippetLabel') }}</div>
<div class="flex items-start gap-2">
<code
class="flex-1 block bg-gray-50 dark:bg-gray-800 rounded p-3 font-mono text-xs break-all"
data-test="embed-code-snippet"
>
{{ snippet }}
</code>
<ElButton type="primary" plain data-test="embed-code-copy" @click="copySnippet">
{{ $t('embedSlotsManage.code.copy') }}
</ElButton>
</div>
</div>
<ElAlert
type="info"
:closable="false"
:title="$t('embedSlotsManage.code.hint')"
data-test="embed-code-hint"
/>
</div>
<template #footer>
<ElButton data-test="embed-code-close" @click="visible = false">
{{ $t('embedSlotsManage.code.close') }}
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { buildEmbedSnippet, embedOriginFromEnv } from '@/utils/system/embedSlots'
const props = defineProps<{
/** 目标展示位(publicToken 拼入 data-slot) */
embedSlot: Api.EmbedSlot.Item | null
}>()
const visible = defineModel<boolean>({ default: false })
const { t } = useI18n()
const snippet = computed(() =>
props.embedSlot === null ? '' : buildEmbedSnippet(embedOrigin(), props.embedSlot.publicToken)
)
function embedOrigin(): string {
return embedOriginFromEnv(import.meta.env)
}
async function copySnippet(): Promise<void> {
await navigator.clipboard.writeText(snippet.value)
ElMessage.success(t('embedSlotsManage.code.copied'))
}
</script>
@@ -0,0 +1,190 @@
<!-- 绑定相册对话框(P4b Task 5):可搜索相册选择器(admin 可见全部相册,
/v1/albums 游标拉取 + 本地按标题过滤——列表端点无标题查询参数)+
清空即解绑。提交前确认框携带 A22 文案:configVersion 将递增,官网无需
改动(嵌入代码只认 token,换绑/解绑不影响已贴出的 script 标签)。 -->
<template>
<ElDialog
v-model="visible"
:title="$t('embedSlotsManage.bind.title', { name: embedSlot?.name ?? '' })"
width="560px"
destroy-on-close
data-test="slot-bind-dialog"
>
<ElForm label-width="110px">
<ElFormItem :label="$t('embedSlotsManage.bind.currentLabel')">
<span v-if="embedSlot?.albumId" class="text-gray-500" data-test="slot-bind-current">
{{ embedSlot.albumTitle }}({{ embedSlot.albumId }})
</span>
<span v-else class="text-gray-400" data-test="slot-bind-current">
{{ $t('embedSlotsManage.list.unbound') }}
</span>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.bind.targetLabel')">
<ElSelect
v-model="targetAlbumId"
filterable
remote
clearable
:remote-method="searchAlbums"
:loading="searching"
:placeholder="$t('embedSlotsManage.bind.searchPlaceholder')"
style="width: 100%"
data-test="slot-bind-select"
>
<ElOption
v-for="album in options"
:key="album.id"
:value="album.id"
:label="`${titleOf(album)}(${album.id})`"
:data-test="`slot-bind-option-${album.id}`"
/>
<template #empty>
<span class="px-4 py-2 text-gray-400 text-sm" data-test="slot-bind-empty">
{{
searching
? t('embedSlotsManage.bind.searching')
: t('embedSlotsManage.bind.noMatch')
}}
</span>
</template>
</ElSelect>
<div class="text-gray-400 text-xs mt-1">{{ $t('embedSlotsManage.bind.clearHint') }}</div>
</ElFormItem>
</ElForm>
<ElAlert
type="info"
:closable="false"
:title="$t('embedSlotsManage.bind.versionHint')"
data-test="slot-bind-version-hint"
/>
<template #footer>
<ElButton data-test="slot-bind-cancel" @click="visible = false">
{{ $t('common.cancel') }}
</ElButton>
<ElButton
type="primary"
:disabled="changeKind === 'none'"
:loading="submitting"
data-test="slot-bind-submit"
@click="submit"
>
{{ $t(`embedSlotsManage.bind.action${KIND_KEY[changeKind]}`) }}
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchAlbumList } from '@/api/albums'
import { fetchUpdateEmbedSlot } from '@/api/embedSlots'
import { bindChangeKind, type BindChangeKind } from '@/utils/system/embedSlots'
/** 变更分类 → 文案键后缀(none 复用 bind 文案;按钮恒禁用不会提交) */
const KIND_KEY: Record<BindChangeKind, string> = {
bind: 'Bind',
rebind: 'Rebind',
unbind: 'Unbind',
none: 'Bind'
}
const props = defineProps<{
/** 绑定目标展示位(打开时回显当前绑定) */
embedSlot: Api.EmbedSlot.Item | null
}>()
const emit = defineEmits<{
/** 绑定变更成功(回传更新后条目,行内整行替换) */
saved: [slot: Api.EmbedSlot.Item]
}>()
const visible = defineModel<boolean>({ default: false })
const { t } = useI18n()
/** 搜索拉取页数上限(每页 50:列表端点无标题过滤,本地匹配;管理规模
有界,5 页 ≈ 250 本封顶,超出以 ID 精确匹配兜底) */
const MAX_SEARCH_PAGES = 5
const targetAlbumId = ref<string | null>(null)
const options = ref<Api.Album.Summary[]>([])
const searching = ref(false)
const submitting = ref(false)
const changeKind = computed(() =>
props.embedSlot === null ? 'none' : bindChangeKind(props.embedSlot.albumId, targetAlbumId.value)
)
/** 列表标题按 defaultLocale 解析(回退第一条翻译,同相册管理页) */
function titleOf(album: Api.Album.Summary): string {
const preferred = album.translations.find((tr) => tr.locale === album.defaultLocale)
return preferred?.title ?? album.translations[0]?.title ?? album.id
}
watch(visible, (open) => {
if (!open) return
targetAlbumId.value = props.embedSlot?.albumId ?? null
options.value = []
void searchAlbums('')
})
/** 相册搜索:游标拉取(上限内)+ 本地按标题/ID 过滤(空查询=首页全集) */
async function searchAlbums(query: string): Promise<void> {
const keyword = query.trim().toLowerCase()
searching.value = true
try {
const all: Api.Album.Summary[] = []
let cursor: string | undefined
for (let page = 0; page < MAX_SEARCH_PAGES; page++) {
const res = await fetchAlbumList({ cursor, limit: 50 })
all.push(...res.items)
if (!res.hasMore || res.nextCursor === null) break
cursor = res.nextCursor
}
options.value =
keyword === ''
? all
: all.filter(
(album) =>
titleOf(album).toLowerCase().includes(keyword) ||
album.id.toLowerCase().includes(keyword)
)
} finally {
searching.value = false
}
}
async function submit(): Promise<void> {
if (props.embedSlot === null || changeKind.value === 'none') return
const kind = changeKind.value
try {
await ElMessageBox.confirm(
t(`embedSlotsManage.bind.confirm${KIND_KEY[kind]}`),
t('embedSlotsManage.bind.confirmTitle'),
{
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
}
)
} catch {
return
}
submitting.value = true
try {
const updated = await fetchUpdateEmbedSlot(props.embedSlot.id, {
albumId: targetAlbumId.value
})
ElMessage.success(t('embedSlotsManage.bind.saved', { version: updated.configVersion }))
visible.value = false
emit('saved', updated)
} catch {
// 错误已由 http 层统一弹出
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,185 @@
<!-- 展示位新建/编辑对话框(P4b Task 5):名称 + 设置表单(位置/尺寸/间隔/
语言/默认收起)。slot 为 null 即新建(表单取契约缺省值);编辑按契约
整体替换语义提交完整 settings(intervalMs 夹取兜底 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}`"
/>
</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>
</ElForm>
<template #footer>
<ElButton data-test="slot-form-cancel" @click="visible = false">
{{ $t('common.cancel') }}
</ElButton>
<ElButton type="primary" :loading="submitting" data-test="slot-form-submit" @click="submit">
{{ $t('embedSlotsManage.form.submit') }}
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { fetchCreateEmbedSlot, fetchUpdateEmbedSlot } from '@/api/embedSlots'
import {
DEFAULT_EMBED_SETTINGS,
EMBED_INTERVAL_MAX_MS,
EMBED_INTERVAL_MIN_MS,
buildSettingsPayload,
buildSlotEditPayload,
type EmbedSettingsDraft
} from '@/utils/system/embedSlots'
const props = defineProps<{
/** 编辑目标(null = 新建:表单取契约缺省设置) */
embedSlot: Api.EmbedSlot.Item | null
}>()
const emit = defineEmits<{
/** 新建成功(回传服务端条目,列表回显) */
created: [slot: Api.EmbedSlot.Item]
/** 编辑成功(回传更新后条目,行内整行替换) */
saved: [slot: Api.EmbedSlot.Item]
}>()
const visible = defineModel<boolean>({ default: false })
const { t } = useI18n()
const draft = reactive<EmbedSettingsDraft>({ ...DEFAULT_EMBED_SETTINGS })
const draftName = ref('')
const nameError = ref('')
const submitting = ref(false)
// 过滤/下拉选项(computed:语言切换时标签随 i18n 响应式刷新)
const positionOptions = computed(() => [
{ value: 'left' as const, label: t('embedSlotsManage.form.positionLeft') },
{ value: 'right' as const, label: t('embedSlotsManage.form.positionRight') }
])
const sizeOptions = computed(() => [
{ value: 'sm' as const, label: t('embedSlotsManage.form.sizeSm') },
{ value: 'md' as const, label: t('embedSlotsManage.form.sizeMd') }
])
const langOptions = computed(() => [
{ value: 'auto' as const, label: t('embedSlotsManage.form.langAuto') },
{ value: 'zh-CN' as const, label: t('embedSlotsManage.form.langZh') },
{ value: 'en' as const, label: t('embedSlotsManage.form.langEn') }
])
watch(visible, (open) => {
if (!open) return
nameError.value = ''
if (props.embedSlot !== null) {
draftName.value = props.embedSlot.name
Object.assign(draft, props.embedSlot.settings)
} else {
draftName.value = ''
Object.assign(draft, DEFAULT_EMBED_SETTINGS)
}
})
async function submit(): Promise<void> {
const name = draftName.value.trim()
if (name === '') {
nameError.value = t('embedSlotsManage.form.nameRequired')
return
}
nameError.value = ''
submitting.value = true
try {
if (props.embedSlot !== null) {
const payload = buildSlotEditPayload(props.embedSlot, name, { ...draft })
if (Object.keys(payload).length === 0) {
ElMessage.info(t('embedSlotsManage.edit.noChanges'))
visible.value = false
return
}
const updated = await fetchUpdateEmbedSlot(props.embedSlot.id, payload)
ElMessage.success(t('embedSlotsManage.edit.saved', { version: updated.configVersion }))
visible.value = false
emit('saved', updated)
} else {
const created = await fetchCreateEmbedSlot({
name,
settings: buildSettingsPayload({ ...draft })
})
ElMessage.success(t('embedSlotsManage.create.created'))
visible.value = false
emit('created', created)
}
} catch {
// 错误已由 http 层统一弹出(ElMessage),此处仅吸收避免未处理拒绝
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,203 @@
<!-- 展示位管理页(P4b Task 5):游标累积列表(名称/绑定相册/启停开关/
configVersion/更新时间)+ 新建/编辑对话框 + 绑定相册对话框(A22 确认
文案)+ 嵌入代码卡。启停与任何管理面变更一样递增 configVersion——toast
携带新版本号提示(嵌入端轮询发现版本升即整体重置渲染)。 -->
<template>
<div class="p-5">
<ElCard data-test="embed-slots-card">
<template #header>
<div class="flex items-center justify-between">
<span>{{ $t('embedSlotsManage.list.title') }}</span>
<ElButton type="primary" data-test="slot-create-btn" @click="openCreate">
{{ $t('embedSlotsManage.list.create') }}
</ElButton>
</div>
</template>
<ElAlert
v-if="error && !items.length"
type="error"
:closable="false"
data-test="embed-slots-error"
>
<div class="flex items-center gap-2">
<span>{{ $t('embedSlotsManage.list.loadFailed') }}:{{ error.message }}</span>
<ElButton size="small" data-test="embed-slots-retry" @click="loadFirst()">
{{ $t('embedSlotsManage.list.retry') }}
</ElButton>
</div>
</ElAlert>
<ElTable v-else v-loading="loading" :data="items" data-test="embed-slots-table">
<ElTableColumn :label="$t('embedSlotsManage.list.colName')" min-width="150">
<template #default="{ row }">
<span class="font-medium" data-test="slot-name">{{ row.name }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('embedSlotsManage.list.colAlbum')" min-width="220">
<template #default="{ row }">
<div v-if="row.albumId" class="flex flex-col" data-test="slot-album">
<span>{{ row.albumTitle }}</span>
<span class="text-gray-400 text-xs font-mono">{{ row.albumId }}</span>
</div>
<ElTag v-else type="info" effect="plain" data-test="slot-album-unbound">
{{ $t('embedSlotsManage.list.unbound') }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('embedSlotsManage.list.colEnabled')" width="90">
<template #default="{ row, $index }">
<ElSwitch
:model-value="row.enabled"
:loading="togglingId === row.id"
:disabled="togglingId !== null && togglingId !== row.id"
data-test="slot-enabled-switch"
@change="toggleEnabled(row as Api.EmbedSlot.Item, $index)"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('embedSlotsManage.list.colConfigVersion')" width="120">
<template #default="{ row }">
<ElTag type="info" effect="plain" data-test="slot-config-version">
v{{ row.configVersion }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('embedSlotsManage.list.colUpdatedAt')" width="160">
<template #default="{ row }">
<span>{{ formatDateTime(row.updatedAt) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('embedSlotsManage.list.colActions')" width="240" fixed="right">
<template #default="{ row }">
<ElButton link type="primary" data-test="slot-edit-btn" @click="openEdit(row)">
{{ $t('embedSlotsManage.list.actionEdit') }}
</ElButton>
<ElButton link type="primary" data-test="slot-bind-btn" @click="openBind(row)">
{{ $t('embedSlotsManage.list.actionBind') }}
</ElButton>
<ElButton link type="primary" data-test="slot-code-btn" @click="openCode(row)">
{{ $t('embedSlotsManage.list.actionCode') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('embedSlotsManage.list.empty')" />
</template>
</ElTable>
<div v-if="items.length" class="mt-4 text-center">
<ElButton
v-if="hasMore"
:loading="loading"
data-test="embed-slots-load-more"
@click="loadMore()"
>
{{ $t('embedSlotsManage.list.loadMore') }}
</ElButton>
<span v-else class="text-gray-400 text-sm">{{ $t('embedSlotsManage.list.noMore') }}</span>
</div>
</ElCard>
<SlotFormDialog
v-model="formVisible"
:embed-slot="editing"
@created="onCreated"
@saved="onSaved"
/>
<SlotBindDialog v-model="bindVisible" :embed-slot="binding" @saved="onSaved" />
<EmbedCodeDialog v-model="codeVisible" :embed-slot="coding" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import SlotFormDialog from './SlotFormDialog.vue'
import SlotBindDialog from './SlotBindDialog.vue'
import EmbedCodeDialog from './EmbedCodeDialog.vue'
import { fetchEmbedSlots, fetchUpdateEmbedSlot } from '@/api/embedSlots'
import { useCursorList } from '@/views/albums/composables/useCursorList'
import { formatDateTime } from '@/utils/albums/format'
defineOptions({ name: 'SystemEmbedSlots' })
const PAGE_SIZE = 20
const { t } = useI18n()
const { items, hasMore, loading, error, loadFirst, loadMore, replaceItem } =
useCursorList<Api.EmbedSlot.Item>(
(cursor?: string) => fetchEmbedSlots({ cursor, limit: PAGE_SIZE }),
(item) => item.id
)
// ── 启停开关:PATCH enabled → configVersion+1(toast 携带新版本提示) ──
const togglingId = ref<string | null>(null)
async function toggleEnabled(row: Api.EmbedSlot.Item, index: number): Promise<void> {
togglingId.value = row.id
try {
const next = row.enabled ? false : true
const updated = await fetchUpdateEmbedSlot(row.id, { enabled: next })
replaceItem(index, updated)
ElMessage.success(
updated.enabled
? t('embedSlotsManage.toast.enabledOn', { version: updated.configVersion })
: t('embedSlotsManage.toast.enabledOff', { version: updated.configVersion })
)
} catch {
// 失败不换行:ElSwitch 用 model-value 单向绑定,行数据未变即回弹
} finally {
togglingId.value = null
}
}
// ── 新建 / 编辑 ──
const formVisible = ref(false)
const editing = ref<Api.EmbedSlot.Item | null>(null)
function openCreate(): void {
editing.value = null
formVisible.value = true
}
function openEdit(row: Api.EmbedSlot.Item): void {
editing.value = row
formVisible.value = true
}
/** 新建 → 列表按 createdAt 倒序,重载首页即置顶回显 */
function onCreated(): void {
void loadFirst()
}
function onSaved(updated: Api.EmbedSlot.Item): void {
const index = items.value.findIndex((item) => item.id === updated.id)
if (index >= 0) replaceItem(index, updated)
}
// ── 绑定相册 ──
const bindVisible = ref(false)
const binding = ref<Api.EmbedSlot.Item | null>(null)
function openBind(row: Api.EmbedSlot.Item): void {
binding.value = row
bindVisible.value = true
}
// ── 嵌入代码 ──
const codeVisible = ref(false)
const coding = ref<Api.EmbedSlot.Item | null>(null)
function openCode(row: Api.EmbedSlot.Item): void {
coding.value = row
codeVisible.value = true
}
onMounted(() => {
void loadFirst()
})
</script>