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