diff --git a/apps/admin/src/api/ads.ts b/apps/admin/src/api/ads.ts new file mode 100644 index 0000000..fe96c2d --- /dev/null +++ b/apps/admin/src/api/ads.ts @@ -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({ url: '/v1/admin/ads' }) +} + +/** 初始化广告图直传(staging 键 + 预签名凭证) */ +export function fetchInitAdUpload(data: Api.Ads.UploadInitPayload) { + return request.post({ url: '/v1/admin/ads/uploads', data }) +} + +/** 创建广告位(服务端核验 staging 后转存终键;albumIds 空=全局) */ +export function fetchCreateAd(data: Api.Ads.CreatePayload) { + return request.post({ url: '/v1/admin/ads', data }) +} + +/** 更新广告位(部分更新;换图三字段同时提供) */ +export function fetchUpdateAd(id: string, data: Api.Ads.UpdatePayload) { + return request.patch({ url: `/v1/admin/ads/${id}`, data }) +} + +/** 硬删除广告位(无恢复;重复删除 404) */ +export function fetchDeleteAd(id: string) { + return request.del({ url: `/v1/admin/ads/${id}` }) +} diff --git a/apps/admin/src/locales/langs/en.json b/apps/admin/src/locales/langs/en.json index 5061555..66be2da 100644 --- a/apps/admin/src/locales/langs/en.json +++ b/apps/admin/src/locales/langs/en.json @@ -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", diff --git a/apps/admin/src/locales/langs/zh.json b/apps/admin/src/locales/langs/zh.json index 110c842..8fa6723 100644 --- a/apps/admin/src/locales/langs/zh.json +++ b/apps/admin/src/locales/langs/zh.json @@ -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": "登录失败次数过多,请稍后再试", diff --git a/apps/admin/src/router/modules/system.ts b/apps/admin/src/router/modules/system.ts index f4d95b6..057ab1a 100644 --- a/apps/admin/src/router/modules/system.ts +++ b/apps/admin/src/router/modules/system.ts @@ -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' + } } ] } diff --git a/apps/admin/src/types/api/ads.d.ts b/apps/admin/src/types/api/ads.d.ts new file mode 100644 index 0000000..a3a9173 --- /dev/null +++ b/apps/admin/src/types/api/ads.d.ts @@ -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 + 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 + } + } +} diff --git a/apps/admin/src/utils/ads/adsLogic.spec.ts b/apps/admin/src/utils/ads/adsLogic.spec.ts new file mode 100644 index 0000000..efa6c4a --- /dev/null +++ b/apps/admin/src/utils/ads/adsLogic.spec.ts @@ -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, prefix = ''): string[] => + Object.entries(obj).flatMap(([key, value]) => + typeof value === 'object' && value !== null + ? keysOf(value as Record, `${prefix}${key}.`) + : [`${prefix}${key}`] + ) + expect(keysOf(enMessages.adsManage as Record)).toEqual( + keysOf(zhMessages.adsManage as Record) + ) + }) +}) diff --git a/apps/admin/src/utils/ads/adsLogic.ts b/apps/admin/src/utils/ads/adsLogic.ts new file mode 100644 index 0000000..ac42dda --- /dev/null +++ b/apps/admin/src/utils/ads/adsLogic.ts @@ -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 +} diff --git a/apps/admin/src/views/system/ads/AdsFormDialog.vue b/apps/admin/src/views/system/ads/AdsFormDialog.vue new file mode 100644 index 0000000..4981f02 --- /dev/null +++ b/apps/admin/src/views/system/ads/AdsFormDialog.vue @@ -0,0 +1,362 @@ + + + + + + diff --git a/apps/admin/src/views/system/ads/index.vue b/apps/admin/src/views/system/ads/index.vue new file mode 100644 index 0000000..fde3b02 --- /dev/null +++ b/apps/admin/src/views/system/ads/index.vue @@ -0,0 +1,278 @@ + + + + + +