UI 重设计(P1-P5): - 主题从靛蓝 accent 切换为近黑白单色(#111318),品牌感靠排版与财务语义色 - 暗色模式适配 OLED 纯黑,财务色整体提亮一档保证对比度 - 圆角体系新增 xl(24),卡片/弹窗统一大圆角 - 移除 Caveat/Quicksand 自定义字体,切换为系统字体 - 新增 display(34/800) 字阶用于净资产等大数字展示 - 分类/标签/渠道颜色统一到 palette.ts 色板(12 色循环 + FNV 哈希) 记一笔 NumpadSheet(P3 录入闭环): - 全新计算器风格录入面板:方向 chip → 大金额 → 账户 chip → 分类快捷行 → 数字键盘 - 支持表达式输入(20+15-5.5)与字符串十进制求值,避免浮点精度问题 - 高级模式保留多行分录编辑器,简单模式自动推断双腿 - postingLegs.ts 实现 buildPostings 的逆操作(编辑/草稿预填) - 未保存守卫:整页 beforeRemove + modal 脏状态上报 - 全局弹层(NumpadSheetHost)由+按钮唤起,可通过设置开关 Tab 栏重构: - AppTabBar 替换默认 tab bar,中央+按钮唤起全局记账面板 - Tab 从 5 个精简为 4 个(首页/交易/报表/设置),+按钮独立 管理页模板 ManagementScreen: - 统一「列表 + 新增 + 点按编辑 + 长按删除 + FormModal」模式 - 预算/分类/标签/信用卡等管理页消除手写样板 其他: - OCR 模型从 PP-OCRv5 升级到 v6(det/rec/dict 全部替换) - GBK 解码器 import 修复(text-encoding-gbk ESM 兼容) - 批量确认失败数计算修正(用 failedPayees.length 替代 store 快照差值) - 新增 diagnostics 诊断页、TodoStrip 首页待办条、SwipeableTransactionCard 左滑操作 - periodNav.ts 报表周期导航(anchor+period 统一状态管理,本地时区运算) - 测试覆盖 numpadExpression/postingLegs/periodNav/palette/search/transactionGroups/categoryIcons/dateGrid
234 lines
8.1 KiB
TypeScript
234 lines
8.1 KiB
TypeScript
import { create } from 'zustand';
|
||
import { DEFAULT_DEDUP_CONFIG, type DedupConfig } from '../domain/dedup';
|
||
import { DEFAULT_TRANSFER_CONFIG, type TransferConfig } from '../domain/transferRecognizer';
|
||
|
||
/**
|
||
* 设置状态管理(plan.md「0.1 状态管理」settingsStore)。
|
||
*
|
||
* 持久化策略:内存状态 + 持久化钩子(persistTo)。生产环境传入 AsyncStorage 持久化,
|
||
* 测试环境不传(纯内存)。这样避免直接依赖 @react-native-async-storage/async-storage,
|
||
* 使本 store 可单测。
|
||
*/
|
||
|
||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||
export type Locale = 'zh' | 'en';
|
||
|
||
export interface SettingsState {
|
||
// 去重/转账配置(影响 BillPipeline)
|
||
dedupConfig: DedupConfig;
|
||
transferConfig: TransferConfig;
|
||
/** 是否启用多通道联合去重。 */
|
||
dedupEnabled: boolean;
|
||
/** 是否启用转账智能识别。 */
|
||
transferRecognitionEnabled: boolean;
|
||
|
||
// UI 偏好
|
||
themeMode: ThemeMode;
|
||
locale: Locale;
|
||
/** 应用锁启用。 */
|
||
appLockEnabled: boolean;
|
||
/** 引导是否完成。 */
|
||
onboardingCompleted: boolean;
|
||
setOnboardingCompleted: (v: boolean) => void;
|
||
|
||
// Sync Configurations
|
||
webdavUrl?: string;
|
||
webdavUsername?: string;
|
||
webdavPassword?: string;
|
||
webdavRemotePath?: string;
|
||
gitRemoteUrl?: string;
|
||
gitBranch?: string;
|
||
gitUsername?: string;
|
||
gitPassword?: string;
|
||
|
||
// AI Configurations
|
||
aiEnabled: boolean;
|
||
aiProviderId: 'openai' | 'gemini' | 'deepseek';
|
||
aiApiKey?: string;
|
||
aiBaseUrl?: string;
|
||
aiModel?: string;
|
||
|
||
// Reminder Configurations
|
||
reminderEnabled: boolean;
|
||
reminderHour: number;
|
||
reminderMinute: number;
|
||
|
||
// 悬浮球
|
||
floatingBallEnabled: boolean;
|
||
setFloatingBallEnabled: (enabled: boolean) => void;
|
||
|
||
// 记账面板
|
||
/** +按钮是否打开全局记账面板(P5 起默认 true;已持久化 false 的用户保持关闭)。 */
|
||
numpadGlobalEntry: boolean;
|
||
setNumpadGlobalEntry: (enabled: boolean) => void;
|
||
/** 上次记账的资金账户(NumpadSheet 默认选中)。 */
|
||
lastUsedAccount: string | null;
|
||
/** 上次使用的分类 id。 */
|
||
lastUsedCategoryId: string | null;
|
||
setLastUsedEntry: (account: string | null, categoryId: string | null) => void;
|
||
|
||
// actions
|
||
updateDedupConfig: (patch: Partial<DedupConfig>) => void;
|
||
updateTransferConfig: (patch: Partial<TransferConfig>) => void;
|
||
setDedupEnabled: (enabled: boolean) => void;
|
||
setTransferRecognitionEnabled: (enabled: boolean) => void;
|
||
setThemeMode: (mode: ThemeMode) => void;
|
||
setLocale: (locale: Locale) => void;
|
||
setAppLockEnabled: (enabled: boolean) => void;
|
||
updateSyncConfig: (patch: Partial<Pick<SettingsState, 'webdavUrl' | 'webdavUsername' | 'webdavPassword' | 'webdavRemotePath' | 'gitRemoteUrl' | 'gitBranch' | 'gitUsername' | 'gitPassword'>>) => void;
|
||
updateAiConfig: (patch: Partial<Pick<SettingsState, 'aiEnabled' | 'aiProviderId' | 'aiApiKey' | 'aiBaseUrl' | 'aiModel'>>) => void;
|
||
updateReminderConfig: (patch: Partial<Pick<SettingsState, 'reminderEnabled' | 'reminderHour' | 'reminderMinute'>>) => void;
|
||
|
||
/** 持久化钩子(每次状态变更后调用,生产环境传 AsyncStorage 写入)。 */
|
||
persistTo?: (state: PersistableSettings) => void;
|
||
/** 从持久化源恢复(启动时调用)。 */
|
||
hydrate: (data: Partial<PersistableSettings>) => void;
|
||
/** 跳过下次持久化(hydrate 时使用)。 */
|
||
skipPersist: boolean;
|
||
}
|
||
|
||
/** 可持久化的设置子集(排除函数)。 */
|
||
export interface PersistableSettings {
|
||
dedupConfig: DedupConfig;
|
||
transferConfig: TransferConfig;
|
||
dedupEnabled: boolean;
|
||
transferRecognitionEnabled: boolean;
|
||
themeMode: ThemeMode;
|
||
locale: Locale;
|
||
appLockEnabled: boolean;
|
||
webdavUrl?: string;
|
||
webdavUsername?: string;
|
||
webdavPassword?: string;
|
||
webdavRemotePath?: string;
|
||
gitRemoteUrl?: string;
|
||
gitBranch?: string;
|
||
gitUsername?: string;
|
||
gitPassword?: string;
|
||
onboardingCompleted: boolean;
|
||
aiEnabled: boolean;
|
||
aiProviderId: 'openai' | 'gemini' | 'deepseek';
|
||
aiApiKey?: string;
|
||
aiBaseUrl?: string;
|
||
aiModel?: string;
|
||
reminderEnabled: boolean;
|
||
reminderHour: number;
|
||
reminderMinute: number;
|
||
floatingBallEnabled: boolean;
|
||
numpadGlobalEntry: boolean;
|
||
lastUsedAccount: string | null;
|
||
lastUsedCategoryId: string | null;
|
||
}
|
||
|
||
/**
|
||
* 敏感字段:这些值不写入明文 settings.json,由 storePersistence 层
|
||
* 通过 expo-secure-store(Android Keystore / iOS Keychain)加密存储。
|
||
*/
|
||
export const SECURE_KEYS = ['aiApiKey', 'webdavPassword', 'gitPassword'] as const;
|
||
export type SecureKey = (typeof SECURE_KEYS)[number];
|
||
|
||
const DEFAULTS: PersistableSettings & { skipPersist: boolean; floatingBallEnabled: boolean } = {
|
||
dedupConfig: DEFAULT_DEDUP_CONFIG,
|
||
transferConfig: DEFAULT_TRANSFER_CONFIG,
|
||
dedupEnabled: true,
|
||
transferRecognitionEnabled: true,
|
||
themeMode: 'system',
|
||
locale: 'zh',
|
||
appLockEnabled: false,
|
||
webdavUrl: '',
|
||
webdavUsername: '',
|
||
webdavPassword: '',
|
||
webdavRemotePath: 'main.bean',
|
||
gitRemoteUrl: '',
|
||
gitBranch: 'main',
|
||
gitUsername: '',
|
||
gitPassword: '',
|
||
onboardingCompleted: false,
|
||
aiEnabled: false,
|
||
aiProviderId: 'openai',
|
||
aiApiKey: '',
|
||
aiBaseUrl: 'https://api.openai.com/v1',
|
||
aiModel: 'gpt-4o-mini',
|
||
reminderEnabled: false,
|
||
reminderHour: 20,
|
||
reminderMinute: 0,
|
||
floatingBallEnabled: false,
|
||
// P5 翻默认:+按钮默认打开全局记账面板。
|
||
// 注意:hydrate 以持久化值覆盖 DEFAULTS,已持久化 false 的老用户不受影响,仅新安装生效。
|
||
numpadGlobalEntry: true,
|
||
lastUsedAccount: null,
|
||
lastUsedCategoryId: null,
|
||
skipPersist: false,
|
||
};
|
||
|
||
export function toPersistable(state: SettingsState): PersistableSettings {
|
||
return {
|
||
dedupConfig: state.dedupConfig,
|
||
transferConfig: state.transferConfig,
|
||
dedupEnabled: state.dedupEnabled,
|
||
transferRecognitionEnabled: state.transferRecognitionEnabled,
|
||
themeMode: state.themeMode,
|
||
locale: state.locale,
|
||
appLockEnabled: state.appLockEnabled,
|
||
webdavUrl: state.webdavUrl,
|
||
webdavUsername: state.webdavUsername,
|
||
webdavPassword: state.webdavPassword,
|
||
webdavRemotePath: state.webdavRemotePath,
|
||
gitRemoteUrl: state.gitRemoteUrl,
|
||
gitBranch: state.gitBranch,
|
||
gitUsername: state.gitUsername,
|
||
gitPassword: state.gitPassword,
|
||
onboardingCompleted: state.onboardingCompleted,
|
||
aiEnabled: state.aiEnabled,
|
||
aiProviderId: state.aiProviderId,
|
||
aiApiKey: state.aiApiKey,
|
||
aiBaseUrl: state.aiBaseUrl,
|
||
aiModel: state.aiModel,
|
||
reminderEnabled: state.reminderEnabled,
|
||
reminderHour: state.reminderHour,
|
||
reminderMinute: state.reminderMinute,
|
||
floatingBallEnabled: state.floatingBallEnabled,
|
||
numpadGlobalEntry: state.numpadGlobalEntry,
|
||
lastUsedAccount: state.lastUsedAccount,
|
||
lastUsedCategoryId: state.lastUsedCategoryId,
|
||
};
|
||
}
|
||
|
||
export const useSettingsStore = create<SettingsState>((set) => ({
|
||
...DEFAULTS,
|
||
|
||
updateDedupConfig: (patch) => set((s) => ({ dedupConfig: { ...s.dedupConfig, ...patch } })),
|
||
|
||
updateTransferConfig: (patch) => set((s) => ({ transferConfig: { ...s.transferConfig, ...patch } })),
|
||
|
||
setDedupEnabled: (enabled) => set({ dedupEnabled: enabled }),
|
||
|
||
setTransferRecognitionEnabled: (enabled) => set({ transferRecognitionEnabled: enabled }),
|
||
|
||
setThemeMode: (mode) => set({ themeMode: mode }),
|
||
|
||
setLocale: (locale) => set({ locale }),
|
||
|
||
setAppLockEnabled: (enabled) => set({ appLockEnabled: enabled }),
|
||
|
||
updateSyncConfig: (patch) => set((s) => ({ ...s, ...patch })),
|
||
|
||
updateAiConfig: (patch) => set((s) => ({ ...s, ...patch })),
|
||
|
||
updateReminderConfig: (patch) => set((s) => ({ ...s, ...patch })),
|
||
|
||
setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }),
|
||
|
||
setFloatingBallEnabled: (enabled) => set({ floatingBallEnabled: enabled }),
|
||
|
||
setNumpadGlobalEntry: (enabled) => set({ numpadGlobalEntry: enabled }),
|
||
|
||
setLastUsedEntry: (account, categoryId) => set({ lastUsedAccount: account, lastUsedCategoryId: categoryId }),
|
||
|
||
hydrate: (data) => {
|
||
// hydrate 期间跳过自动持久化
|
||
// 使用单次 setState 合并所有数据,避免多次触发 subscriber
|
||
useSettingsStore.setState({ ...DEFAULTS, ...data, skipPersist: true });
|
||
useSettingsStore.setState({ skipPersist: false });
|
||
},
|
||
}));
|