DriftLedger/src/store/settingsStore.ts
fengmengqi 2e73b5a2c6 feat: UI 全面重设计(Bento 明亮风)+ 记一笔 NumpadSheet + 管理页模板 + OCR v5→v6 升级
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
2026-07-22 13:33:29 +08:00

234 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-storeAndroid 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 });
},
}));