feat(admin): album management pages with members and categories

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 12:33:46 +08:00
parent a6cc190751
commit 50c59ca9d4
27 changed files with 2977 additions and 16 deletions
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import {
toTranslationPayload,
validateTimeRange,
validateTranslations,
type TranslationDraft
} from './albumValidation'
const drafts = (
over: Partial<Record<'zh-CN' | 'en', Partial<TranslationDraft>>> = {}
): TranslationDraft[] => [
{ locale: 'zh-CN', title: '', description: null, venue: null, address: null, ...over['zh-CN'] },
{ locale: 'en', title: '', description: null, venue: null, address: null, ...over['en'] }
]
describe('validateTranslations 翻译覆盖校验', () => {
it('defaultLocale 条目缺失 title → defaultLocaleMissing', () => {
const errors = validateTranslations(drafts({ en: { title: 'Album' } }), 'zh-CN')
expect(errors).toContain('defaultLocaleMissing')
})
it('默认语言齐全 + 其他语言仅 title 缺省(无其他字段)→ 通过且不生成该条目', () => {
const errors = validateTranslations(drafts({ 'zh-CN': { title: '相册' } }), 'zh-CN')
expect(errors).toEqual([])
expect(toTranslationPayload(drafts({ 'zh-CN': { title: ' 相册 ' } }))).toEqual([
{ locale: 'zh-CN', title: '相册', description: null, venue: null, address: null }
])
})
it('title 空但 description/venue 有值 → titleRequiredWithFields', () => {
const errors = validateTranslations(
drafts({ 'zh-CN': { title: '相册' }, en: { venue: 'Grand Hotel' } }),
'zh-CN'
)
expect(errors).toEqual(['titleRequiredWithFields'])
})
})
describe('validateTimeRange 时间范围校验', () => {
it('endAt 早于或等于 startAt → 拒绝;严格晚于 → 通过;endAt 未定 → 放行', () => {
expect(validateTimeRange('2026-10-01T10:00:00Z', '2026-10-01T09:00:00Z')).toBe(false)
expect(validateTimeRange('2026-10-01T10:00:00Z', '2026-10-01T10:00:00Z')).toBe(false)
expect(validateTimeRange('2026-10-01T10:00:00Z', '2026-10-01T11:00:00Z')).toBe(true)
expect(validateTimeRange('2026-10-01T10:00:00Z', null)).toBe(true)
})
})
@@ -0,0 +1,74 @@
/**
* 相册表单校验(纯函数,镜像契约约束)
*
* 客户端先拦一层常见错误,服务端 VALIDATION_FAILED 仍是权威:
* - translations 必须包含 defaultLocale 对应条目且 title 非空(minLength 1)
* - 提交的每个翻译条目 title 均必填(未填 title 的语言不生成条目;
* 但「title 空而其他字段有值」视为漏填,给出可定位错误)
* - endAt 必须严格晚于 startAt(null 表示未定,放行)
*/
/** 校验错误码(i18n 键后缀,展示层映射 albumsManage.form.errors.*) */
export type AlbumFormErrorCode =
'defaultLocaleMissing' | 'titleRequired' | 'titleRequiredWithFields' | 'endAtNotAfterStartAt'
export interface TranslationDraft {
locale: string
title: string
description?: string | null
venue?: string | null
address?: string | null
}
const hasValue = (value: string | null | undefined): boolean => !!value && value.trim() !== ''
/**
* 校验翻译草稿覆盖:返回按序去重的错误码列表(空数组 = 通过)。
*
* @param drafts 表单内的翻译草稿(通常 zh-CN + en 两项)
* @param defaultLocale 默认语言
*/
export function validateTranslations(
drafts: TranslationDraft[],
defaultLocale: string
): AlbumFormErrorCode[] {
const errors: AlbumFormErrorCode[] = []
const defaultDraft = drafts.find((d) => d.locale === defaultLocale)
if (!defaultDraft || !hasValue(defaultDraft.title)) {
errors.push('defaultLocaleMissing')
}
for (const draft of drafts) {
const hasOtherFields = [draft.description, draft.venue, draft.address].some(hasValue)
if (!hasValue(draft.title)) {
if (draft.locale === defaultLocale || hasOtherFields) {
errors.push(hasOtherFields ? 'titleRequiredWithFields' : 'titleRequired')
}
}
}
return [...new Set(errors)]
}
/**
* 校验时间范围:endAt 必须严格晚于 startAt。
* 任意一端缺失(表单层必填校验另行处理)或 endAt 为 null(未定)时放行。
*/
export function validateTimeRange(startAt: string | null, endAt: string | null): boolean {
if (!startAt || !endAt) return true
return new Date(endAt).getTime() > new Date(startAt).getTime()
}
/** 从草稿提炼可提交的 translations:只保留 title 非空的语言条目 */
export function toTranslationPayload<T extends TranslationDraft>(drafts: T[]) {
return drafts
.filter((d) => hasValue(d.title))
.map((d) => ({
locale: d.locale,
title: d.title.trim(),
description: hasValue(d.description) ? (d.description as string).trim() : null,
venue: hasValue(d.venue) ? (d.venue as string).trim() : null,
address: hasValue(d.address) ? (d.address as string).trim() : null
}))
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import {
INITIAL_ASSET_UPLOAD_STATE,
nextAssetUploadState,
type AssetUploadEvent
} from './assetUpload'
const run = (events: AssetUploadEvent[]) =>
events.reduce(nextAssetUploadState, INITIAL_ASSET_UPLOAD_STATE)
describe('assetUpload 状态机', () => {
it('happy path:START→INIT_OK→PUT_OK→COMPLETE_OK 进入 done 并保留签名预览', () => {
const state = run([
{ type: 'START' },
{ type: 'INIT_OK' },
{ type: 'PUT_OK' },
{ type: 'COMPLETE_OK', url: 'http://minio/signed', expiresAt: '2026-10-01T00:00:00Z' }
])
expect(state.stage).toBe('done')
expect(state.previewUrl).toBe('http://minio/signed')
expect(state.previewExpiresAt).toBe('2026-10-01T00:00:00Z')
})
it('PUT 失败 → error 携带失败阶段,可重新 START', () => {
const state = run([
{ type: 'START' },
{ type: 'INIT_OK' },
{ type: 'FAIL', stage: 'put', message: 'network' }
])
expect(state.stage).toBe('error')
expect(state.failedStage).toBe('put')
expect(state.error).toBe('network')
expect(nextAssetUploadState(state, { type: 'START' }).stage).toBe('initializing')
})
it('迟到响应不污染新会话:RESET 后的 INIT_OK/PUT_OK 被忽略', () => {
const state = run([{ type: 'START' }, { type: 'INIT_OK' }])
const reset = nextAssetUploadState(state, { type: 'RESET' })
expect(reset.stage).toBe('idle')
// RESET 之前发出的迟到完成事件不得把 idle 推进到 done
expect(nextAssetUploadState(reset, { type: 'PUT_OK' }).stage).toBe('idle')
expect(
nextAssetUploadState(reset, { type: 'COMPLETE_OK', url: 'x', expiresAt: 'y' }).stage
).toBe('idle')
})
})
@@ -0,0 +1,78 @@
/**
* 资产预签名上传状态机(纯函数 reducer)
*
* 三段链路 init(POST /assets 预签名)→ PUT(staging 直传)→ complete
* (核验生效并返回签名 URL)。任何一步失败进入 error(携带阶段与原因,
* 可重新开始);complete 幂等,成功态保留签名 URL 与过期时间用于预览。
* 抽成纯 reducer 以便单测与 UI 解耦。
*/
export type AssetUploadStage =
'idle' | 'initializing' | 'uploading' | 'completing' | 'done' | 'error'
export interface AssetUploadState {
stage: AssetUploadStage
/** 失败发生的阶段(error 态定位重试入口) */
failedStage?: 'init' | 'put' | 'complete'
/** 错误消息(已本地化/服务端文案) */
error?: string
/** complete 返回的签名预览 URL */
previewUrl?: string
/** 签名 URL 过期时间(ISO) */
previewExpiresAt?: string
}
export type AssetUploadEvent =
| { type: 'START' }
| { type: 'INIT_OK' }
| { type: 'PUT_OK' }
| { type: 'COMPLETE_OK'; url: string; expiresAt: string }
| { type: 'FAIL'; stage: 'init' | 'put' | 'complete'; message?: string }
| { type: 'RESET' }
export const INITIAL_ASSET_UPLOAD_STATE: AssetUploadState = { stage: 'idle' }
/**
* 状态转移:非法转移(如 idle 收到 PUT_OK)保持原状——UI 侧的迟到响应
* 不得污染已重置的新会话。
*/
export function nextAssetUploadState(
state: AssetUploadState,
event: AssetUploadEvent
): AssetUploadState {
switch (event.type) {
case 'START':
if (state.stage === 'idle' || state.stage === 'done' || state.stage === 'error') {
return { stage: 'initializing' }
}
return state
case 'INIT_OK':
if (state.stage === 'initializing') return { stage: 'uploading' }
return state
case 'PUT_OK':
if (state.stage === 'uploading') return { stage: 'completing' }
return state
case 'COMPLETE_OK':
if (state.stage === 'completing') {
return {
stage: 'done',
previewUrl: event.url,
previewExpiresAt: event.expiresAt
}
}
return state
case 'FAIL':
if (
state.stage === 'initializing' ||
state.stage === 'uploading' ||
state.stage === 'completing'
) {
return { stage: 'error', failedStage: event.stage, error: event.message }
}
return state
case 'RESET':
return INITIAL_ASSET_UPLOAD_STATE
default:
return state
}
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { accumulatePage, firstPage, type CursorPage } from './cursorList'
const pageOf = <T>(items: T[], nextCursor: string | null): CursorPage<T> => ({
items,
nextCursor,
hasMore: nextCursor !== null
})
describe('cursorList 游标累积', () => {
it('首页整页替换并保留游标', () => {
const state = firstPage(pageOf([1, 2], 'c1'))
expect(state.items).toEqual([1, 2])
expect(state.nextCursor).toBe('c1')
expect(state.hasMore).toBe(true)
})
it('加载更多按序追加,nextCursor 为 null 即到底', () => {
let state = firstPage(pageOf([1, 2], 'c1'))
state = accumulatePage(state, pageOf([3], 'c2'))
expect(state.items).toEqual([1, 2, 3])
expect(state.hasMore).toBe(true)
state = accumulatePage(state, pageOf([4], null))
expect(state.items).toEqual([1, 2, 3, 4])
expect(state.hasMore).toBe(false)
})
it('keyOf 去重:跨页重复行不重复 append', () => {
let state = firstPage(pageOf([{ id: 'a' }, { id: 'b' }], 'c1'))
state = accumulatePage(state, pageOf([{ id: 'b' }, { id: 'c' }], null), (x) => x.id)
expect(state.items.map((x) => x.id)).toEqual(['a', 'b', 'c'])
})
})
+67
View File
@@ -0,0 +1,67 @@
/**
* 游标分页累积(纯函数)
*
* 契约列表端点(相册/成员/管理照片)一律 nextCursor 键集分页,而模板
* useTable 假设 offset 分页(current/size/total 并整页替换 data)——两者
* 语义不兼容。此处以「页内累积 + 加载更多」适配:每次把新页 append 到
* 已有行,nextCursor 为 null 即到底。保持纯函数以便单测。
*/
/** 契约游标页形状(CursorPageMeta + items) */
export interface CursorPage<T> {
items: T[]
nextCursor: string | null
hasMore: boolean
}
/** 累积状态:已加载行 + 下一页游标 */
export interface CursorState<T> {
items: T[]
nextCursor: string | null
hasMore: boolean
}
export const EMPTY_CURSOR_STATE: CursorState<never> = {
items: [],
nextCursor: null,
hasMore: false
}
/** 首页:整页替换(重置语义) */
export function firstPage<T>(page: CursorPage<T>): CursorState<T> {
return { items: [...page.items], nextCursor: page.nextCursor, hasMore: page.hasMore }
}
/**
* 追加一页(加载更多语义)
*
* @param state 当前累积状态
* @param page 新拉取的一页
* @param keyOf 行唯一键(可选):服务端排序键含 id,正常不会跨页重复;
* 传入时防御性去重(重复行以旧位置为准,不重复 append)
*/
export function accumulatePage<T>(
state: CursorState<T>,
page: CursorPage<T>,
keyOf?: (item: T) => string
): CursorState<T> {
if (!keyOf) {
return {
items: [...state.items, ...page.items],
nextCursor: page.nextCursor,
hasMore: page.hasMore
}
}
const seen = new Set(state.items.map(keyOf))
const appended = page.items.filter((item) => {
const key = keyOf(item)
if (seen.has(key)) return false
seen.add(key)
return true
})
return {
items: [...state.items, ...appended],
nextCursor: page.nextCursor,
hasMore: page.hasMore
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 相册页展示格式化(无第三方日期库:Intl 够用且随语言环境走)
*/
/** ISO 时间 → 本地展示(yyyy-MM-dd HH:mm) */
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '-'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
/** 字节数 → 人类可读 */
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`
}
+3
View File
@@ -217,6 +217,9 @@ const api = {
post<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'POST' })
},
patch<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'PATCH' })
},
put<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'PUT' })
},