type BooleanStorage = Pick type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings' type StoredNavigation = { view: NavigationView; listId: string } const NAVIGATION_VIEWS = new Set(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'calendar', '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(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) } export type HabitHistoryLog = { day: string; value: number } export function habitHistoryWindow(toDay: string, size = 90) { const [year, month, day] = toDay.split('-').map(Number) const to = new Date(year, month - 1, day) const from = new Date(to) from.setDate(from.getDate() - (size - 1)) return { from: dateKey(from), to: dateKey(to) } } export function dayBefore(day: string) { const [year, month, date] = day.split('-').map(Number) const value = new Date(year, month - 1, date) value.setDate(value.getDate() - 1) return dateKey(value) } export function mergeHabitHistory(previous: HabitHistoryLog[], incoming: HabitHistoryLog[]) { const merged = new Map(previous.map((item) => [item.day, item])) for (const item of incoming) merged.set(item.day, { day: item.day, value: Number(item.value) }) return [...merged.values()].sort((a, b) => b.day.localeCompare(a.day)) } export function formatHabitHistoryNumber(value: number) { return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6))) } type HabitDetailFields = { kind?: string target?: number unit?: string archived_at?: string | null schedule_type?: 'daily' | 'weekly' | 'monthly' | 'interval' weekdays?: number[] | null month_days?: number[] | null interval_days?: number | null } export function formatHabitRecordMode(habit: HabitDetailFields) { if (habit.kind !== 'numeric') return '完成 / 未完成' const unit = habit.unit ? ` ${habit.unit}` : '' return `按数量记录 · 目标 ${formatHabitHistoryNumber(habit.target ?? 1)}${unit}` } export function formatHabitSchedule(habit: HabitDetailFields) { if (habit.schedule_type === 'weekly') { const weekdays = habit.weekdays ?? [] if (!weekdays.length) return '每周(日期未设置)' const labels = ['一', '二', '三', '四', '五', '六', '日'] return `每${weekdays.map((day) => `周${labels[day] ?? day}`).join('、')}` } if (habit.schedule_type === 'monthly') { const monthDays = habit.month_days ?? [] return monthDays.length ? `每月 ${monthDays.join('、')} 日` : '每月(日期未设置)' } if (habit.schedule_type === 'interval') { return habit.interval_days ? `每 ${habit.interval_days} 天` : '按间隔(天数未设置)' } return '每天' } export function formatHabitDetailDate(day?: string) { if (!day) return '未提供' const [year, month, date] = day.split('-').map(Number) if (!year || !month || !date) return day return `${year}年${month}月${date}日` } export function formatHabitDetailProgress(habit: Pick & { value?: number | boolean }) { if (habit.archived_at) return null const target = habit.target ?? 1 const value = Number(habit.value ?? 0) if (habit.kind !== 'numeric') { const complete = Boolean(habit.value) return { primary: complete ? '已完成' : '未完成', note: complete ? '今日目标已达成' : '今天尚未完成', semantic: complete ? '已完成' : '未完成' } } const complete = value >= target return { primary: `${formatHabitHistoryNumber(value)} / ${formatHabitHistoryNumber(target)}`, note: complete ? '今日目标已达成' : `今天还差 ${formatHabitHistoryNumber(Math.max(0, target - value))}${habit.unit ? ` ${habit.unit}` : ''}`, semantic: complete ? '已达标' : `${Math.round(target > 0 ? value / target * 100 : 0)}%`, } } 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)[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, reconcileLocal: () => void, refresh: () => Promise, ): Promise { try { await mutate() } catch (error) { return { mutated: false, refreshed: false, error } } reconcileLocal() return { mutated: true, refreshed: await refresh() } } export function fabBottomReserved(safeAreaBottom = 0, navigationHeight = 56, gap = 12) { return navigationHeight + Math.max(0, safeAreaBottom) + gap } 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() 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( key: string, request: () => Promise, 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 MutationOwnership = { current: () => boolean } type LatestMutationCallbacks = { success?: (value: T, ownership: MutationOwnership) => void | Promise error?: (reason: unknown, ownership: MutationOwnership) => void | Promise settled?: (result: { ok: true; value: T } | { ok: false; reason: unknown }, ownership: MutationOwnership) => void | Promise } export function createKeyedLatestMutationQueue() { type Entry = { generation: number; tail: Promise } const entries = new Map() return { size: () => entries.size, async run(key: string, mutation: () => Promise, callbacks: LatestMutationCallbacks = {}) { let entry = entries.get(key) if (!entry) { entry = { generation: 0, tail: Promise.resolve() } entries.set(key, entry) } const generation = ++entry.generation const ownership = { current: () => entries.get(key) === entry && entry.generation === generation } const pending = entry.tail.catch(() => undefined).then(mutation) const tail = pending.then(() => undefined, () => undefined) entry.tail = tail try { const value = await pending await callbacks.settled?.({ ok: true, value }, ownership) if (!ownership.current()) return false await callbacks.success?.(value, ownership) return ownership.current() } catch (reason) { await callbacks.settled?.({ ok: false, reason }, ownership) if (ownership.current()) await callbacks.error?.(reason, ownership) return false } finally { if (entries.get(key) === entry && entry.generation === generation && entry.tail === tail) entries.delete(key) } }, } } export function createHabitMutationCoordinator() { type Entry = { generation: number; tail: Promise; confirmed: T } const entries = new Map() return { size: () => entries.size, async run( key: string, previous: T, next: T, mutation: () => Promise, callbacks: { current?: () => boolean reconcile?: (confirmed: T) => void | Promise success?: (ownership: MutationOwnership) => void | Promise error?: (rollback: T, reason: unknown, ownership: MutationOwnership) => void | Promise } = {}, ) { let entry = entries.get(key) if (!entry) { entry = { generation: 0, tail: Promise.resolve(), confirmed: previous } entries.set(key, entry) } const generation = ++entry.generation const ownership = { current: () => entries.get(key) === entry && entry.generation === generation && callbacks.current?.() !== false, } const pending = entry.tail.catch(() => undefined).then(mutation) const tail = pending.then(() => undefined, () => undefined) entry.tail = tail let result: { ok: true } | { ok: false; reason: unknown } try { await pending result = { ok: true } } catch (reason) { result = { ok: false, reason } } try { if (result.ok) { entry.confirmed = next await callbacks.reconcile?.(entry.confirmed) if (!ownership.current()) return false await callbacks.success?.(ownership) return ownership.current() } await callbacks.reconcile?.(entry.confirmed) if (ownership.current()) await callbacks.error?.(entry.confirmed, result.reason, ownership) return false } finally { if (entries.get(key) === entry && entry.generation === generation && entry.tail === tail) entries.delete(key) } }, } } export function createTaskToggleCoordinator() { type Entry = { tail: Promise; intendedCompleted: boolean; confirmed: T; generation: number } const entries = new Map() return { size: () => entries.size, async toggle( key: string, initial: T, mutation: (payload: { completed: boolean; version: number }) => Promise, callbacks: { current?: () => boolean beforeReconcile?: (value: T, ownership: MutationOwnership) => void | Promise reconcile?: (confirmed: T, ownership: MutationOwnership) => void | Promise success?: (value: T, ownership: MutationOwnership) => void | Promise error?: (confirmed: T, reason: unknown, ownership: MutationOwnership) => void | Promise } = {}, ) { let entry = entries.get(key) if (!entry) { entry = { tail: Promise.resolve(), intendedCompleted: initial.completed, confirmed: initial, generation: 0 } entries.set(key, entry) } entry.intendedCompleted = !entry.intendedCompleted const intendedCompleted = entry.intendedCompleted const generation = ++entry.generation const ownership = { current: () => entry!.generation === generation && callbacks.current?.() !== false } const pending = entry.tail.then(async () => { const value = await mutation({ completed: intendedCompleted, version: entry!.confirmed.version }) entry!.confirmed = value return value }) entry.tail = pending.then(() => undefined, () => undefined) try { let value: T try { value = await pending } catch (reason) { await callbacks.reconcile?.(entry.confirmed, ownership) if (ownership.current()) await callbacks.error?.(entry.confirmed, reason, ownership) return false } if (ownership.current()) await callbacks.beforeReconcile?.(value, ownership) await callbacks.reconcile?.(value, ownership) if (!ownership.current()) return false await callbacks.success?.(value, ownership) return ownership.current() } finally { if (entry.generation === generation && entries.get(key) === entry) entries.delete(key) } }, } } type CurrentViewReconciliationOptions = { capture: () => TContext isTaskBacked: (context: TContext) => boolean isTrash?: (context: TContext) => boolean sameContext: (left: TContext, right: TContext) => boolean loadTaskView: (context: TContext, ownership: MutationOwnership) => Promise loadTrash?: (ownership: MutationOwnership) => Promise affectsTrash?: boolean affectsTaskView?: boolean } export async function reconcileCurrentTaskView(options: CurrentViewReconciliationOptions) { const context = options.capture() const ownership = { current: () => options.sameContext(context, options.capture()) } if (options.affectsTaskView !== false && options.isTaskBacked(context)) { await options.loadTaskView(context, ownership) return ownership.current() } if (options.affectsTrash && options.isTrash?.(context) && options.loadTrash) { await options.loadTrash(ownership) return ownership.current() } return false } export function createTaskCompletionExitCoordinator(setExit: (key: string, active: boolean) => void) { const tokens = new Map() const supersede = (key: string) => { tokens.delete(key) setExit(key, false) } const clearAll = () => { const keys = [...tokens.keys()] tokens.clear() keys.forEach((key) => setExit(key, false)) } const begin = (key: string, animate: boolean) => { tokens.delete(key) if (!animate) { setExit(key, false) return null } const token = {} tokens.set(key, token) setExit(key, true) return token } const wait = async (key: string, token: object | null, waitForExit: () => Promise) => { if (!token) return try { await waitForExit() } finally { if (tokens.get(key) === token) { tokens.delete(key) setExit(key, false) } } } return { size: () => tokens.size, supersede, clearAll, begin, wait, async run(key: string, animate: boolean, waitForExit: () => Promise) { const token = begin(key, animate) await wait(key, token, waitForExit) }, } } type TaskToggleState = { id: string list_id: string parent_id: string | null title: string description: string priority: number completed: boolean completed_at: string | null version: number due_at: string | null due_has_time: boolean subtasks?: TaskToggleState[] } export function taskVersionedPatchPayload(task: T, patch: P): P & { version: number } { return { ...patch, version: task.version } } export function mergeTaskToggleResponse(draft: T, response: TaskToggleState): T { return { ...response, list_id: draft.list_id, parent_id: draft.parent_id, title: draft.title, description: draft.description, priority: draft.priority, due_at: draft.due_at, due_has_time: draft.due_has_time, subtasks: draft.subtasks, } as T } type MutationSuccessCallback = (value: T) => void | Promise type MutationReconciliationOptions = { affectsTrash?: boolean; affectsTaskView?: boolean } export type MutationReconciler = { run( mutation: () => Promise, onSuccess: (value: T) => void, onError?: (reason: unknown) => void, onCurrentSuccess?: MutationSuccessCallback, reconciliation?: MutationReconciliationOptions, ): Promise } export function createMutationReconciler( currentContext: () => TContext, sameContext: (left: TContext, right: TContext) => boolean, reconcileCurrentView: (options?: MutationReconciliationOptions) => Promise, ): MutationReconciler { let dirty = 0 let reconciled = 0 let refreshLoop: Promise | null = null const reconcile = (context: TContext, options?: MutationReconciliationOptions) => { if (!sameContext(context, currentContext())) return Promise.resolve() dirty += 1 if (!refreshLoop) { refreshLoop = (async () => { while (reconciled < dirty && sameContext(context, currentContext())) { const target = dirty await reconcileCurrentView(options) if (!sameContext(context, currentContext())) break reconciled = target } })().finally(() => { refreshLoop = null }) } return refreshLoop } return { async run(mutation, onSuccess, onError, onCurrentSuccess, reconciliation) { const context = currentContext() let value: Awaited> try { value = await mutation() } catch (reason) { if (sameContext(context, currentContext())) onError?.(reason) return false } if (sameContext(context, currentContext())) { onSuccess(value) const currentSuccess = onCurrentSuccess?.(value) if (currentSuccess instanceof Promise) await currentSuccess } else { await reconcileCurrentView(reconciliation) return true } await reconcile(context, reconciliation) if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context, reconciliation) return true }, } } export function startPrimaryWithBackground( primary: Array<() => Promise>, background: () => Promise, ) { const pending = primary.map((start) => start()) void background().catch(() => undefined) return Promise.all(pending) } export function readHabitGridCache(week: string): T[] | null { return habitGridCache?.week === week ? habitGridCache.habits as T[] : null } export function writeHabitGridCache(week: string, habits: T[]) { habitGridCache = { week, habits } } export function invalidateHabitGridCache() { habitGridCache = null } export async function performHabitRestore(actions: { restore: () => Promise commitRestore: () => void refreshGrid: () => Promise }) { 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() { if (!countdownCache) return null return { items: countdownCache.items as T[], archived: countdownCache.archived as T[] } } export function writeCountdownCache(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( 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()!) } 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> 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 = {} for (const key of Object.keys(after) as Array) { const oldValue = before[key] const newValue = after[key] if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) (result as Record)[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 = { create: '创建', update: '更新', complete: '完成', delete: '删除', archive: '归档', restore: '恢复', move: '移动', import: '导入', } const AUDIT_ENTITIES: Record = { 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).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).loc) ? ((item as Record).loc as unknown[]).at(-1) : '').find(Boolean) const fieldMessages: Record = { 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 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 if ('detail' in record) return formatApiErrorDetail(record.detail) return JSON.stringify(record) } return '请求失败' }