feat(admin): batch ops, transfer, watermark, usage and qr share pages
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user