feat(admin): album management pages with members and categories
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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' })
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user