546 lines
21 KiB
TypeScript
546 lines
21 KiB
TypeScript
type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'>
|
||
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
|
||
type StoredNavigation = { view: NavigationView; listId: string }
|
||
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'settings'])
|
||
|
||
export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation {
|
||
try {
|
||
const value = JSON.parse(storage.getItem(key) ?? 'null')
|
||
if (value && NAVIGATION_VIEWS.has(value.view) && typeof value.listId === 'string') {
|
||
return { view: value.view, listId: value.listId }
|
||
}
|
||
} catch { /* storage may be unavailable or invalid */ }
|
||
return { view: defaultView(), listId: '' }
|
||
}
|
||
|
||
export function writeStoredNavigation(storage: BooleanStorage, key: string, view: NavigationView, listId: string) {
|
||
try { storage.setItem(key, JSON.stringify({ view, listId })) } catch { /* storage may be unavailable */ }
|
||
}
|
||
|
||
export function readStoredBoolean(storage: BooleanStorage, key: string, fallback: boolean) {
|
||
try {
|
||
const value = storage.getItem(key)
|
||
if (value === 'true') return true
|
||
if (value === 'false') return false
|
||
} catch { /* storage may be unavailable */ }
|
||
return fallback
|
||
}
|
||
|
||
export function writeStoredBoolean(storage: BooleanStorage, key: string, value: boolean) {
|
||
try { storage.setItem(key, String(value)) } catch { /* storage may be unavailable */ }
|
||
}
|
||
|
||
export function dateKey(date: Date) {
|
||
const y = date.getFullYear()
|
||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||
const d = `${date.getDate()}`.padStart(2, '0')
|
||
return `${y}-${m}-${d}`
|
||
}
|
||
|
||
export function habitWeek(now = new Date()) {
|
||
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7))
|
||
return Array.from({ length: 7 }, (_, index) => {
|
||
const date = new Date(monday)
|
||
date.setDate(monday.getDate() + index)
|
||
return date
|
||
})
|
||
}
|
||
|
||
export function mergePage<T>(page: T[] | { items?: T[]; next_cursor?: string | null }) {
|
||
if (Array.isArray(page)) return { items: page, nextCursor: null }
|
||
return { items: page.items ?? [], nextCursor: page.next_cursor ?? null }
|
||
}
|
||
|
||
export function isTaskView(view: string) {
|
||
return view === 'tasks' || view === 'today' || view === 'upcoming'
|
||
}
|
||
|
||
export function defaultView() {
|
||
return 'today' as const
|
||
}
|
||
|
||
export function quickTaskFields(view: string, activeList: string, inboxList: string, now = new Date()) {
|
||
if (view !== 'today') return { list_id: activeList }
|
||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||
timeZone: 'Asia/Shanghai',
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
}).formatToParts(now)
|
||
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value
|
||
return {
|
||
list_id: inboxList,
|
||
due_at: `${value('year')}-${value('month')}-${value('day')}T12:00:00+08:00`,
|
||
}
|
||
}
|
||
|
||
export function isHabitComplete(kind: string | undefined, value: number | boolean | undefined, target = 1) {
|
||
if (kind === 'numeric') return Number(value ?? 0) >= target
|
||
return Boolean(value)
|
||
}
|
||
|
||
type HabitSchedule = {
|
||
kind?: string
|
||
cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; value?: number | boolean }>
|
||
}
|
||
|
||
export function isHabitScheduledToday(habit: HabitSchedule, day: string) {
|
||
const cell = (habit.cells ?? []).find((item) => item.day === day)
|
||
return Boolean(cell?.scheduled && !cell.paused)
|
||
}
|
||
|
||
export function nextHabitSwipeValue(kind: string | undefined, current: number | boolean | undefined, target = 1) {
|
||
const value = Number(current ?? 0)
|
||
if (kind === 'numeric') return Math.min(value + 1, target)
|
||
return value > 0 ? 0 : 1
|
||
}
|
||
|
||
export function previousHabitSwipeValue(kind: string | undefined, current: number | boolean | undefined) {
|
||
const value = Number(current ?? 0)
|
||
if (kind === 'numeric') return Math.max(0, value - 1)
|
||
return 0
|
||
}
|
||
|
||
export function habitButtonValue(kind: string | undefined, current: number | boolean | undefined, target = 1) {
|
||
if (isHabitComplete(kind, current, target)) return 0
|
||
return nextHabitSwipeValue(kind, current, target)
|
||
}
|
||
|
||
export function habitButtonNotice(kind: string | undefined, previous: number | boolean | undefined, next: number | boolean) {
|
||
if (kind === 'numeric' && Number(previous ?? 0) > 0 && Number(next) === 0) return '今日进度已重置'
|
||
if (kind !== 'numeric' && Number(previous ?? 0) > 0 && Number(next) === 0) return '已取消完成'
|
||
return Number(next) > Number(previous ?? 0) ? '已记录一次 🎉' : '已减少一次'
|
||
}
|
||
|
||
export function numericHabitInputValue(input: number | string | undefined) {
|
||
if (input === undefined || input === '') return null
|
||
const value = Number(input)
|
||
return Number.isFinite(value) && value >= 0 ? value : null
|
||
}
|
||
|
||
export function countdownDayText(days: number) {
|
||
if (days > 0) return `还有 ${days} 天`
|
||
if (days === 0) return '就是今天'
|
||
return `已经 ${Math.abs(days)} 天`
|
||
}
|
||
|
||
export function countdownKindLabel(kind: string) {
|
||
return ({ countdown: '倒数日', anniversary: '纪念日', birthday: '生日' } as Record<string, string>)[kind] ?? '倒数日'
|
||
}
|
||
|
||
export function calendarModeLabel(mode: string) {
|
||
return mode === 'lunar' ? '农历' : '公历'
|
||
}
|
||
|
||
export function shouldToggleRowSwipe(deltaX: number, deltaY: number) {
|
||
return deltaX >= 64 && deltaX > Math.abs(deltaY) * 1.5
|
||
}
|
||
|
||
export function nextTotalAfterLocalTaskAdd(total: number) {
|
||
return Math.max(0, Number(total) || 0) + 1
|
||
}
|
||
|
||
export function nextTotalAfterLocalTaskRemoval(total: number, removed = 1) {
|
||
return Math.max(0, (Number(total) || 0) - Math.max(0, removed))
|
||
}
|
||
|
||
export type TrashMutationResult =
|
||
| { mutated: false; refreshed: false; error: unknown }
|
||
| { mutated: true; refreshed: boolean }
|
||
|
||
export async function performTrashMutation(
|
||
mutate: () => Promise<unknown>,
|
||
reconcileLocal: () => void,
|
||
refresh: () => Promise<boolean>,
|
||
): Promise<TrashMutationResult> {
|
||
try {
|
||
await mutate()
|
||
} catch (error) {
|
||
return { mutated: false, refreshed: false, error }
|
||
}
|
||
reconcileLocal()
|
||
return { mutated: true, refreshed: await refresh() }
|
||
}
|
||
|
||
export function clampFabPosition(x: number, y: number, viewportWidth: number, viewportHeight: number, size = 56, margin = 14, bottomReserved = 84) {
|
||
return {
|
||
x: Math.min(Math.max(x, margin), Math.max(margin, viewportWidth - size - margin)),
|
||
y: Math.min(Math.max(y, margin), Math.max(margin, viewportHeight - size - bottomReserved)),
|
||
}
|
||
}
|
||
|
||
export function snapFabPosition(x: number, y: number, viewportWidth: number, viewportHeight: number, size = 56, margin = 14, bottomReserved = 84) {
|
||
const clamped = clampFabPosition(x, y, viewportWidth, viewportHeight, size, margin, bottomReserved)
|
||
const left = margin
|
||
const right = Math.max(margin, viewportWidth - size - margin)
|
||
const midpoint = viewportWidth / 2
|
||
return { x: clamped.x + size / 2 < midpoint ? left : right, y: clamped.y }
|
||
}
|
||
|
||
export function isFabDrag(deltaX: number, deltaY: number, threshold = 8) {
|
||
return Math.hypot(deltaX, deltaY) >= threshold
|
||
}
|
||
|
||
let habitGridCache: { week: string; habits: unknown[] } | null = null
|
||
let countdownCache: { items: unknown[]; archived: unknown[]; writtenAt: number } | null = null
|
||
let countdownInFlight: Promise<{ items: unknown[]; archived: unknown[] }> | null = null
|
||
let countdownCacheGeneration = 0
|
||
const requestGenerations = new Map<string, number>()
|
||
|
||
export function beginLatestRequest(key: string) {
|
||
const generation = (requestGenerations.get(key) ?? 0) + 1
|
||
requestGenerations.set(key, generation)
|
||
return generation
|
||
}
|
||
|
||
export function isLatestRequest(key: string, generation: number) {
|
||
return requestGenerations.get(key) === generation
|
||
}
|
||
|
||
export type RequestContext = { key: string; generation: number }
|
||
|
||
export function captureRequestContext(key: string): RequestContext {
|
||
return { key, generation: requestGenerations.get(key) ?? 0 }
|
||
}
|
||
|
||
export function commitIfRequestContextCurrent(context: RequestContext, commit: () => void) {
|
||
if (!isLatestRequest(context.key, context.generation)) return false
|
||
commit()
|
||
return true
|
||
}
|
||
|
||
export async function runLatestRequest<T>(
|
||
key: string,
|
||
request: () => Promise<T>,
|
||
callbacks: {
|
||
success: (value: T) => void
|
||
error: (reason: unknown) => void
|
||
finally: () => void
|
||
},
|
||
) {
|
||
const generation = beginLatestRequest(key)
|
||
let committed = false
|
||
try {
|
||
const value = await request()
|
||
if (isLatestRequest(key, generation)) {
|
||
callbacks.success(value)
|
||
committed = true
|
||
}
|
||
} catch (reason) {
|
||
if (isLatestRequest(key, generation)) callbacks.error(reason)
|
||
} finally {
|
||
if (isLatestRequest(key, generation)) callbacks.finally()
|
||
}
|
||
return committed
|
||
}
|
||
|
||
type MutationSuccessCallback<T> = (value: T) => void | Promise<void>
|
||
|
||
export type MutationReconciler<TContext> = {
|
||
run<T>(
|
||
mutation: () => Promise<T>,
|
||
onSuccess: (value: T) => void,
|
||
onError?: (reason: unknown) => void,
|
||
onCurrentSuccess?: MutationSuccessCallback<T>,
|
||
): Promise<boolean>
|
||
}
|
||
|
||
export function createMutationReconciler<TContext>(
|
||
currentContext: () => TContext,
|
||
sameContext: (left: TContext, right: TContext) => boolean,
|
||
refresh: () => Promise<unknown>,
|
||
): MutationReconciler<TContext> {
|
||
let dirty = 0
|
||
let reconciled = 0
|
||
let refreshLoop: Promise<void> | null = null
|
||
|
||
const reconcile = (context: TContext) => {
|
||
if (!sameContext(context, currentContext())) return Promise.resolve()
|
||
dirty += 1
|
||
if (!refreshLoop) {
|
||
refreshLoop = (async () => {
|
||
while (reconciled < dirty && sameContext(context, currentContext())) {
|
||
const target = dirty
|
||
await refresh()
|
||
if (!sameContext(context, currentContext())) break
|
||
reconciled = target
|
||
}
|
||
})().finally(() => { refreshLoop = null })
|
||
}
|
||
return refreshLoop
|
||
}
|
||
|
||
return {
|
||
async run(mutation, onSuccess, onError, onCurrentSuccess) {
|
||
const context = currentContext()
|
||
let value: Awaited<ReturnType<typeof mutation>>
|
||
try {
|
||
value = await mutation()
|
||
} catch (reason) {
|
||
onError?.(reason)
|
||
return false
|
||
}
|
||
onSuccess(value)
|
||
if (sameContext(context, currentContext())) {
|
||
const currentSuccess = onCurrentSuccess?.(value)
|
||
if (currentSuccess instanceof Promise) await currentSuccess
|
||
}
|
||
await reconcile(context)
|
||
if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context)
|
||
return true
|
||
},
|
||
}
|
||
}
|
||
|
||
export function startPrimaryWithBackground<T>(
|
||
primary: Array<() => Promise<T>>,
|
||
background: () => Promise<unknown>,
|
||
) {
|
||
const pending = primary.map((start) => start())
|
||
void background().catch(() => undefined)
|
||
return Promise.all(pending)
|
||
}
|
||
|
||
export function readHabitGridCache<T>(week: string): T[] | null {
|
||
return habitGridCache?.week === week ? habitGridCache.habits as T[] : null
|
||
}
|
||
|
||
export function writeHabitGridCache<T>(week: string, habits: T[]) {
|
||
habitGridCache = { week, habits }
|
||
}
|
||
|
||
export function invalidateHabitGridCache() {
|
||
habitGridCache = null
|
||
}
|
||
|
||
export async function performHabitRestore(actions: {
|
||
restore: () => Promise<unknown>
|
||
commitRestore: () => void
|
||
refreshGrid: () => Promise<boolean | unknown>
|
||
}) {
|
||
await actions.restore()
|
||
actions.commitRestore()
|
||
try {
|
||
const refreshed = await actions.refreshGrid()
|
||
return { restored: true as const, refreshed: refreshed !== false }
|
||
} catch {
|
||
return { restored: true as const, refreshed: false }
|
||
}
|
||
}
|
||
|
||
export function readCountdownCache<T>() {
|
||
if (!countdownCache) return null
|
||
return { items: countdownCache.items as T[], archived: countdownCache.archived as T[] }
|
||
}
|
||
|
||
export function writeCountdownCache<T>(items: T[], archived: T[], now = Date.now()) {
|
||
countdownCache = { items, archived, writtenAt: now }
|
||
}
|
||
|
||
export function invalidateCountdownCache() {
|
||
countdownCacheGeneration += 1
|
||
countdownCache = null
|
||
countdownInFlight = null
|
||
}
|
||
|
||
export function getCountdownCacheGeneration() {
|
||
return countdownCacheGeneration
|
||
}
|
||
|
||
export function isCountdownCacheGenerationCurrent(generation: number) {
|
||
return generation === countdownCacheGeneration
|
||
}
|
||
|
||
export function loadCountdownCache<T>(
|
||
fetcher: () => Promise<{ items: T[]; archived: T[] }>,
|
||
options: { now?: number; maxAge?: number; force?: boolean } = {},
|
||
) {
|
||
const now = options.now ?? Date.now()
|
||
const maxAge = options.maxAge ?? 30_000
|
||
if (!options.force && countdownCache && now - countdownCache.writtenAt < maxAge) {
|
||
return Promise.resolve(readCountdownCache<T>()!)
|
||
}
|
||
if (countdownInFlight) return countdownInFlight as Promise<{ items: T[]; archived: T[] }>
|
||
const generation = countdownCacheGeneration
|
||
const pending = fetcher().then((data) => {
|
||
if (generation === countdownCacheGeneration) writeCountdownCache(data.items, data.archived, now)
|
||
return data
|
||
}).finally(() => {
|
||
if (countdownInFlight === pending) countdownInFlight = null
|
||
})
|
||
countdownInFlight = pending as Promise<{ items: unknown[]; archived: unknown[] }>
|
||
return pending
|
||
}
|
||
|
||
export type HabitFormValues = {
|
||
name: string
|
||
kind: 'boolean' | 'numeric'
|
||
target: number
|
||
max_value?: number | null
|
||
schedule_type: 'daily' | 'weekly' | 'monthly' | 'interval'
|
||
weekdays?: number[] | null
|
||
month_days?: number[] | null
|
||
interval_days?: number | null
|
||
}
|
||
|
||
export type HabitFormErrors = Partial<Record<keyof HabitFormValues, string>>
|
||
|
||
export function normalizeRequiredName(input: string) {
|
||
const value = input.trim()
|
||
return { value, error: value ? '' : '名称不能为空,请输入至少一个可见字符。' }
|
||
}
|
||
|
||
export function validateHabitForm(form: HabitFormValues): HabitFormErrors {
|
||
const errors: HabitFormErrors = {}
|
||
if (!form.name.trim()) errors.name = '请输入习惯名称'
|
||
if (form.kind === 'numeric') {
|
||
if (!Number.isFinite(Number(form.target)) || Number(form.target) <= 0) errors.target = '目标值必须大于 0'
|
||
if (form.max_value != null && (!Number.isFinite(Number(form.max_value)) || Number(form.max_value) <= 0 || Number(form.max_value) < Number(form.target))) errors.max_value = '最大值不能小于目标值'
|
||
}
|
||
if (form.schedule_type === 'weekly') {
|
||
const values = form.weekdays ?? []
|
||
if (!values.length) errors.weekdays = '至少选择一个星期'
|
||
else if (new Set(values).size !== values.length) errors.weekdays = '星期不能重复'
|
||
}
|
||
if (form.schedule_type === 'monthly') {
|
||
const values = form.month_days ?? []
|
||
if (!values.length || values.some((day) => !Number.isInteger(day) || day < 1 || day > 31)) errors.month_days = '请输入 1 到 31 的日期'
|
||
else if (new Set(values).size !== values.length) errors.month_days = '日期不能重复'
|
||
}
|
||
if (form.schedule_type === 'interval' && (!Number.isInteger(Number(form.interval_days)) || Number(form.interval_days) < 1)) errors.interval_days = '间隔天数至少为 1'
|
||
return errors
|
||
}
|
||
|
||
export function changedHabitFields(before: HabitFormValues, after: HabitFormValues) {
|
||
const result: Partial<HabitFormValues> = {}
|
||
for (const key of Object.keys(after) as Array<keyof HabitFormValues>) {
|
||
const oldValue = before[key]
|
||
const newValue = after[key]
|
||
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) (result as Record<string, unknown>)[key] = newValue
|
||
}
|
||
return result
|
||
}
|
||
|
||
export function habitActionState(cell?: { scheduled?: boolean; paused?: boolean }, archived = false) {
|
||
if (archived) return { writable: false, reason: '该习惯已归档' }
|
||
if (cell?.paused) return { writable: false, reason: '今天已暂停' }
|
||
if (!cell?.scheduled) return { writable: false, reason: '今天未安排' }
|
||
return { writable: true, reason: '' }
|
||
}
|
||
|
||
export type ArchivePanelState = 'idle' | 'loading' | 'success' | 'error'
|
||
|
||
export function archivePanelFlags(state: ArchivePanelState, count: number) {
|
||
return {
|
||
loading: state === 'loading',
|
||
error: state === 'error',
|
||
empty: state === 'success' && count === 0,
|
||
list: state === 'success' && count > 0,
|
||
}
|
||
}
|
||
|
||
export function formatArchivedAt(value?: string | null, options: { timeZone?: string } = {}) {
|
||
const formatted = formatLocalShortDateTime(value, options)
|
||
return formatted === '未知时间' ? '归档时间未知' : `归档于 ${formatted}`
|
||
}
|
||
|
||
export function formatUserAgent(userAgent?: string | null): string {
|
||
const ua = userAgent?.trim() ?? ''
|
||
let device = '未知设备'
|
||
if (/iPhone/i.test(ua)) device = 'iPhone'
|
||
else if (/iPad/i.test(ua) || (/Macintosh/i.test(ua) && /Mobile/i.test(ua))) device = 'iPad'
|
||
else if (/Android/i.test(ua) && /Mobile/i.test(ua)) device = 'Android 手机'
|
||
else if (/Android/i.test(ua)) device = 'Android 平板'
|
||
else if (/Windows/i.test(ua)) device = 'Windows'
|
||
else if (/Macintosh|Mac OS X/i.test(ua)) device = 'Mac'
|
||
else if (/Linux/i.test(ua)) device = 'Linux'
|
||
|
||
let browser = '未知浏览器'
|
||
if (/Edg(?:e|iOS|A)?\//i.test(ua)) browser = 'Edge'
|
||
else if (/Chrome\/|CriOS\//i.test(ua)) browser = 'Chrome'
|
||
else if (/Firefox\/|FxiOS\//i.test(ua)) browser = 'Firefox'
|
||
else if (/Safari\//i.test(ua) && /Version\//i.test(ua)) browser = 'Safari'
|
||
return `${device} · ${browser}`
|
||
}
|
||
|
||
export function formatLocalShortDateTime(value?: string | null, options: { timeZone?: string } = {}): string {
|
||
const source = value?.trim()
|
||
if (!source) return '未知时间'
|
||
const calendar = /^(\d{4})-(\d{2})-(\d{2})T/.exec(source)
|
||
if (calendar) {
|
||
const [, year, month, day] = calendar
|
||
const probe = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
|
||
if (probe.getUTCFullYear() !== Number(year) || probe.getUTCMonth() + 1 !== Number(month) || probe.getUTCDate() !== Number(day)) return '未知时间'
|
||
}
|
||
const timezoneLess = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/i.test(source)
|
||
const date = new Date(timezoneLess ? `${source}Z` : source)
|
||
if (Number.isNaN(date.getTime())) return '未知时间'
|
||
try {
|
||
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short', ...options }).format(date)
|
||
} catch {
|
||
return '未知时间'
|
||
}
|
||
}
|
||
|
||
const AUDIT_ACTIONS: Record<string, string> = {
|
||
create: '创建', update: '更新', complete: '完成', delete: '删除', archive: '归档', restore: '恢复', move: '移动', import: '导入',
|
||
}
|
||
const AUDIT_ENTITIES: Record<string, string> = {
|
||
task: '任务', list: '清单', folder: '文件夹', countdown: '倒数日', backup: '备份',
|
||
}
|
||
|
||
export function formatAuditAction(action?: string | null): string {
|
||
return action ? AUDIT_ACTIONS[action.toLowerCase()] ?? '其他操作' : '其他操作'
|
||
}
|
||
|
||
export function formatAuditEntity(entityType?: string | null): string {
|
||
return entityType ? AUDIT_ENTITIES[entityType.toLowerCase()] ?? '内容' : '内容'
|
||
}
|
||
|
||
export function formatHabitApiError(detail: unknown): string {
|
||
const source = detail && typeof detail === 'object' && !Array.isArray(detail) && 'detail' in detail
|
||
? (detail as Record<string, unknown>).detail
|
||
: detail
|
||
const text = typeof source === 'string' ? source : ''
|
||
const mappings: Array<[string, string]> = [
|
||
['暂停日不可记录正向进度', '今天已暂停,不能记录进度。'],
|
||
['非计划日不可记录正向进度', '今天未安排该习惯,不能记录进度。'],
|
||
['归档习惯不可修改', '该习惯已归档,不能继续操作。'],
|
||
['请先归档再永久删除', '请先归档该习惯,再永久删除。'],
|
||
]
|
||
for (const [needle, message] of mappings) if (text.includes(needle)) return message
|
||
if (Array.isArray(source)) {
|
||
const field = source.map((item) => item && typeof item === 'object' && Array.isArray((item as Record<string, unknown>).loc) ? ((item as Record<string, unknown>).loc as unknown[]).at(-1) : '').find(Boolean)
|
||
const fieldMessages: Record<string, string> = {
|
||
name: '名称不能为空,请输入至少一个可见字符。', weekdays: '每周计划至少选择一天,且不能重复。', month_days: '每月日期必须是 1–31,且不能重复。', interval_days: '间隔天数至少为 1。', target: '目标值必须大于 0。', max_value: '最大值必须大于 0。',
|
||
}
|
||
if (typeof field === 'string' && fieldMessages[field]) return fieldMessages[field]
|
||
}
|
||
return formatApiErrorDetail(source)
|
||
}
|
||
|
||
export function formatApiErrorDetail(detail: unknown): string {
|
||
if (typeof detail === 'string') return detail
|
||
if (Array.isArray(detail)) {
|
||
const messages = detail.map((item) => {
|
||
if (item && typeof item === 'object') {
|
||
const record = item as Record<string, unknown>
|
||
const location = Array.isArray(record.loc)
|
||
? record.loc.filter((part) => part !== 'body').join('.')
|
||
: ''
|
||
const message = typeof record.msg === 'string' ? record.msg : JSON.stringify(record)
|
||
return location ? `${location}:${message}` : message
|
||
}
|
||
return String(item)
|
||
})
|
||
return messages.filter(Boolean).join(';') || '请求参数有误'
|
||
}
|
||
if (detail && typeof detail === 'object') {
|
||
const record = detail as Record<string, unknown>
|
||
if ('detail' in record) return formatApiErrorDetail(record.detail)
|
||
return JSON.stringify(record)
|
||
}
|
||
return '请求失败'
|
||
}
|