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,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 })
|
||||
}
|
||||
Vendored
+2
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "登录失败次数过多,请稍后再试",
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user