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
@@ -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>