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
+39
View File
@@ -0,0 +1,39 @@
/**
* 广告位管理接口(对齐 @photo/contracts tags [ads]:listAdminAds /
* createAd / updateAd / deleteAd / initAdUpload,广告位轮播 Task 11;仅 admin 可用)
*
* - 列表全量不分页(sortOrder 升序、并列 createdAt 升序);imageUrl 为
* 现签 GET,过期后重拉本列表刷新
* - 直传两步:init 签发 staging 凭证 → 裸 fetch PUT(预签名 URL 自带
* 凭证,Content-Type 与申明一致);无 complete 步——创建/更新端点核验
* staging 对象后 copyIfAbsent 转存版本化终键
* - PATCH 部分更新:换图三字段(imageStagingKey/imageBytes/imageContentType)
* 必须同时提供;albumIds null=转全局
* - DELETE 硬删(数据不保留、无恢复端点;幂等 404)
*/
import request from '@/utils/http'
/** 广告位列表(全量不分页) */
export function fetchAds() {
return request.get<Api.Ads.ListResponse>({ url: '/v1/admin/ads' })
}
/** 初始化广告图直传(staging 键 + 预签名凭证) */
export function fetchInitAdUpload(data: Api.Ads.UploadInitPayload) {
return request.post<Api.Ads.UploadInitResponse>({ url: '/v1/admin/ads/uploads', data })
}
/** 创建广告位(服务端核验 staging 后转存终键;albumIds 空=全局) */
export function fetchCreateAd(data: Api.Ads.CreatePayload) {
return request.post<Api.Ads.Item>({ url: '/v1/admin/ads', data })
}
/** 更新广告位(部分更新;换图三字段同时提供) */
export function fetchUpdateAd(id: string, data: Api.Ads.UpdatePayload) {
return request.patch<Api.Ads.Item>({ url: `/v1/admin/ads/${id}`, data })
}
/** 硬删除广告位(无恢复;重复删除 404) */
export function fetchDeleteAd(id: string) {
return request.del<void>({ url: `/v1/admin/ads/${id}` })
}
+63 -1
View File
@@ -235,7 +235,8 @@
"users": "Account Management",
"userSync": "User Sync",
"usage": "Usage",
"embedSlots": "Embed Slots"
"embedSlots": "Embed Slots",
"ads": "Ad Management"
},
"albums": {
"title": "Albums",
@@ -922,6 +923,67 @@
"enabledOff": "Disabled (configVersion increased to {version}; public feed returns 404)"
}
},
"adsManage": {
"list": {
"title": "Ad Management",
"subtitle": "Manage album-page ad carousels: images, links, targeting and ordering.",
"tableTitle": "Ads",
"create": "New Ad",
"empty": "No ads yet; click \"New Ad\" to create one",
"loadFailed": "Failed to load ads",
"retry": "Retry",
"colImage": "Image",
"colTitle": "Label",
"colLink": "Link",
"colScope": "Targeting",
"colEnabled": "Enabled",
"colActions": "Actions",
"scopeGlobal": "Global",
"scopeTargeted": "{n} albums",
"imageAlt": "Ad image",
"actionUp": "Move Up",
"actionDown": "Move Down",
"actionEdit": "Edit",
"actionDelete": "Delete",
"deleteConfirm": "Delete ad \"{name}\"? This cannot be undone (data is not retained).",
"deleted": "Ad deleted"
},
"toast": {
"enabledOn": "Ad enabled",
"enabledOff": "Ad disabled (hidden from viewer pages immediately)"
},
"form": {
"imageLabel": "Ad image",
"imagePick": "Choose Image",
"imageRepick": "Replace Image",
"imageHint": "Recommended 1200×360, up to 2MB; jpeg/png/heic",
"imageRequired": "Please upload an ad image first",
"imageInvalidType": "Only jpeg/png/heic images are supported",
"imageTooLarge": "Image exceeds the 2MB limit",
"imageUploadFailed": "Image upload failed: {message}",
"imageStagedTag": "New image pending save",
"linkLabel": "Link",
"linkPlaceholder": "https://example.com/promo",
"linkRequired": "Please enter a link",
"linkInvalid": "Link must start with http(s):// and be at most 2048 characters",
"titleLabel": "Label",
"titlePlaceholder": "e.g. Autumn campaign (back-office only, optional)",
"albumsLabel": "Bound albums",
"albumsPlaceholder": "Search by album title or ID",
"albumsSearching": "Searching…",
"albumsNoMatch": "No matching album",
"albumsHint": "Leave empty = global (carousel on all album viewer pages)",
"submit": "Save"
},
"create": {
"title": "New Ad",
"created": "Ad created"
},
"edit": {
"title": "Edit Ad",
"saved": "Ad saved"
}
},
"apiMsg": {
"AUTH_INVALID_CREDENTIALS": "Invalid account or password",
"AUTH_RATE_LIMITED": "Too many failed login attempts; try again later",
+63 -1
View File
@@ -235,7 +235,8 @@
"users": "账号管理",
"userSync": "用户同步",
"usage": "用量统计",
"embedSlots": "展示位管理"
"embedSlots": "展示位管理",
"ads": "广告位管理"
},
"albums": {
"title": "相册管理",
@@ -922,6 +923,67 @@
"enabledOff": "已停用(configVersion 已递增至 {version},公开端随即 404)"
}
},
"adsManage": {
"list": {
"title": "广告位管理",
"subtitle": "管理相册页广告轮播:图片、跳转链接、投放范围与排序。",
"tableTitle": "广告位列表",
"create": "新建广告位",
"empty": "暂无广告位,点击「新建广告位」创建",
"loadFailed": "广告位列表加载失败",
"retry": "重试",
"colImage": "图片",
"colTitle": "备注名",
"colLink": "跳转链接",
"colScope": "投放范围",
"colEnabled": "启用",
"colActions": "操作",
"scopeGlobal": "全局",
"scopeTargeted": "定向 {n} 相册",
"imageAlt": "广告图",
"actionUp": "上移",
"actionDown": "下移",
"actionEdit": "编辑",
"actionDelete": "删除",
"deleteConfirm": "确定删除广告位「{name}」?删除后不可恢复(数据不保留)。",
"deleted": "广告位已删除"
},
"toast": {
"enabledOn": "广告位已启用",
"enabledOff": "广告位已停用(观众页随即不可见)"
},
"form": {
"imageLabel": "广告图",
"imagePick": "选择图片",
"imageRepick": "重选图片",
"imageHint": "建议 1200×360、不超过 2MB,支持 jpeg/png/heic",
"imageRequired": "请先上传广告图",
"imageInvalidType": "仅支持 jpeg/png/heic 图片",
"imageTooLarge": "图片超过 2MB 上限",
"imageUploadFailed": "图片上传失败:{message}",
"imageStagedTag": "新图待保存",
"linkLabel": "跳转链接",
"linkPlaceholder": "https://example.com/promo",
"linkRequired": "请输入跳转链接",
"linkInvalid": "链接须以 http(s):// 开头且不超过 2048 字符",
"titleLabel": "备注名",
"titlePlaceholder": "如:秋季活动推广(仅后台可见,可空)",
"albumsLabel": "绑定相册",
"albumsPlaceholder": "输入相册标题或 ID 搜索",
"albumsSearching": "搜索中…",
"albumsNoMatch": "无匹配相册",
"albumsHint": "留空=全局投放(全部相册观众页轮播)",
"submit": "保存"
},
"create": {
"title": "新建广告位",
"created": "广告位已创建"
},
"edit": {
"title": "编辑广告位",
"saved": "广告位已保存"
}
},
"apiMsg": {
"AUTH_INVALID_CREDENTIALS": "账号或密码错误",
"AUTH_RATE_LIMITED": "登录失败次数过多,请稍后再试",
+13 -1
View File
@@ -1,6 +1,6 @@
/**
* 账号治理路由(P4a Task 8:账号管理 + 用户同步;Task 9:用量统计;
* P4b Task 5:展示位管理)
* P4b Task 5:展示位管理;广告位轮播 Task 11:广告位管理)
*/
import { AppRouteRecord } from '@/types/router'
@@ -58,6 +58,18 @@ export const systemRoutes: AppRouteRecord = {
keepAlive: false,
menuGroup: 'workspace'
}
},
{
path: 'ads',
name: 'SystemAds',
component: '/system/ads',
meta: {
title: 'menus.system.ads',
// ri:advertisement-line(Remix 基础集广告语义图标,同 embed-slots 坑位避雷)
icon: 'ri:advertisement-line',
keepAlive: false,
menuGroup: 'workspace'
}
}
]
}
+99
View File
@@ -0,0 +1,99 @@
/**
* 广告位域接口类型(对齐 @photo/contracts openapi.yaml tags [ads]:
* listAdminAds / createAd / updateAd / deleteAd / initAdUpload,广告位轮播
* Task 11 后台侧)
*
* 与 api.d.ts 同用全局 Api 命名空间(declare namespace 自动合并)。
* 契约要点:
* - 列表全量不分页(sortOrder 升序、并列 createdAt 升序;运营量级 <20)
* - imageUrl 为现签 GET(TTL 过期后重拉列表刷新)
* - albumIds null=全局投放(空数组提交服务端归一 null;绑定相册删除不
* 级联清理,后台编辑容失效 ID——设计 §2.1)
* - 换图三字段(imageStagingKey/imageBytes/imageContentType)必须同时提供
* - 删除为硬删(cn 侧数据不保留、无恢复端点;重复删除 404)
*
* @module types/api/ads
*/
declare namespace Api {
/** 广告位域 */
namespace Ads {
/** 广告图 MIME 白名单(image/heic 为暂定项,解码能力待 G2 真机验证) */
type ImageContentType = 'image/jpeg' | 'image/png' | 'image/heic'
/** staging 直传凭证(预签名 URL 模式,与相册资产 UploadCredential 同构) */
interface UploadCredential {
readonly mode: 'presigned'
/** 预签名上传 URL(裸 fetch PUT,不带会话头) */
readonly url: string
readonly method: 'PUT'
/** 上传时必须携带的请求头(如 Content-Type,须与申明一致) */
readonly headers: Record<string, string>
readonly expiresAt: string
}
/** 广告位条目(管理视角) */
interface Item {
id: string
/** 后台备注名(不展示观众;可空) */
title: string | null
/** 版本化终键(换图递增版本,禁止覆盖;readOnly) */
readonly imageObjectKey: string
/** 现签 GET(TTL=SIGNED_URL_TTL_SECONDS;过期重拉列表刷新) */
readonly imageUrl: string
/** 跳转链接(观众点击新开标签) */
linkUrl: string
/** 是否启用;false 公开端不可见(ov 投影行删除) */
enabled: boolean
/** 排序权重(升序,并列按 createdAt 升序决胜) */
sortOrder: number
/** 绑定相册 ID 列表;null=全局投放 */
albumIds: string[] | null
readonly createdAt: string
readonly updatedAt: string
}
/** 广告位列表响应(全量不分页) */
interface ListResponse {
items: Item[]
}
/** 直传初始化载荷(无会话概念,幂等由创建端 copyIfAbsent 承担) */
interface UploadInitPayload {
filename: string
/** 申明字节数(上限 AD_UPLOAD_MAX_BYTES=2MB) */
bytes: number
contentType: ImageContentType
}
/** 直传初始化响应(objectKey 创建/更新时引用) */
interface UploadInitResponse {
/** staging 键 photo-live/{env}/ads/staging/{cuid}.{ext} */
readonly objectKey: string
credential: UploadCredential
}
/** 创建载荷(albumIds 未提供或空数组=全局投放;title 1..120 可空) */
interface CreatePayload {
title?: string
linkUrl: string
imageStagingKey: string
imageBytes: number
imageContentType: ImageContentType
sortOrder?: number
albumIds?: string[]
}
/** 更新载荷(部分更新;空请求体 400;albumIds null=转全局、数组=整体替换;换图三字段同时提供) */
interface UpdatePayload {
title?: string
linkUrl?: string
enabled?: boolean
sortOrder?: number
albumIds?: string[] | null
imageStagingKey?: string
imageBytes?: number
imageContentType?: ImageContentType
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import zhMessages from '@/locales/langs/zh.json'
import enMessages from '@/locales/langs/en.json'
import { isValidAdLinkUrl, normalizeAlbumIds, scopeKind } from './adsLogic'
describe('scopeKind 投放范围分类', () => {
it('null / 空数组 → global;非空 → targeted', () => {
expect(scopeKind(null)).toBe('global')
expect(scopeKind([])).toBe('global')
expect(scopeKind(['album-1'])).toBe('targeted')
expect(scopeKind(['album-1', 'album-2'])).toBe('targeted')
})
})
describe('normalizeAlbumIds 提交前归一化(服务端语义对齐)', () => {
it('null 透传;空数组 → null(全局投放落库归一)', () => {
expect(normalizeAlbumIds(null)).toBeNull()
expect(normalizeAlbumIds([])).toBeNull()
})
it('去重保序;非空结果原样返回', () => {
expect(normalizeAlbumIds(['a', 'a'])).toEqual(['a'])
expect(normalizeAlbumIds(['b', 'a', 'b', 'c'])).toEqual(['b', 'a', 'c'])
expect(normalizeAlbumIds(['a', 'b'])).toEqual(['a', 'b'])
})
})
describe('isValidAdLinkUrl 表单链接校验(与服务端 pattern 同源)', () => {
it('http/https 前缀放行;其他协议/裸域名拒绝', () => {
expect(isValidAdLinkUrl('https://example.com/promo')).toBe(true)
expect(isValidAdLinkUrl('http://a.cn')).toBe(true)
expect(isValidAdLinkUrl('ftp://example.com')).toBe(false)
expect(isValidAdLinkUrl('example.com/promo')).toBe(false)
expect(isValidAdLinkUrl('')).toBe(false)
})
it('总长 ≤2048 边界:恰 2048 放行、2049 拒绝(契约 maxLength)', () => {
expect(
isValidAdLinkUrl(`https://example.com/${'a'.repeat(2048 - 'https://example.com/'.length)}`)
).toBe(true)
expect(
isValidAdLinkUrl(`https://example.com/${'a'.repeat(2049 - 'https://example.com/'.length)}`)
).toBe(false)
})
})
describe('adsManage 词条 zh/en 键集一致(对照 embedSlotsManage 先例)', () => {
it('zh/en 键集合一致(adsManage 子树)', () => {
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(enMessages.adsManage as Record<string, unknown>)).toEqual(
keysOf(zhMessages.adsManage as Record<string, unknown>)
)
})
})
+25
View File
@@ -0,0 +1,25 @@
/**
* 广告位管理纯逻辑(广告位轮播 Task 11)——无 Vue 依赖,vitest 直测。
*
* - scopeKind/normalizeAlbumIds 对齐服务端归一语义:albumIds null=全局投放,
* 空数组提交落库归一 null(AdCreateRequestDto 注释口径)
* - isValidAdLinkUrl 与服务端 pattern 同源(dto.ts LINK_URL_PATTERN:
* ^https?:// 前缀 + 契约 maxLength 2048)
*/
/** 投放范围:null 或空数组 → 'global';非空 → 'targeted'。 */
export function scopeKind(albumIds: string[] | null): 'global' | 'targeted' {
return albumIds === null || albumIds.length === 0 ? 'global' : 'targeted'
}
/** 提交前归一化:去重 + 空数组→null(服务端语义对齐)。 */
export function normalizeAlbumIds(v: string[] | null): string[] | null {
if (v === null) return null
const uniq = [...new Set(v)]
return uniq.length === 0 ? null : uniq
}
/** 表单链接校验(与服务端 pattern 同源:^https?://,总长 ≤2048)。 */
export function isValidAdLinkUrl(u: string): boolean {
return /^https?:\/\//.test(u) && u.length <= 2048
}
@@ -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>