feat(admin): batch ops, transfer, watermark, usage and qr share pages

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 21:10:30 +08:00
parent 618d1d796c
commit f08d68bebf
31 changed files with 2717 additions and 34 deletions
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { indexOfPhotoId, localizeBatchHideCode, summarizeBatchHideResults } from './batchHide'
/** 构造管理照片行(仅批量逻辑消费的字段) */
function photoOf(photoId: string): Api.Album.AdminPhoto {
return {
photoId,
albumId: 'a1',
categoryId: null,
uploaderId: 'u1',
visibility: 'published',
processingState: 'ready',
bytes: 1024,
width: 100,
height: 80,
shotAt: null,
uploadedAt: '2026-09-01T00:00:00.000Z',
publishedAt: '2026-09-01T00:00:00.000Z',
likeCount: 0,
uploader: { userId: 'u1', account: 'staff', displayName: 'Staff' }
}
}
describe('summarizeBatchHideResults 批量隐藏结果归集', () => {
it('部分成功:计数与失败明细逐项对应(缺 code 兜底 UNKNOWN)', () => {
const summary = summarizeBatchHideResults([
{ photoId: 'p1', ok: true },
{ photoId: 'p2', ok: false, code: 'PHOTO_NOT_PUBLIC' },
{ photoId: 'p3', ok: true },
{ photoId: 'p4', ok: false }
])
expect(summary.okCount).toBe(2)
expect(summary.failCount).toBe(2)
expect(summary.failures).toEqual([
{ photoId: 'p2', code: 'PHOTO_NOT_PUBLIC' },
{ photoId: 'p4', code: 'UNKNOWN' }
])
})
it('全部成功:failures 为空(toast 走 AllOk 文案分支)', () => {
const summary = summarizeBatchHideResults([
{ photoId: 'p1', ok: true },
{ photoId: 'p2', ok: true }
])
expect(summary.okCount).toBe(2)
expect(summary.failCount).toBe(0)
expect(summary.failures).toEqual([])
})
})
describe('localizeBatchHideCode 失败码本地化', () => {
it('apiMsg 有词条返回译文;未知码原样返回', () => {
const dict: Record<string, string> = {
'apiMsg.PHOTO_NOT_PUBLIC': '照片状态不可隐藏(非已发布)'
}
const t = (key: string) => dict[key] ?? key
expect(localizeBatchHideCode(t, 'PHOTO_NOT_PUBLIC')).toBe('照片状态不可隐藏(非已发布)')
expect(localizeBatchHideCode(t, 'SOME_FUTURE_CODE')).toBe('SOME_FUTURE_CODE')
})
})
describe('indexOfPhotoId 行内回显定位', () => {
it('命中返回索引;不在当前页返回 -1(换页/过滤竞态兜底)', () => {
const items = [photoOf('p1'), photoOf('p2'), photoOf('p3')]
expect(indexOfPhotoId(items, 'p2')).toBe(1)
expect(indexOfPhotoId(items, 'pX')).toBe(-1)
})
})
+47
View File
@@ -0,0 +1,47 @@
/**
* 批量隐藏结果汇总(P4a A16 逐项部分成功语义,纯函数供 vitest 覆盖)
*
* - 汇总:成功/失败计数 + 失败明细(photoId + 错误码)——toast 文案与
* 失败详情弹窗共用同一份归集结果
* - 错误码本地化:apiMsg 有词条用词条(PHOTO_NOT_PUBLIC/PHOTO_NOT_FOUND),
* 未知码原样展示(契约 code 为开放枚举,服务端后续扩充不破前端)
* - 行内回显归集:结果 photoId → 列表行索引(shallowRef 列表整行替换用)
*/
import type { useI18n } from 'vue-i18n'
/** 批量隐藏结果归集 */
export interface BatchHideSummary {
/** 成功项数 */
okCount: number
/** 失败项数 */
failCount: number
/** 失败明细(与结果同序) */
failures: Array<{ photoId: string; code: string }>
}
/** 逐项结果 → 汇总(ok 计成功;ok=false 收 code,缺 code 兜底 UNKNOWN) */
export function summarizeBatchHideResults(results: Api.Album.BatchHideItem[]): BatchHideSummary {
const failures: BatchHideSummary['failures'] = []
let okCount = 0
for (const item of results) {
if (item.ok) {
okCount += 1
} else {
failures.push({ photoId: item.photoId, code: item.code ?? 'UNKNOWN' })
}
}
return { okCount, failCount: failures.length, failures }
}
/** 失败码 → 本地化文案(apiMsg 词条优先,未知码原样;t 为 i18n 翻译函数) */
export function localizeBatchHideCode(t: ReturnType<typeof useI18n>['t'], code: string): string {
const key = `apiMsg.${code}`
const text = t(key)
// vue-i18n 缺词条时回退键名本身(apiMsg.${code})——非词条原样展示 code
return text === key ? code : text
}
/** 结果 photoId → 列表行索引(不在当前页的结果忽略——换页/过滤竞态兜底) */
export function indexOfPhotoId(items: Api.Album.AdminPhoto[], photoId: string): number {
return items.findIndex((item) => item.photoId === photoId)
}
+4 -2
View File
@@ -11,10 +11,12 @@ export function formatDateTime(iso: string | null | undefined): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
/** 字节数 → 人类可读 */
/** 字节数 → 人类可读(B/KB/MB/GB/TB;用量页大容量场景覆盖到 GB+) */
export function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`
}
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest'
import {
buildViewerAlbumUrl,
renderShareQr,
viewerBaseFromEnv,
VIEWER_BASE_FALLBACK,
type QrDraw
} from './shareLink'
describe('buildViewerAlbumUrl viewer 分享链接', () => {
it('base + /a/{token}(尾斜杠容忍)', () => {
expect(buildViewerAlbumUrl('http://127.0.0.1:5175', 'abc123')).toBe(
'http://127.0.0.1:5175/a/abc123'
)
expect(buildViewerAlbumUrl('https://view.example.com/', 'abc123')).toBe(
'https://view.example.com/a/abc123'
)
expect(buildViewerAlbumUrl('https://view.example.com//', 'abc123')).toBe(
'https://view.example.com/a/abc123'
)
})
it('环境基址:VITE_VIEWER_BASE 缺省/空白兜底本机 dev', () => {
expect(viewerBaseFromEnv({})).toBe(VIEWER_BASE_FALLBACK)
expect(viewerBaseFromEnv({ VITE_VIEWER_BASE: ' ' })).toBe(VIEWER_BASE_FALLBACK)
expect(viewerBaseFromEnv({ VITE_VIEWER_BASE: ' https://x.example.com ' })).toBe(
'https://x.example.com'
)
})
})
describe('renderShareQr 二维码渲染(注入实现)', () => {
it('以 canvas + URL + 尺寸出参调用绘制函数', async () => {
const draw = vi.fn().mockResolvedValue(undefined) as unknown as QrDraw
const canvas = {} as HTMLCanvasElement
await renderShareQr(canvas, 'http://127.0.0.1:5175/a/tok', draw)
expect(draw).toHaveBeenCalledTimes(1)
expect(draw).toHaveBeenCalledWith(canvas, 'http://127.0.0.1:5175/a/tok', {
width: 200,
margin: 2
})
})
})
+39
View File
@@ -0,0 +1,39 @@
/**
* 分享链接与二维码纯逻辑(P4a Task 9,依赖注入 qrcode 实现供 vitest 覆盖)
*
* - viewer URL:VITE_VIEWER_BASE + /a/{publicToken}(viewer 路由
* /a/:token;base 缺省兜底本机 dev 5175,尾斜杠容忍)
* - 二维码:node-qrcode canvas 渲染;实现经参数注入(单测不拉真库),
* 组件侧默认传 QRCode.toCanvas
*/
/** viewer 部署基址(缺省兜底本机 dev) */
export const VIEWER_BASE_FALLBACK = 'http://127.0.0.1:5175'
/** 部署基址 + 公开令牌 → viewer 相册页 URL(尾斜杠容忍) */
export function buildViewerAlbumUrl(base: string, publicToken: string): string {
return `${base.replace(/\/+$/, '')}/a/${publicToken}`
}
/** 环境基址(VITE_VIEWER_BASE 缺省兜底;Vite 注入 import.meta.env) */
export function viewerBaseFromEnv(env: { VITE_VIEWER_BASE?: string }): string {
const base = env.VITE_VIEWER_BASE?.trim()
return base && base !== '' ? base : VIEWER_BASE_FALLBACK
}
/** 二维码单点绘制函数(注入边界:组件包 QRCode.toCanvas,单测传假实现——
@types/qrcode 的 callback 重载使整模块注入不可赋值,收窄到本签名) */
export type QrDraw = (
canvas: HTMLCanvasElement,
text: string,
options: { width: number; margin: number }
) => Promise<unknown> | void
/** URL → 二维码 canvas(组件 watch 相册变化时调用) */
export async function renderShareQr(
canvas: HTMLCanvasElement,
url: string,
draw: QrDraw
): Promise<void> {
await draw(canvas, url, { width: 200, margin: 2 })
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { formatBytes } from './format'
import {
alertTagType,
buildThresholdsPayload,
bytesToGb,
draftFromThresholds,
gbToBytes,
isAlertActive
} from './usageStorage'
describe('formatBytes 人类可读(含 GB/TB,用量页大容量)', () => {
it('B/KB/MB/GB/TB 阶梯与缺省', () => {
expect(formatBytes(null)).toBe('-')
expect(formatBytes(512)).toBe('512 B')
expect(formatBytes(2048)).toBe('2.0 KB')
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB')
expect(formatBytes(2.5 * 1024 * 1024 * 1024)).toBe('2.50 GB')
expect(formatBytes(3 * 1024 ** 4)).toBe('3.00 TB')
})
})
describe('GB ↔ bytes 换算', () => {
it('GB → bytes 取整;负数/非法兜底 0;bytes → GB 回显', () => {
expect(gbToBytes(1)).toBe(1024 ** 3)
expect(gbToBytes(0.001)).toBe(Math.round(0.001 * 1024 ** 3))
expect(gbToBytes(null)).toBe(0)
expect(gbToBytes(-5)).toBe(0)
expect(bytesToGb(null)).toBeNull()
expect(bytesToGb(2 * 1024 ** 3)).toBe(2)
})
})
describe('阈值表单 ↔ 契约载荷', () => {
it('null 阈值 → 关闭态草稿;开启态回填 GB 值', () => {
const draft = draftFromThresholds({ storageBytes: null, perAlbumBytes: 2 * 1024 ** 3 })
expect(draft.storage).toEqual({ enabled: false, gb: null })
expect(draft.perAlbum).toEqual({ enabled: true, gb: 2 })
})
it('开启 → bytes;关闭 → 显式 null 清除(告警停用);未填值兜底 0', () => {
const payload = buildThresholdsPayload({
storage: { enabled: true, gb: 10 },
perAlbum: { enabled: false, gb: null }
})
expect(payload).toEqual({ storageBytes: 10 * 1024 ** 3, perAlbumBytes: null })
expect(
buildThresholdsPayload({
storage: { enabled: true, gb: null },
perAlbum: { enabled: true, gb: 0.5 }
})
).toEqual({ storageBytes: 0, perAlbumBytes: Math.round(0.5 * 1024 ** 3) })
})
})
describe('告警条目展示态', () => {
it('未解决 danger(红)/ 已解决 info(灰)', () => {
expect(alertTagType({ resolvedAt: null })).toBe('danger')
expect(alertTagType({ resolvedAt: '2026-09-01T00:00:00.000Z' })).toBe('info')
expect(isAlertActive({ resolvedAt: null })).toBe(true)
expect(isAlertActive({ resolvedAt: '2026-09-01T00:00:00.000Z' })).toBe(false)
})
})
@@ -0,0 +1,66 @@
/**
* 用量页纯逻辑(P4a Task 9 / A23,无组件依赖供 vitest 覆盖)
*
* - GB ↔ bytes 换算:阈值表单以 GB 为单位(运营口径),契约传输 bytes;
* GB 输入按 1024^3 取整(契约 integer)
* - 阈值载荷:开关关闭=显式 null(清除该维度=告警停用);开启=bytes。
* 两维度一并提交(对未触碰的维度等值重放,幂等无噪音——服务端只在
* 值变化时评估出告警差异)
* - 告警条目 → 展示态:未解决 danger / 已解决 info(灰)
*/
/** 1 GB 的字节数(阈值换算基准) */
export const BYTES_PER_GB = 1024 * 1024 * 1024
/** GB 输入 → bytes(负数/非法值兜底 0;契约 minimum: 0) */
export function gbToBytes(gb: number | null | undefined): number {
if (gb === null || gb === undefined || Number.isNaN(gb) || gb <= 0) return 0
return Math.round(gb * BYTES_PER_GB)
}
/** bytes → GB 展示值(null → null:未设置态回填表单) */
export function bytesToGb(bytes: number | null): number | null {
if (bytes === null) return null
return bytes / BYTES_PER_GB
}
/** 阈值表单草稿(开关表达「启用/清除」,输入以 GB 为单位) */
export interface ThresholdFieldDraft {
enabled: boolean
/** GB 值(enabled 时应 > 0) */
gb: number | null
}
export interface ThresholdsDraft {
storage: ThresholdFieldDraft
perAlbum: ThresholdFieldDraft
}
/** 当前阈值 → 表单草稿(null → 关闭态) */
export function draftFromThresholds(thresholds: Api.Usage.Thresholds): ThresholdsDraft {
return {
storage: { enabled: thresholds.storageBytes !== null, gb: bytesToGb(thresholds.storageBytes) },
perAlbum: {
enabled: thresholds.perAlbumBytes !== null,
gb: bytesToGb(thresholds.perAlbumBytes)
}
}
}
/** 表单草稿 → PUT 载荷(关闭=显式 null 清除;开启但未填值兜底 0) */
export function buildThresholdsPayload(draft: ThresholdsDraft): Api.Usage.ThresholdsUpdatePayload {
return {
storageBytes: draft.storage.enabled ? gbToBytes(draft.storage.gb) : null,
perAlbumBytes: draft.perAlbum.enabled ? gbToBytes(draft.perAlbum.gb) : null
}
}
/** 告警 → ElTag type(未解决 danger 红 / 已解决 info 灰) */
export function alertTagType(alert: Pick<Api.Usage.AlertItem, 'resolvedAt'>) {
return alert.resolvedAt === null ? 'danger' : 'info'
}
/** 告警是否未解决 */
export function isAlertActive(alert: Pick<Api.Usage.AlertItem, 'resolvedAt'>): boolean {
return alert.resolvedAt === null
}
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import {
buildWatermarkPayload,
defaultWatermarkForm,
formFromConfig,
hasRunningWatermarkJob,
previewTextStyle,
watermarkProgressPercent
} from './watermarkForm'
describe('buildWatermarkPayload 表单 → 契约载荷', () => {
it('关闭态仅送 {enabled:false}(契约:其余字段此时被忽略)', () => {
const form = { ...defaultWatermarkForm(), textContent: '遗留文字' }
expect(buildWatermarkPayload(form)).toEqual({ enabled: false })
})
it('开启 + 文字:全量参数 + text(content 去首尾空白)', () => {
const form = {
...defaultWatermarkForm(),
enabled: true,
position: 'topRight' as const,
opacity: 70,
scalePercent: 30,
textContent: ' © Photo Live ',
fontSize: 32
}
expect(buildWatermarkPayload(form)).toEqual({
enabled: true,
position: 'topRight',
opacity: 70,
scalePercent: 30,
text: { content: '© Photo Live', fontSize: 32 }
})
})
it('开启但文字为空:不送 text(服务端要求 content 非空)', () => {
const form = { ...defaultWatermarkForm(), enabled: true, textContent: ' ' }
const payload = buildWatermarkPayload(form)
expect(payload.enabled).toBe(true)
expect(payload.text).toBeUndefined()
})
it('GET current 回包(缺省字段)→ 表单填齐服务端默认口径', () => {
const form = formFromConfig({ enabled: true, text: { content: 'x' } })
expect(form).toEqual({
enabled: true,
position: 'bottomRight',
opacity: 50,
scalePercent: 20,
textContent: 'x',
fontSize: 24
})
})
})
describe('hasRunningWatermarkJob 轮询决策', () => {
it('任一 pending/running → 继续轮询;全终态 → 停', () => {
const job = (state: Api.Album.WatermarkJobState): Api.Album.WatermarkJob => ({
jobId: 'j1',
configVersion: 1,
state,
progress: 0,
total: 3,
failed: 0,
createdAt: '2026-09-01T00:00:00.000Z'
})
expect(hasRunningWatermarkJob([job('pending'), job('done')])).toBe(true)
expect(hasRunningWatermarkJob([job('running')])).toBe(true)
expect(hasRunningWatermarkJob([job('done'), job('failed')])).toBe(false)
expect(hasRunningWatermarkJob([])).toBe(false)
})
it('进度百分比:total=0 视为 100(空相册即建即 done)', () => {
const base = { jobId: 'j', configVersion: 1, failed: 0, createdAt: '' }
expect(watermarkProgressPercent({ ...base, state: 'running', progress: 0, total: 0 })).toBe(100)
expect(watermarkProgressPercent({ ...base, state: 'running', progress: 1, total: 4 })).toBe(25)
// progress 可超 total(发布中途新照片纳入处理)——封顶 100
expect(watermarkProgressPercent({ ...base, state: 'running', progress: 5, total: 4 })).toBe(100)
})
})
describe('previewTextStyle 示意预览样式映射', () => {
it('不透明度百分比 → CSS opacity;位置锚点随 position 变化', () => {
const style = previewTextStyle({
...defaultWatermarkForm(),
enabled: true,
opacity: 50,
position: 'bottomRight'
})
expect(style.opacity).toBe('0.5')
expect(style.bottom).toBe('8px')
expect(style.right).toBe('8px')
const center = previewTextStyle({
...defaultWatermarkForm(),
position: 'center'
})
expect(center.transform).toBe('translate(-50%, -50%)')
})
})
@@ -0,0 +1,108 @@
/**
* 水印卡纯逻辑(P4a Task 9,无组件依赖供 vitest 覆盖)
*
* - 表单草稿与缺省值:对齐服务端缺省(position=bottomRight / opacity=50 /
* scalePercent=20 / fontSize=24,watermark e2e ① 的填齐口径)——GET current
* 回包可直接喂表单,POST 载荷保持全量显式(值即所见)
* - 载荷构建:enabled=false 仅送 {enabled:false}(契约:其余字段此时被忽略);
* enabled=true 且文字非空才送 text(服务端要求 content 必填非空)
* - 轮询决策:任一任务 pending/running 即继续轮询(3s),全终态即停
* - 预览样式映射:CSS 叠加示意(位置/不透明度/字号),明确标注非实际渲染
*/
/** 水印表单草稿(全量字段;UI 可用性由 enabled 派生) */
export interface WatermarkFormState {
enabled: boolean
position: Api.Album.WatermarkPosition
opacity: number
scalePercent: number
textContent: string
fontSize: number
}
/** 服务端缺省对齐的表单初值(从未配置时) */
export function defaultWatermarkForm(): WatermarkFormState {
return {
enabled: false,
position: 'bottomRight',
opacity: 50,
scalePercent: 20,
textContent: '',
fontSize: 24
}
}
/** 当前生效配置 → 表单草稿(GET current 回包适配) */
export function formFromConfig(config: Api.Album.WatermarkConfig): WatermarkFormState {
return {
enabled: config.enabled,
position: config.position ?? 'bottomRight',
opacity: config.opacity ?? 50,
scalePercent: config.scalePercent ?? 20,
textContent: config.text?.content ?? '',
fontSize: config.text?.fontSize ?? 24
}
}
/** 表单 → 请求载荷(enabled=false 仅送开关;文字非空才送 text) */
export function buildWatermarkPayload(form: WatermarkFormState): Api.Album.WatermarkConfig {
if (!form.enabled) return { enabled: false }
const content = form.textContent.trim()
return {
enabled: true,
position: form.position,
opacity: form.opacity,
scalePercent: form.scalePercent,
...(content !== '' ? { text: { content, fontSize: form.fontSize } } : {})
}
}
/** 是否仍有进行中的任务(pending/running → 继续轮询) */
export function hasRunningWatermarkJob(jobs: Api.Album.WatermarkJob[]): boolean {
return jobs.some((job) => job.state === 'pending' || job.state === 'running')
}
/** 任务进度百分比(total=0 视为 100——空相册 POST 即建即 done) */
export function watermarkProgressPercent(job: Api.Album.WatermarkJob): number {
if (job.total <= 0) return 100
return Math.min(100, Math.round((job.progress / job.total) * 100))
}
/** 预览框宽度(px;示意尺寸,等比缩放实际效果) */
export const WATERMARK_PREVIEW_WIDTH = 280
/** 预览框高度(px) */
export const WATERMARK_PREVIEW_HEIGHT = 180
/** 位置 → 预览叠加层的定位方式(absolute 锚点) */
export function previewPositionStyle(
position: Api.Album.WatermarkPosition
): Record<string, string> {
switch (position) {
case 'topLeft':
return { top: '8px', left: '8px' }
case 'topRight':
return { top: '8px', right: '8px' }
case 'bottomLeft':
return { bottom: '8px', left: '8px' }
case 'bottomRight':
return { bottom: '8px', right: '8px' }
case 'center':
return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }
}
}
/**
* 表单 → 预览文字叠加样式(CSS 示意,非 sharp 实际渲染)。
* 字号按预览框与常用浏览图宽(1400px)等比缩放;scalePercent 体现为
* 文字最大宽度占预览框的百分比。
*/
export function previewTextStyle(form: WatermarkFormState): Record<string, string> {
const scale = WATERMARK_PREVIEW_WIDTH / 1400
return {
...previewPositionStyle(form.position),
opacity: String(form.opacity / 100),
fontSize: `${Math.max(8, Math.round(form.fontSize * scale))}px`,
maxWidth: `${form.scalePercent}%`,
position: 'absolute'
}
}