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",
|
"users": "Account Management",
|
||||||
"userSync": "User Sync",
|
"userSync": "User Sync",
|
||||||
"usage": "Usage",
|
"usage": "Usage",
|
||||||
"embedSlots": "Embed Slots"
|
"embedSlots": "Embed Slots",
|
||||||
|
"ads": "Ad Management"
|
||||||
},
|
},
|
||||||
"albums": {
|
"albums": {
|
||||||
"title": "Albums",
|
"title": "Albums",
|
||||||
@@ -922,6 +923,67 @@
|
|||||||
"enabledOff": "Disabled (configVersion increased to {version}; public feed returns 404)"
|
"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": {
|
"apiMsg": {
|
||||||
"AUTH_INVALID_CREDENTIALS": "Invalid account or password",
|
"AUTH_INVALID_CREDENTIALS": "Invalid account or password",
|
||||||
"AUTH_RATE_LIMITED": "Too many failed login attempts; try again later",
|
"AUTH_RATE_LIMITED": "Too many failed login attempts; try again later",
|
||||||
|
|||||||
@@ -235,7 +235,8 @@
|
|||||||
"users": "账号管理",
|
"users": "账号管理",
|
||||||
"userSync": "用户同步",
|
"userSync": "用户同步",
|
||||||
"usage": "用量统计",
|
"usage": "用量统计",
|
||||||
"embedSlots": "展示位管理"
|
"embedSlots": "展示位管理",
|
||||||
|
"ads": "广告位管理"
|
||||||
},
|
},
|
||||||
"albums": {
|
"albums": {
|
||||||
"title": "相册管理",
|
"title": "相册管理",
|
||||||
@@ -922,6 +923,67 @@
|
|||||||
"enabledOff": "已停用(configVersion 已递增至 {version},公开端随即 404)"
|
"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": {
|
"apiMsg": {
|
||||||
"AUTH_INVALID_CREDENTIALS": "账号或密码错误",
|
"AUTH_INVALID_CREDENTIALS": "账号或密码错误",
|
||||||
"AUTH_RATE_LIMITED": "登录失败次数过多,请稍后再试",
|
"AUTH_RATE_LIMITED": "登录失败次数过多,请稍后再试",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 账号治理路由(P4a Task 8:账号管理 + 用户同步;Task 9:用量统计;
|
* 账号治理路由(P4a Task 8:账号管理 + 用户同步;Task 9:用量统计;
|
||||||
* P4b Task 5:展示位管理)
|
* P4b Task 5:展示位管理;广告位轮播 Task 11:广告位管理)
|
||||||
*/
|
*/
|
||||||
import { AppRouteRecord } from '@/types/router'
|
import { AppRouteRecord } from '@/types/router'
|
||||||
|
|
||||||
@@ -58,6 +58,18 @@ export const systemRoutes: AppRouteRecord = {
|
|||||||
keepAlive: false,
|
keepAlive: false,
|
||||||
menuGroup: 'workspace'
|
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