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
+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
}