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