feat(admin): 广告位管理页——上传/链接/绑定相册/开关/排序

Task 11(广告位轮播):admin 后台广告位治理页。

- utils/ads/adsLogic:scopeKind/normalizeAlbumIds/isValidAdLinkUrl 纯逻辑
  (与服务端 dto LINK_URL_PATTERN、albumIds 归一语义同源)+ vitest 6 例
- api/ads + types/api/ads.d.ts:list/create/update/delete/initUpload 五端点
  (对照 embedSlots.ts 逐字风格;列表全量不分页、imageUrl 现签)
- views/system/ads:列表(缩略图/备注名/链接截断/投放范围标签/启停开关
  失败重拉回滚/上移下移 sortOrder 互换/硬删确认)+ 新建编辑对话框
  (staging 直传 init→裸 fetch PUT、Content-Type 与申明一致;相册多选
  远程搜索复用 /v1/albums 分页+本地过滤,绑定 ID 超限/失效以裸 ID 兜底;
  编辑换图才带 image 三字段;albumIds 归一空→null 转全局)
- 路由/菜单:system 组 ads 页(ri:advertisement-line,workspace 分组);
  locales zh/en 补 menus.system.ads + adsManage 子树(键集一致入单测)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-29 01:21:33 +08:00
parent 7f9789eb46
commit 239ef5648f
9 changed files with 1001 additions and 3 deletions
@@ -0,0 +1,362 @@
<!-- 广告位新建/编辑对话框(广告位轮播 Task 11):广告图直传(init 签发
staging 凭证 → 裸 fetch PUT 预签名 URL,Content-Type 与申明一致——无
complete 步,创建/更新端点核验后转存终键)+ 跳转链接(isValidAdLinkUrl
与服务端 pattern 同源)+ 备注名(≤120 可空)+ 绑定相册(多选远程搜索,
/v1/albums 游标拉取 + 本地过滤;留空=全局投放)。编辑态换图才带 image
三字段;albumIds 经 normalizeAlbumIds 归一(空→null=转全局)。 -->
<template>
<ElDialog
v-model="visible"
:title="ad === null ? $t('adsManage.create.title') : $t('adsManage.edit.title')"
width="640px"
destroy-on-close
data-test="ad-form-dialog"
>
<ElForm ref="formRef" label-width="110px" :model="form" :rules="rules">
<ElFormItem :label="$t('adsManage.form.imageLabel')">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<ElButton :loading="uploading" data-test="ad-form-pick" @click="fileInput?.click()">
{{
staging || ad !== null
? $t('adsManage.form.imageRepick')
: $t('adsManage.form.imagePick')
}}
</ElButton>
<span class="text-gray-400 text-sm">{{ $t('adsManage.form.imageHint') }}</span>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/heic"
class="hidden"
data-test="ad-form-file-input"
@change="onFileChange"
/>
</div>
<!-- 预览:新选文件用本地对象 URL;未换图的编辑态沿用行内现签 URL -->
<div v-if="previewSrc" class="flex items-center gap-2" data-test="ad-form-preview">
<img
:src="previewSrc"
:alt="$t('adsManage.list.imageAlt')"
class="ads-form-preview-img"
data-test="ad-form-preview-img"
/>
<ElTag v-if="staging" type="warning" effect="light" data-test="ad-form-staged-tag">
{{ $t('adsManage.form.imageStagedTag') }}
</ElTag>
</div>
<div v-if="uploadError" class="text-red-500 text-sm" data-test="ad-form-upload-error">
{{ uploadError }}
</div>
</div>
</ElFormItem>
<ElFormItem :label="$t('adsManage.form.linkLabel')" prop="linkUrl">
<ElInput
v-model="form.linkUrl"
:placeholder="$t('adsManage.form.linkPlaceholder')"
maxlength="2048"
data-test="ad-form-link"
/>
</ElFormItem>
<ElFormItem :label="$t('adsManage.form.titleLabel')">
<ElInput
v-model="form.title"
:placeholder="$t('adsManage.form.titlePlaceholder')"
maxlength="120"
show-word-limit
data-test="ad-form-title"
/>
</ElFormItem>
<ElFormItem :label="$t('adsManage.form.albumsLabel')">
<ElSelect
v-model="form.albumIds"
multiple
filterable
remote
clearable
:remote-method="searchAlbums"
:loading="searching"
:placeholder="$t('adsManage.form.albumsPlaceholder')"
style="width: 100%"
data-test="ad-form-albums"
>
<ElOption
v-for="album in options"
:key="album.id"
:value="album.id"
:label="album.label"
:data-test="`ad-form-album-option-${album.id}`"
/>
<template #empty>
<span class="px-4 py-2 text-gray-400 text-sm" data-test="ad-form-albums-empty">
{{
searching ? t('adsManage.form.albumsSearching') : t('adsManage.form.albumsNoMatch')
}}
</span>
</template>
</ElSelect>
<div class="text-gray-400 text-xs mt-1">{{ $t('adsManage.form.albumsHint') }}</div>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton data-test="ad-form-cancel" @click="visible = false">
{{ $t('common.cancel') }}
</ElButton>
<ElButton type="primary" :loading="submitting" data-test="ad-form-submit" @click="submit">
{{ $t('adsManage.form.submit') }}
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { fetchAlbumList } from '@/api/albums'
import { fetchCreateAd, fetchInitAdUpload, fetchUpdateAd } from '@/api/ads'
import { isValidAdLinkUrl, normalizeAlbumIds } from '@/utils/ads/adsLogic'
/** 广告图字节上限(契约 AD_UPLOAD_MAX_BYTES=2MB,独立于照片域 50MB) */
const AD_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
const CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/heic'])
/** 相册搜索拉取页数上限(每页 50:列表端点无标题过滤,本地匹配;管理
规模有界,5 页 ≈ 250 本封顶,超出以 ID 兜底标签回显) */
const MAX_SEARCH_PAGES = 5
const props = defineProps<{
/** 编辑目标(null = 新建) */
ad: Api.Ads.Item | null
}>()
const emit = defineEmits<{
/** 新建/编辑成功(父页重拉列表对齐排序与现签 imageUrl) */
saved: [ad: Api.Ads.Item]
}>()
const visible = defineModel<boolean>({ default: false })
const { t } = useI18n()
const formRef = ref<FormInstance | null>(null)
const submitting = ref(false)
const form = ref({
linkUrl: '',
title: '',
albumIds: [] as string[]
})
const rules = computed<FormRules>(() => ({
linkUrl: [
{ required: true, message: t('adsManage.form.linkRequired'), trigger: 'blur' },
{
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
callback(isValidAdLinkUrl(value) ? undefined : new Error(t('adsManage.form.linkInvalid')))
},
trigger: 'blur'
}
]
}))
// ── 广告图直传:init → 裸 fetch PUT staging(无 complete;创建/更新核验)──
interface StagingImage {
objectKey: string
bytes: number
contentType: Api.Ads.ImageContentType
}
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const uploadError = ref('')
const staging = ref<StagingImage | null>(null)
/** 新选文件预览(本地对象 URL;替换时回收,对话框重开时回收) */
const previewObjectUrl = ref<string | null>(null)
const previewSrc = computed(() => previewObjectUrl.value ?? props.ad?.imageUrl ?? null)
function releasePreview(): void {
if (previewObjectUrl.value !== null) {
URL.revokeObjectURL(previewObjectUrl.value)
previewObjectUrl.value = null
}
}
async function onFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
// 允许同一文件连续重选:change 处理完即复位
input.value = ''
if (!file) return
if (!CONTENT_TYPES.has(file.type)) {
uploadError.value = t('adsManage.form.imageInvalidType')
return
}
if (file.size > AD_UPLOAD_MAX_BYTES) {
uploadError.value = t('adsManage.form.imageTooLarge')
return
}
uploadError.value = ''
uploading.value = true
try {
// ① init:staging 键 + 预签名凭证(失败重试重新 init,staging 键不复用)
const init = await fetchInitAdUpload({
filename: file.name,
bytes: file.size,
contentType: file.type as Api.Ads.ImageContentType
})
// ② PUT staging:裸 fetch(预签名 URL 自带凭证;headers 必须原样携带)
const response = await fetch(init.credential.url, {
method: init.credential.method,
headers: init.credential.headers,
body: file
})
if (!response.ok) throw new Error(`PUT ${response.status}`)
staging.value = {
objectKey: init.objectKey,
bytes: file.size,
contentType: file.type as Api.Ads.ImageContentType
}
releasePreview()
previewObjectUrl.value = URL.createObjectURL(file)
} catch (e) {
uploadError.value = t('adsManage.form.imageUploadFailed', { message: (e as Error).message })
} finally {
uploading.value = false
}
}
// ── 绑定相册:多选远程搜索(/v1/albums 分页拉取 + 本地按标题/ID 过滤)──
interface AlbumOption {
id: string
label: string
}
const options = ref<AlbumOption[]>([])
const searching = ref(false)
/** 列表标题按 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
}
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.map((album) => ({ id: album.id, label: `${titleOf(album)}(${album.id})` }))
: all
.filter(
(album) =>
titleOf(album).toLowerCase().includes(keyword) ||
album.id.toLowerCase().includes(keyword)
)
.map((album) => ({ id: album.id, label: `${titleOf(album)}(${album.id})` }))
} finally {
searching.value = false
}
}
/** 编辑态初始回显:已绑定 albumId 解析为标签(列表接口按 id 过滤;
超出拉取上限或已删除的相册以裸 ID 兜底——绑定不级联清理,容失效) */
async function hydrateBoundAlbums(ids: string[]): Promise<void> {
await searchAlbums('')
const known = new Set(options.value.map((option) => option.id))
const missing = ids.filter((id) => !known.has(id)).map((id) => ({ id, label: id }))
if (missing.length) options.value = [...missing, ...options.value]
}
watch(visible, (open) => {
if (!open) {
releasePreview()
return
}
uploadError.value = ''
staging.value = null
releasePreview()
if (props.ad !== null) {
form.value.linkUrl = props.ad.linkUrl
form.value.title = props.ad.title ?? ''
form.value.albumIds = props.ad.albumIds ?? []
} else {
form.value.linkUrl = ''
form.value.title = ''
form.value.albumIds = []
}
options.value = []
if (props.ad !== null && (props.ad.albumIds ?? []).length > 0) {
void hydrateBoundAlbums(props.ad.albumIds ?? [])
}
})
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (valid === false) return
// 新建必须先传图;编辑态未换图沿用原图(不带 image 三字段)
if (props.ad === null && staging.value === null) {
uploadError.value = t('adsManage.form.imageRequired')
return
}
uploadError.value = ''
const title = form.value.title.trim()
const albumIds = normalizeAlbumIds(form.value.albumIds)
submitting.value = true
try {
const saved =
props.ad !== null
? await fetchUpdateAd(props.ad.id, {
// title 服务端 @IsNotEmpty:空串不可提交,清空=保持原值
...(title === '' ? {} : { title }),
linkUrl: form.value.linkUrl.trim(),
albumIds,
...(staging.value !== null
? {
imageStagingKey: staging.value.objectKey,
imageBytes: staging.value.bytes,
imageContentType: staging.value.contentType
}
: {})
})
: await fetchCreateAd({
...(title === '' ? {} : { title }),
linkUrl: form.value.linkUrl.trim(),
imageStagingKey: staging.value!.objectKey,
imageBytes: staging.value!.bytes,
imageContentType: staging.value!.contentType,
albumIds: albumIds ?? undefined
})
ElMessage.success(
props.ad !== null ? t('adsManage.edit.saved') : t('adsManage.create.created')
)
visible.value = false
emit('saved', saved)
} catch {
// 错误已由 http 层统一弹出(ElMessage),此处仅吸收避免未处理拒绝
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.ads-form-preview-img {
width: 160px;
height: 80px;
object-fit: cover;
display: block;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
}
</style>
+278
View File
@@ -0,0 +1,278 @@
<!-- 广告位管理页(广告位轮播 Task 11):全量列表(缩略图/备注名/跳转链接/
投放范围/启停开关)+ 新建/编辑对话框 + 上移/下移排序 + 硬删除。列表按
sortOrder 升序返回,imageUrl 为现签 GET——过期后重拉本列表刷新。启停/
排序/删除均直接变更观众端可见内容(ov 投影实时跟随)。 -->
<template>
<div class="ui06-page">
<!-- 页头(示例 13 .page-heading) -->
<div class="ui06-page-heading">
<div>
<h1>{{ $t('adsManage.list.title') }}</h1>
<p>{{ $t('adsManage.list.subtitle') }}</p>
</div>
<div class="actions">
<ElButton type="primary" data-test="ad-create-btn" @click="openCreate">
{{ $t('adsManage.list.create') }}
</ElButton>
</div>
</div>
<ElCard data-test="ads-card">
<template #header>
<div class="flex items-center justify-between">
<span class="ui06-card-title">{{ $t('adsManage.list.tableTitle') }}</span>
</div>
</template>
<ElAlert v-if="error && !items.length" type="error" :closable="false" data-test="ads-error">
<div class="flex items-center gap-2">
<span>{{ $t('adsManage.list.loadFailed') }}:{{ error.message }}</span>
<ElButton size="small" data-test="ads-retry" @click="loadFirst()">
{{ $t('adsManage.list.retry') }}
</ElButton>
</div>
</ElAlert>
<ElTable v-else v-loading="loading" :data="items" data-test="ads-table">
<ElTableColumn :label="$t('adsManage.list.colImage')" width="130">
<template #default="{ row }">
<img
:src="row.imageUrl"
:alt="$t('adsManage.list.imageAlt')"
class="ads-thumb"
data-test="ad-image"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colTitle')" min-width="150">
<template #default="{ row }">
<span class="font-medium" data-test="ad-title">{{ row.title ?? '—' }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colLink')" min-width="220">
<template #default="{ row }">
<span class="ads-link" :title="row.linkUrl" data-test="ad-link">
{{ row.linkUrl }}
</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colScope')" width="130">
<template #default="{ row }">
<ElTag
v-if="scopeKind(row.albumIds) === 'global'"
type="success"
effect="plain"
data-test="ad-scope-global"
>
{{ $t('adsManage.list.scopeGlobal') }}
</ElTag>
<ElTag v-else type="warning" effect="plain" data-test="ad-scope-targeted">
{{ $t('adsManage.list.scopeTargeted', { n: row.albumIds.length }) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colEnabled')" width="90">
<template #default="{ row }">
<ElSwitch
:model-value="row.enabled"
:loading="togglingId === row.id"
:disabled="togglingId !== null && togglingId !== row.id"
data-test="ad-enabled-switch"
@change="toggleEnabled(row as Api.Ads.Item)"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('adsManage.list.colActions')" width="260" fixed="right">
<template #default="{ row, $index }">
<ElButton
link
type="primary"
:disabled="$index === 0 || moving"
data-test="ad-up-btn"
@click="move($index, -1)"
>
{{ $t('adsManage.list.actionUp') }}
</ElButton>
<ElButton
link
type="primary"
:disabled="$index === items.length - 1 || moving"
data-test="ad-down-btn"
@click="move($index, 1)"
>
{{ $t('adsManage.list.actionDown') }}
</ElButton>
<ElButton link type="primary" data-test="ad-edit-btn" @click="openEdit(row)">
{{ $t('adsManage.list.actionEdit') }}
</ElButton>
<ElButton link type="danger" data-test="ad-delete-btn" @click="remove(row)">
{{ $t('adsManage.list.actionDelete') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('adsManage.list.empty')" />
</template>
</ElTable>
</ElCard>
<AdsFormDialog v-model="formVisible" :ad="editing" @saved="onSaved" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdsFormDialog from './AdsFormDialog.vue'
import { fetchAds, fetchDeleteAd, fetchUpdateAd } from '@/api/ads'
import { scopeKind } from '@/utils/ads/adsLogic'
defineOptions({ name: 'SystemAds' })
const { t } = useI18n()
// ── 列表:契约全量不分页(sortOrder 升序),整页替换式加载 ──
const items = ref<Api.Ads.Item[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)
async function loadFirst(): Promise<void> {
loading.value = true
error.value = null
try {
const res = await fetchAds()
items.value = res.items
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
// ── 启停开关:PATCH enabled(false 公开端不可见)──
const togglingId = ref<string | null>(null)
async function toggleEnabled(row: Api.Ads.Item): Promise<void> {
togglingId.value = row.id
try {
const updated = await fetchUpdateAd(row.id, { enabled: !row.enabled })
replaceById(updated)
ElMessage.success(
updated.enabled ? t('adsManage.toast.enabledOn') : t('adsManage.toast.enabledOff')
)
} catch {
// 失败回滚+重拉:ElSwitch 单向绑定行数据未变即回弹,重拉对齐服务端状态
await loadFirst()
} finally {
togglingId.value = null
}
}
function replaceById(next: Api.Ads.Item): void {
const index = items.value.findIndex((item) => item.id === next.id)
if (index >= 0) items.value.splice(index, 1, next)
}
// ── 上移/下移:与相邻行互换 sortOrder(两次 PATCH 后重拉列表对齐回显)──
const moving = ref(false)
async function move(index: number, dir: -1 | 1): Promise<void> {
const target = index + dir
if (moving.value || target < 0 || target >= items.value.length) return
const row = items.value[index]
const neighbor = items.value[target]
// 互换 sortOrder;并列相等时错开一档(上移改为抬邻居 +1——契约
// sortOrder ≥0,不可给移动行 -1),值未变的一侧跳过 no-op PATCH
const equal = row.sortOrder === neighbor.sortOrder
const rowNext = equal
? dir === 1
? neighbor.sortOrder + 1
: neighbor.sortOrder
: neighbor.sortOrder
const neighborNext = equal
? dir === -1
? neighbor.sortOrder + 1
: neighbor.sortOrder
: row.sortOrder
moving.value = true
try {
if (rowNext !== row.sortOrder) await fetchUpdateAd(row.id, { sortOrder: rowNext })
if (neighborNext !== neighbor.sortOrder) {
await fetchUpdateAd(neighbor.id, { sortOrder: neighborNext })
}
await loadFirst()
} catch {
// 错误已由 http 层统一弹出;重拉恢复服务端真实顺序
await loadFirst()
} finally {
moving.value = false
}
}
// ── 新建 / 编辑 ──
const formVisible = ref(false)
const editing = ref<Api.Ads.Item | null>(null)
function openCreate(): void {
editing.value = null
formVisible.value = true
}
function openEdit(row: Api.Ads.Item): void {
editing.value = row
formVisible.value = true
}
/** 新建/编辑成功 → 重拉(排序位置与现签 imageUrl 一并对齐) */
function onSaved(): void {
void loadFirst()
}
/** 硬删除:确认(数据不保留、不可恢复)→ DELETE → 重拉列表 */
async function remove(row: Api.Ads.Item): Promise<void> {
try {
await ElMessageBox.confirm(
t('adsManage.list.deleteConfirm', { name: row.title ?? row.id }),
t('common.tips'),
{
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
}
)
} catch {
return // 用户取消
}
await fetchDeleteAd(row.id)
ElMessage.success(t('adsManage.list.deleted'))
loadFirst()
}
onMounted(() => {
void loadFirst()
})
</script>
<style scoped>
.ads-thumb {
width: 96px;
height: 48px;
object-fit: cover;
display: block;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
}
.ads-link {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
color: var(--el-text-color-regular);
}
</style>