feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构

品牌与配置
- 重命名 Bean Mobile → 浮记(DriftLedger),slug 改为 drift-ledger
- 所有 Config Plugin 从 app.json 动态读取 appId,替换硬编码 com.beancount.mobile
- 预构建时自动从 constants.ts 同步支付包名列表到 Kotlin 端
- size-optimization 插件新增 NDK 版本强制统一(27.1.12297006)
- 添加 app icon(direction_a_feather.png)
Decimal 精度改造
- 全面消除 parseFloat 用于金额计算的场景,改用 string decimal 运算
- 新增 divideDecimals(除法)、toDateString(Date→YYYY-MM-DD)
- negateDecimal 零值特殊处理,避免产生 "-0"
- 年报/月报/预算/信用卡/汇率/图表统计等模块全部迁移
- 去重指纹用 parseDecimal 归一化金额格式
- 转账识别增加 normalizeAmount,支持不同精度格式配对(100 vs 100.00)
架构变更
- mobile.bean → main.bean:移除启动时合并迁移逻辑,直接读写 main.bean
- TransactionDraft.sourceEventId → sourceEventIds(数组,支持转账双方)
- 新增 buildPostings() 纯函数,从 transactionBuilder 提取复式分录构建逻辑
- 新增 csvUtils.ts(引号感知 CSV 行拆分),statements/adapters 共用
- 新增 accessibilityParser.ts:从 automationPipeline 提取微信/支付宝文本解析
- 新增 floatingBillManager.ts:悬浮窗草稿管理(LRU+超时)与排序数据构建
- 新增 txKeyStore.ts:交易级去重键持久化(原子写入+500条LRU)
- ledger.ts 新增 resolveIncludes 选项,支持 include 指令递归解析
- metadataStore CRUD 用 createCrudActions 工厂消除7组实体重复代码
安全与持久化
- PIN 哈希改用带盐 SHA-256,兼容旧版无 salt 数据迁移
- storePersistence 从 persistTo 回调改为 subscribe 自动触发
- 持久化增加深比较跳过无变化写入 + 滞后重试
- 新增 flushPersistence(),App 切后台时立即刷盘
- CrashReporter 新增文件持久化(CrashFs 抽象)
- settingsStore hydrate 期间跳过自动持久化(skipPersist 标志)
去重与管道
- batchDedup 改用日期索引 Map,候选查找从 O(n) 降至 O(1)
- dedupAgainstHistory 按日期分组,仅扫描时间窗口内候选
- OcrProcessor 从布尔标志改为 Promise 队列,不再丢弃并发请求
- 去重缓存改用 Map<hash,timestamp> LRU,阈值超20%时批量淘汰
- 悬浮球全局开关(floatingBallEnabled) + 原生 bridge API
其他改进
- recurring 计算修复月/年溢出(1月31日+1月→2月28日,闰年2月29日)
- 预算进度按 posting 分录金额求和(不再仅过滤负数金额)
- recurring.ts 用 Date.parse 本地时间替代 toISOString 的 UTC 偏移
- 信用卡账单日/还款日限制不超过28,防止月份溢出
- 类别模糊匹配增加最小长度2,避免单字符误匹配
- 还款检测排除 refund 方向,避免退款被误判为还款
- WebDAV 同步增加 HTTP 日期格式解析
- Git 合并策略改为以 remote 为基础追加 local 独有行
性能优化
- commonStyles 全局 useMemo 缓存
- Button/Card 的 pressIn/pressOut 改用 useCallback
- TransactionCard/AccountTreeNode 包裹 React.memo
- PostingEditor 用 refs + 函数式更新避免重建
- CreditCard 页预计算账户余额 Map,避免渲染循环重复遍历
- OCR 处理器单例复用,配置变更时才重建
- getLocalAuth/getAccessibilityBridge 结果缓存
测试
- 新增 decimal/constants/channelConfig/transactionBuilder 单测
- 修复金额格式断言(-24.5→-24.50),匹配字符串精度行为
- billPipeline 新增不同格式金额配对、指纹唯一性测试
This commit is contained in:
fengmengqi 2026-07-21 15:16:44 +08:00
parent 76a5853ab6
commit 7fa345b558
119 changed files with 3348 additions and 1302 deletions

4
.gitignore vendored
View File

@ -47,4 +47,6 @@ CLAUDE.md
/.mimocode/
/.agents/
/reference_project/
/.zcode/
/.zcode/
# superpowers brainstorm mockups
.superpowers/

View File

@ -1,8 +1,9 @@
{
"expo": {
"name": "Bean Mobile",
"slug": "bean-mobile",
"scheme": "beanmobile",
"name": "浮记",
"slug": "drift-ledger",
"scheme": "driftledger",
"icon": "./assets/icon/direction_a_feather.png",
"plugins": [
[
"expo-sqlite",
@ -25,10 +26,15 @@
"typedRoutes": true
},
"ios": {
"bundleIdentifier": "com.example.beanmobile"
"bundleIdentifier": "com.example.driftledger",
"icon": "./assets/icon/direction_a_feather.png"
},
"android": {
"package": "com.example.beanmobile"
"package": "com.example.driftledger",
"adaptiveIcon": {
"foregroundImage": "./assets/icon/direction_a_feather.png",
"backgroundColor": "#1B1F20"
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

View File

@ -1,5 +1,5 @@
{
"name": "beancount-mobile",
"name": "drift-ledger",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",

View File

@ -230,4 +230,21 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
}
}
}
/** 设置悬浮球开关状态。 */
@ReactMethod
fun setFloatingBallEnabled(enabled: Boolean, promise: Promise) {
BillingAccessibilityService.floatingBallEnabled = enabled
val service = BillingAccessibilityService.instance
if (service == null) {
promise.resolve(true)
return
}
if (!enabled) {
service.dismissFloatingHelper()
} else {
service.refreshFloatingHelper()
}
promise.resolve(true)
}
}

View File

@ -49,7 +49,14 @@ class BillingAccessibilityService : AccessibilityService() {
var instance: BillingAccessibilityService? = null
private set
/** 支付 App 白名单。 */
/** 悬浮球全局开关(由 JS 层通过 AccessibilityBridge 控制)。 */
@Volatile
var floatingBallEnabled = true
/**
* 支付 App 包名自动生成来源src/domain/constants.ts PAYMENT_PACKAGES
* 修改时只需编辑 constants.ts运行 expo prebuild 即可同步
*/
val PAYMENT_PACKAGES = setOf(
"com.eg.android.AlipayGphone", // 支付宝
"com.tencent.mm", // 微信
@ -79,6 +86,7 @@ class BillingAccessibilityService : AccessibilityService() {
}
private var ocrDoing = false
private var ocrDoingTimestamp = 0L
private val handler = Handler(Looper.getMainLooper())
private val debounceRunnable = Runnable { processContentChange() }
@Volatile private var topPackage: String? = null
@ -93,6 +101,14 @@ class BillingAccessibilityService : AccessibilityService() {
Log.i(TAG, "无障碍账单识别服务已连接")
loadPageSignatures()
configureService()
// 修复:检查当前前台 App恢复悬浮球
val root = rootInActiveWindow
val pkg = root?.packageName?.toString()
root?.recycle()
if (pkg != null) {
topPackage = pkg
updateFloatingHelperVisibility(pkg)
}
}
/** 动态配置服务能力(截图 + 页面变化监听)。 */
@ -110,7 +126,15 @@ class BillingAccessibilityService : AccessibilityService() {
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (ocrDoing) return // 处理中,跳过
// ocrDoing 超时兜底5秒后强制重置
if (ocrDoing) {
if (System.currentTimeMillis() - ocrDoingTimestamp > 5000) {
Log.w(TAG, "ocrDoing 超时重置")
ocrDoing = false
} else {
return
}
}
val eventPackage = event?.packageName?.toString() ?: return
when (event.eventType) {
@ -120,13 +144,12 @@ class BillingAccessibilityService : AccessibilityService() {
topPackage = eventPackage
topActivity = activityName
Log.d(TAG, "页面切换: $eventPackage / $activityName")
// 更新悬浮窗助手状态
updateFloatingHelperVisibility(eventPackage)
if (PAYMENT_PACKAGES.contains(eventPackage)) {
scheduleContentChange()
}
// 页面切换时检查是否有已记住的签名需要触发文本提取
scheduleContentChange()
// 调试:如果是微信或支付宝,延迟 800ms 抓取并打印全屏无障碍文本内容
if (eventPackage == "com.tencent.mm" || eventPackage == "com.eg.android.AlipayGphone") {
@ -159,16 +182,20 @@ class BillingAccessibilityService : AccessibilityService() {
}
}
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
if (PAYMENT_PACKAGES.contains(eventPackage)) {
scheduleContentChange()
}
// 内容变化时也检查(防抖合并)
scheduleContentChange()
}
}
}
private fun updateFloatingHelperVisibility(pkg: String?) {
handler.post {
if (pkg != null && PAYMENT_PACKAGES.contains(pkg)) {
val shouldShow = floatingBallEnabled
&& pkg != null
&& !filterPackage(pkg, "")
&& !isLandscape()
if (shouldShow) {
if (floatingHelper == null) {
floatingHelper = FloatingHelper(this)
floatingHelper?.show()
@ -236,6 +263,7 @@ class BillingAccessibilityService : AccessibilityService() {
private fun takeScreenshotAndProcess(packageName: String) {
if (ocrDoing) return
ocrDoing = true
ocrDoingTimestamp = System.currentTimeMillis()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
ocrDoing = false
return
@ -451,6 +479,21 @@ class BillingAccessibilityService : AccessibilityService() {
}
}
/** 关闭悬浮球(由 JS 层 setFloatingBallEnabled(false) 调用)。 */
fun dismissFloatingHelper() {
handler.post {
floatingHelper?.dismiss()
floatingHelper = null
}
}
/** 刷新悬浮球状态(由 JS 层 setFloatingBallEnabled(true) 调用)。 */
fun refreshFloatingHelper() {
handler.post {
updateFloatingHelperVisibility(topPackage)
}
}
/** 横屏检测plan.md「3.10 横屏免打扰」)。 */
private fun isLandscape(): Boolean {
val dm = getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
@ -459,17 +502,17 @@ class BillingAccessibilityService : AccessibilityService() {
return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270
}
/** 过滤系统组件/桌面(不触发 OCR)。 */
/** 过滤不应处理的系统组件桌面启动器、SystemUI、输入法等)。 */
private fun filterPackage(pkg: String, className: String): Boolean {
val p = pkg.lowercase()
// 针对自身应用的特殊过滤:只允许 MainActivity 通过以触发隐藏悬浮窗;
// 其他自身组件(如悬浮球容器 LinearLayout一律过滤避免自毁式关闭。
// 自身 App只放行 MainActivity避免悬浮球容器触发自毁式关闭
if (pkg == packageName) {
return className != "com.example.beanmobile.MainActivity"
return !className.endsWith(".MainActivity")
}
if (p == "android" || p.startsWith("com.android.") || p.startsWith("com.google.android.")) return true
if (p.contains("systemui") || p.contains("settings") || p.contains("inputmethod") || p.contains("keyboard") || p.contains("input")) return true
// 桌面启动器(悬浮球在桌面无意义)
if (LAUNCHER_PACKAGES.any { p.contains(it) }) return true
// SystemUI / 输入法(悬浮球在这些组件上会遮挡系统 UI
if (p.contains("systemui") || p.contains("inputmethod") || p.contains("keyboard")) return true
return false
}

View File

@ -86,7 +86,7 @@ class FloatingHelper(
setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框
}
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
gravity = Gravity.CENTER
gravity = Gravity.CENTER_VERTICAL or Gravity.START // 贴左边缘
}
}
bubbleLayout.addView(indicatorView)
@ -255,11 +255,11 @@ class FloatingHelper(
if (!isMoving) {
toggleMenu()
} else {
// 拖动抬起时自动吸附到屏幕边缘
// 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘)
if (params.x < service.resources.displayMetrics.widthPixels / 2) {
params.x = 0
params.x = -dp(1.5f) // 指示器 3dp 宽,中心对齐边缘
} else {
params.x = service.resources.displayMetrics.widthPixels - dp(24f)
params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f)
}
containerView?.let { windowManager.updateViewLayout(it, params) }
@ -276,6 +276,10 @@ class FloatingHelper(
Log.i(TAG, "记账助手悬浮窗显示成功")
} catch (e: Exception) {
Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e)
// 修复:失败时清理状态,允许下次重试
containerView = null
bubbleView = null
menuView = null
}
}

View File

@ -50,8 +50,8 @@ class OcrTileService : TileService() {
val pi = PendingIntent.getActivity(
this, 0,
Intent().apply {
setClassName(packageName, "com.beancount.mobile.MainActivity")
action = "com.beancount.mobile.TRIGGER_OCR"
setClassName(packageName, "$packageName.MainActivity")
action = "$packageName.TRIGGER_OCR"
flags = Intent.FLAG_ACTIVITY_NEW_TASK
},
PendingIntent.FLAG_IMMUTABLE,

View File

@ -12,7 +12,33 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require('
const fs = require('fs');
const path = require('path');
const PACKAGE = 'com.beancount.mobile.accessibility';
/**
* src/domain/constants.ts PAYMENT_PACKAGES 中提取包名列表
* 这是唯一的真相来源Kotlin 端通过此函数自动同步
*/
function readPaymentPackagesFromConstants() {
const constantsPath = path.resolve(__dirname, '../../src/domain/constants.ts');
if (!fs.existsSync(constantsPath)) return null;
const content = fs.readFileSync(constantsPath, 'utf8');
// 匹配 PAYMENT_PACKAGES = { ... } 中的 key冒号前的字符串
const match = content.match(/PAYMENT_PACKAGES:\s*Record<string,\s*string>\s*=\s*\{([\s\S]*?)\n\};/);
if (!match) return null;
const keys = [];
const keyRegex = /^(\s*)'([^']+)'\s*:/gm;
let m;
while ((m = keyRegex.exec(match[1])) !== null) {
keys.push(m[2]);
}
return keys.length > 0 ? keys : null;
}
/**
* 生成 Kotlin setOf("...", "...", ...) 代码
*/
function generateKotlinPaymentPackages(packages) {
const items = packages.map(pkg => ` "${pkg}"`).join(',\n');
return ` val PAYMENT_PACKAGES = setOf(\n${items}\n )`;
}
/** 递归复制目录。 */
function copyDir(src, dest) {
@ -26,19 +52,30 @@ function copyDir(src, dest) {
}
}
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
function withAccessibilityService(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.accessibility`;
// 1. 复制 Kotlin 源码 + res/xml 资源到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
// Kotlin 源码(只复制 .kt 文件,不含 res/ 子目录)
const ktDest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/accessibility');
const pkgPath = appId.replace(/\./g, '/');
const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'accessibility');
fs.mkdirSync(ktDest, { recursive: true });
const androidDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(androidDir)) {
if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(androidDir, f), path.join(ktDest, f));
let content = fs.readFileSync(path.join(androidDir, f), 'utf8');
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
fs.writeFileSync(path.join(ktDest, f), content, 'utf8');
}
}
// res/xml 资源
@ -46,6 +83,23 @@ function withAccessibilityService(config) {
if (fs.existsSync(resSrc)) {
copyDir(resSrc, path.join(projectRoot, 'app/src/main/res'));
}
// 从 constants.ts 读取包名列表,自动同步到 Kotlin
const packages = readPaymentPackagesFromConstants();
if (packages) {
const ktFile = path.join(ktDest, 'BillingAccessibilityService.kt');
if (fs.existsSync(ktFile)) {
let ktContent = fs.readFileSync(ktFile, 'utf8');
// 替换 PAYMENT_PACKAGES 定义(匹配 val PAYMENT_PACKAGES = setOf( ... )
const regex = /val PAYMENT_PACKAGES = setOf\([\s\S]*?\)/;
if (regex.test(ktContent)) {
ktContent = ktContent.replace(regex, generateKotlinPaymentPackages(packages));
fs.writeFileSync(ktFile, ktContent, 'utf8');
console.log(`[accessibility plugin] 已同步 ${packages.length} 个支付 App 包名到 Kotlin`);
}
}
}
// 确保有 accessibility_service_description 字符串资源
const stringsXmlPath = path.join(projectRoot, 'app/src/main/res/values/strings.xml');
if (fs.existsSync(stringsXmlPath)) {
@ -93,14 +147,24 @@ function withAccessibilityService(config) {
return modConfig;
});
// 3. 注册服务到 AndroidManifest
// 3. 注册权限 + 服务到 AndroidManifest
config = withAndroidManifest(config, (modConfig) => {
const manifest = modConfig.modResults.manifest;
// 0. 添加 SYSTEM_ALERT_WINDOW 权限(悬浮窗必需)
if (!manifest['uses-permission']) {
manifest['uses-permission'] = [];
}
const overlayPerm = 'android.permission.SYSTEM_ALERT_WINDOW';
const overlayPermExists = manifest['uses-permission'].some(p => p.$['android:name'] === overlayPerm);
if (!overlayPermExists) {
manifest['uses-permission'].push({ $: { 'android:name': overlayPerm } });
}
// 1. 添加无障碍服务声明
const serviceNode = {
$: {
'android:name': 'com.beancount.mobile.accessibility.BillingAccessibilityService',
'android:name': `${PACKAGE}.BillingAccessibilityService`,
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
'android:label': '账单识别',
'android:exported': 'false',
@ -125,7 +189,7 @@ function withAccessibilityService(config) {
app.service = [];
}
const exists = app.service.some(
s => s.$['android:name'] === 'com.beancount.mobile.accessibility.BillingAccessibilityService'
s => s.$['android:name'] === `${PACKAGE}.BillingAccessibilityService`
);
if (!exists) {
app.service.push(serviceNode);
@ -134,7 +198,7 @@ function withAccessibilityService(config) {
// 3. 添加 OcrTileService 声明快速设置磁贴plan.md「3.11」)
const tileServiceNode = {
$: {
'android:name': 'com.beancount.mobile.accessibility.OcrTileService',
'android:name': `${PACKAGE}.OcrTileService`,
'android:label': 'OCR 记账',
'android:icon': '@android:drawable/ic_menu_camera',
'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE',
@ -145,7 +209,7 @@ function withAccessibilityService(config) {
}],
};
const tileExists = app.service.some(
s => s.$['android:name'] === 'com.beancount.mobile.accessibility.OcrTileService'
s => s.$['android:name'] === `${PACKAGE}.OcrTileService`
);
if (!tileExists) {
app.service.push(tileServiceNode);

View File

@ -1,5 +1,5 @@
{
"name": "beancount-mobile-plugin-accessibility",
"name": "drift-ledger-plugin-accessibility",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"

View File

@ -8,6 +8,7 @@ import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.beancount.mobile.accessibility.BillingAccessibilityService
import com.beancount.mobile.accessibility.ReactContextHolder
/**
@ -15,7 +16,7 @@ import com.beancount.mobile.accessibility.ReactContextHolder
*
* 参考 AutoAccounting NotificationListenerService
* - 提取支付 App 通知的 title/text
* - 白名单过滤仅支付类 App
* - 白名单过滤复用 BillingAccessibilityService.PAYMENT_PACKAGES单一数据源
* - 关键词黑白名单JS keywordFilter 进一步过滤
* - MD5 去重JS NotificationChannel 处理避免原生持有状态
* - onListenerDisconnected requestRebind 自动重连
@ -26,26 +27,14 @@ class BillingNotificationListenerService : NotificationListenerService() {
companion object {
private const val TAG = "BillingNotification"
/** 支付 App 白名单(与 JS 层 DEFAULT_PAYMENT_PACKAGES 一致)。 */
private val PAYMENT_PACKAGES = setOf(
"com.eg.android.AlipayGphone",
"com.tencent.mm",
"com.unionpay",
"com.cmbchina",
"com.icbc",
"com.chinamworld.main",
"com.ccbrcb",
"com.bankcomm.Bankcomm",
)
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
runCatching {
val packageName = sbn?.packageName?.toString() ?: return
// 白名单过滤
if (!PAYMENT_PACKAGES.contains(packageName)) return
// 白名单过滤(复用无障碍服务的 PAYMENT_PACKAGES
if (!BillingAccessibilityService.PAYMENT_PACKAGES.contains(packageName)) return
val notification = sbn.notification
val extras = notification.extras

View File

@ -11,18 +11,29 @@ const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins'
const fs = require('fs');
const path = require('path');
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
function withNotificationListener(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.notification`;
// 1. 复制 Kotlin 源码到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/notification');
const pkgPath = appId.replace(/\./g, '/');
const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'notification');
fs.mkdirSync(dest, { recursive: true });
const srcDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f));
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
fs.writeFileSync(path.join(dest, f), content, 'utf8');
}
}
return modConfig;
@ -36,9 +47,9 @@ function withNotificationListener(config) {
// 1. 添加通知监听服务
const serviceNode = {
$: {
'android:name': 'com.beancount.mobile.notification.BillingNotificationListenerService',
'android:name': `${PACKAGE}.BillingNotificationListenerService`,
'android:permission': 'android.permission.BIND_NOTIFICATION_LISTENER_SERVICE',
'android:exported': 'false',
'android:exported': 'true',
},
'intent-filter': [{
action: [{ $: { 'android:name': 'android.service.notification.NotificationListenerService' } }],
@ -54,7 +65,7 @@ function withNotificationListener(config) {
app.service = [];
}
const exists = app.service.some(
s => s.$['android:name'] === 'com.beancount.mobile.notification.BillingNotificationListenerService'
s => s.$['android:name'] === `${PACKAGE}.BillingNotificationListenerService`
);
if (!exists) {
app.service.push(serviceNode);

View File

@ -1,5 +1,5 @@
{
"name": "beancount-mobile-plugin-notification-listener",
"name": "drift-ledger-plugin-notification-listener",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"

View File

@ -22,9 +22,33 @@ const {
const fs = require('fs');
const path = require('path');
const PACKAGE = 'com.beancount.mobile.ppocr';
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
/** 递归复制目录prebuild 阶段执行一次。 */
/** 递归复制目录prebuild 阶段执行并替换包名。 */
function copyAndReplaceDir(src, dest, appId) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src)) {
const s = path.join(src, entry);
const d = path.join(dest, entry);
if (fs.statSync(s).isDirectory()) {
copyAndReplaceDir(s, d, appId);
} else {
if (entry.endsWith('.kt') || entry.endsWith('.java')) {
let content = fs.readFileSync(s, 'utf8');
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
fs.writeFileSync(d, content, 'utf8');
} else {
fs.copyFileSync(s, d);
}
}
}
}
/** 递归复制目录(仅用于模型资源复制,不替换包名)。 */
function copyDir(src, dest) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
@ -37,17 +61,24 @@ function copyDir(src, dest) {
}
function withPpOcr(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.ppocr`;
// 1+2. 复制 Kotlin 源码与 ONNX 模型/字典到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
// platformProjectRoot 在 modRequest 里(项目根的 android/ 子目录)
const projectRoot = modConfig.modRequest.platformProjectRoot;
// Kotlin 源码OcrModule.kt + OcrPackage.kt
copyDir(
const pkgPath = appId.replace(/\./g, '/');
const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'ppocr');
// Kotlin 源码并自动重命名包名
copyAndReplaceDir(
path.join(__dirname, 'android'),
path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/ppocr'),
ktDest,
appId,
);
// ONNX 模型 + 字典(若已下载)
const assetsSrc = path.join(__dirname, 'assets');
if (fs.existsSync(assetsSrc)) {

View File

@ -1,5 +1,5 @@
{
"name": "beancount-mobile-plugin-ppocr",
"name": "drift-ledger-plugin-ppocr",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"

View File

@ -12,27 +12,36 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require('
const fs = require('fs');
const path = require('path');
const PACKAGE = 'com.beancount.mobile.screenshot';
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
function withScreenshotMonitor(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.screenshot`;
// 1. 复制 Kotlin 源码到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/screenshot');
const pkgPath = appId.replace(/\./g, '/');
const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'screenshot');
fs.mkdirSync(dest, { recursive: true });
const srcDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f));
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
fs.writeFileSync(path.join(dest, f), content, 'utf8');
}
}
return modConfig;
},
]);
// 2. 注册 ScreenshotPackage 到 MainApplication(复制 ppocr 模式)
// 2. 注册 ScreenshotPackage 到 MainApplication
config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents;

View File

@ -1,5 +1,5 @@
{
"name": "beancount-mobile-plugin-screenshot-monitor",
"name": "drift-ledger-plugin-screenshot-monitor",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"

View File

@ -42,6 +42,40 @@ function withSizeOptimization(config) {
}
]);
// 3. 在 prebuild 时修改 root build.gradle 强制指定所有模块的 NDK 版本以匹配 27.1.12297006
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const rootBuildGradlePath = path.join(projectRoot, 'build.gradle');
if (fs.existsSync(rootBuildGradlePath)) {
let content = fs.readFileSync(rootBuildGradlePath, 'utf8');
if (!content.includes('ndkVersion = "27.1.12297006"')) {
content += `
subprojects { project ->
def configureProject = { proj ->
if (proj.hasProperty("android")) {
proj.android {
ndkVersion = "27.1.12297006"
}
}
}
if (project.state.executed) {
configureProject(project)
} else {
project.afterEvaluate {
configureProject(project)
}
}
}
`;
fs.writeFileSync(rootBuildGradlePath, content, 'utf8');
}
}
return modConfig;
}
]);
return config;
}

View File

@ -1,4 +1,4 @@
{
"name": "size-optimization",
"name": "drift-ledger-plugin-size-optimization",
"main": "app.plugin.js"
}

View File

@ -11,18 +11,29 @@ const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins'
const fs = require('fs');
const path = require('path');
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
function withSmsReceiver(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.sms`;
// 1. 复制 Kotlin 源码到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/sms');
const pkgPath = appId.replace(/\./g, '/');
const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'sms');
fs.mkdirSync(dest, { recursive: true });
const srcDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f));
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
fs.writeFileSync(path.join(dest, f), content, 'utf8');
}
}
return modConfig;
@ -48,7 +59,7 @@ function withSmsReceiver(config) {
// 2. 添加短信 Receiver
const receiverNode = {
$: {
'android:name': 'com.beancount.mobile.sms.BillingSmsReceiver',
'android:name': `${PACKAGE}.BillingSmsReceiver`,
'android:exported': 'true',
},
'intent-filter': [{
@ -65,7 +76,7 @@ function withSmsReceiver(config) {
app.receiver = [];
}
const exists = app.receiver.some(
r => r.$['android:name'] === 'com.beancount.mobile.sms.BillingSmsReceiver'
r => r.$['android:name'] === `${PACKAGE}.BillingSmsReceiver`
);
if (!exists) {
app.receiver.push(receiverNode);

View File

@ -1,5 +1,5 @@
{
"name": "beancount-mobile-plugin-sms-receiver",
"name": "drift-ledger-plugin-sms-receiver",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
@ -41,7 +41,7 @@ export default function HomeScreen() {
}, [ledger, transactions]);
// 2. 周期性记账到期计算
const todayStr = useMemo(() => new Date().toISOString().slice(0, 10), []);
const todayStr = useMemo(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }, []);
const dueRecurring = useMemo(() => {
return getDueRecurring(recurringTransactions, todayStr);
}, [recurringTransactions, todayStr]);
@ -229,3 +229,4 @@ const styles = StyleSheet.create({
confirmBtnMini: { paddingVertical: 4, paddingHorizontal: 8, borderWidth: StyleSheet.hairlineWidth },
bentoStats: { gap: 8 },
});

View File

@ -1,4 +1,4 @@
/**
/**
* plan.md5.2 +5.3
*
*
@ -28,10 +28,11 @@ import { NetWorthChart } from '../../components/charts/NetWorthChart';
import { AnnualReport } from '../../components/charts/AnnualReport';
import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary';
import { BaseOpenAIProvider } from '../../domain/ai';
import { addDecimals, negateDecimal } from '../../domain/decimal';
export default function ReportScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
@ -135,31 +136,33 @@ export default function ReportScreen() {
// 周收支统计
const weeklyStats = useMemo(() => {
let income = 0;
let expense = 0;
const incomeAmounts: string[] = [];
const expenseAmounts: string[] = [];
for (const t of weeklyTxs) {
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
if (p.account.startsWith('Income')) income += -amt;
if (p.account.startsWith('Expenses')) expense += amt;
if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount));
if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount);
}
}
const income = parseFloat(addDecimals(incomeAmounts.length ? incomeAmounts : ['0']));
const expense = parseFloat(addDecimals(expenseAmounts.length ? expenseAmounts : ['0']));
return { income, expense, net: income - expense, count: weeklyTxs.length };
}, [weeklyTxs]);
// 月度收支统计
const monthlyStats = useMemo(() => {
let income = 0;
let expense = 0;
const incomeAmounts: string[] = [];
const expenseAmounts: string[] = [];
for (const t of monthlyTxs) {
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
if (p.account.startsWith('Income')) income += -amt;
if (p.account.startsWith('Expenses')) expense += amt;
if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount));
if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount);
}
}
const income = parseFloat(addDecimals(incomeAmounts.length ? incomeAmounts : ['0']));
const expense = parseFloat(addDecimals(expenseAmounts.length ? expenseAmounts : ['0']));
return { income, expense, net: income - expense, count: monthlyTxs.length };
}, [monthlyTxs]);
@ -332,7 +335,7 @@ export default function ReportScreen() {
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>-{fmtAmount(weeklyStats.expense)}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(-weeklyStats.expense)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyNet')}</Text>
@ -359,7 +362,7 @@ export default function ReportScreen() {
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>-{fmtAmount(monthlyStats.expense)}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(-monthlyStats.expense)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyNet')}</Text>
@ -444,3 +447,4 @@ const styles = StyleSheet.create({
statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' },
statItem: { alignItems: 'center', gap: 4 },
});

View File

@ -25,7 +25,7 @@ type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';
export default function TransactionsScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);

View File

@ -8,18 +8,20 @@ import { useLedgerStore } from '../store/ledgerStore';
import { useImportStore } from '../store/importStore';
import { useSettingsStore } from '../store/settingsStore';
import { useMetadataStore } from '../store/metadataStore';
import { parseDecimal } from '../domain/decimal';
import { useAutomationStore } from '../store/automationStore';
import { FileSystemBackend } from '../services/fileSystemBackend';
import { useT } from '../i18n';
import { LockScreen } from '../components/LockScreen';
import { OnboardingScreen } from './_onboarding';
import { setupDeepLinking } from '../services/deepLink';
import { initPersistence } from '../store/storePersistence';
import { initPersistence, flushPersistence } from '../store/storePersistence';
import { buildPostings } from '../domain/transactionBuilder';
import * as FileSystem from 'expo-file-system/legacy';
import { useRouter } from 'expo-router';
import { parseNotification } from '../services/notification';
import { parseSms } from '../services/sms';
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts } from '../services/automationPipeline';
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts, loadProcessedTxKeys, pendingDrafts } from '../services/automationPipeline';
import { getAccessibilityBridge } from '../services/accessibilityBridge';
import { logger } from '../utils/logger';
import {
@ -39,11 +41,23 @@ import {
let lastSignature = '';
let lastProcessedTime = 0;
/** 原生悬浮窗跳转 App 事件类型 */
interface NativeOpenAppEvent {
draftId?: string;
confirmed?: boolean;
account?: string;
category?: string;
amount?: string;
time?: string;
direction?: string;
merchant?: string;
narration?: string;
}
/** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */
const SAMPLE_LEDGER = {
path: 'main.bean',
content: `option "operating_currency" "CNY"
include "mobile.bean"
`,
};
@ -72,8 +86,15 @@ function AppShell() {
const [privacyOverlay, setPrivacyOverlay] = useState(false);
const lastMainFileRef = useRef<{ modificationTime?: number; size?: number } | null>(null);
const persistenceInitRef = useRef(false);
const lastCheckTimeRef = useRef(0);
const checkAndReloadLedger = useCallback(async () => {
// 节流2 秒内不重复检查
const now = Date.now();
if (now - lastCheckTimeRef.current < 2000) return;
lastCheckTimeRef.current = now;
const mainPath = FileSystem.documentDirectory + 'main.bean';
try {
const info = await FileSystem.getInfoAsync(mainPath);
@ -105,31 +126,17 @@ function AppShell() {
}, [loadLedger, setContext]);
useEffect(() => {
if (persistenceInitRef.current) return;
persistenceInitRef.current = true;
// 1. 初始化持久化并还原数据
initPersistence().then(async () => {
const mainPath = FileSystem.documentDirectory + 'main.bean';
const mobilePath = FileSystem.documentDirectory + 'mobile.bean';
// 加载已处理的交易键(跨会话去重)
await loadProcessedTxKeys();
try {
// 一次性合并迁移:将旧的 mobile.bean 内容追加到 main.bean 并物理删除它
const mobileInfo = await FileSystem.getInfoAsync(mobilePath);
if (mobileInfo.exists) {
const mobileContent = await FileSystem.readAsStringAsync(mobilePath);
if (mobileContent.trim()) {
const mainInfo = await FileSystem.getInfoAsync(mainPath);
let mainContent = '';
if (mainInfo.exists) {
mainContent = await FileSystem.readAsStringAsync(mainPath);
}
const mergedContent = `${mainContent.trimEnd()}\n\n${mobileContent.trim()}\n`.trim();
await FileSystem.writeAsStringAsync(mainPath, mergedContent);
logger.info('layout', '已成功将旧 mobile.bean 交易追加合并至 main.bean');
}
await FileSystem.deleteAsync(mobilePath, { idempotent: true });
}
} catch (e) {
logger.error('layout', '合并旧 mobile.bean 数据失败', e);
}
const mainPath = FileSystem.documentDirectory + 'main.bean';
// 架构说明main.bean 作为主账本文件,包含所有 Beancount 指令open/close/option/include 等),
// App 直接对其进行读写与解析。
// 2. 检查并读取本地真实主账本
FileSystem.getInfoAsync(mainPath).then(async (info) => {
@ -156,7 +163,7 @@ function AppShell() {
}
}).catch((e) => {
// 加载失败时仍进入引导流程(而非跳过),让用户有机会重新导入账本
console.warn('[layout] Ledger load failed:', e);
logger.warn('layout', `Ledger load failed: ${e instanceof Error ? e.message : String(e)}`);
setPhase('onboarding');
});
});
@ -233,36 +240,34 @@ function AppShell() {
logger.error('layout', '截图 OCR 处理失败', e);
});
}),
DeviceEventEmitter.addListener('billingOpenApp', (res) => {
DeviceEventEmitter.addListener('billingOpenApp', (res: NativeOpenAppEvent) => {
try {
logger.info('layout', `收到原生悬浮窗跳转 App 请求: ${JSON.stringify(res)}`);
if (res.draftId) {
try {
const { pendingDrafts } = require('../services/automationPipeline');
pendingDrafts.delete(res.draftId);
} catch (err) {
// 忽略
}
}
const amt = res.amount || '0';
const currency = 'CNY';
let postings: any[] = [];
if (res.direction === 'expense') {
postings = [
{ account: res.account, amount: `-${amt}`, currency },
{ account: res.category, amount: amt, currency }
];
} else if (res.direction === 'income') {
postings = [
{ account: res.account, amount: amt, currency },
{ account: res.category, amount: `-${amt}`, currency }
];
} else {
postings = [
{ account: res.account, amount: `-${amt}`, currency },
{ account: res.category, amount: amt, currency }
];
if (!res.account || !res.category || !amt || amt === '0') {
logger.warn('layout', 'billingOpenApp 数据不完整,跳过', JSON.stringify(res));
return;
}
try {
const parsed = parseDecimal(amt);
if (parsed.coefficient <= 0n) {
logger.warn('layout', 'billingOpenApp 金额无效', amt);
return;
}
} catch {
logger.warn('layout', 'billingOpenApp 金额格式错误', amt);
return;
}
const currency = 'CNY';
const dir = (res.direction || 'expense') as 'income' | 'expense' | 'transfer';
const postings = buildPostings(dir, res.account, res.category, amt, currency);
const draft = {
date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0],
payee: res.merchant || undefined,
@ -349,8 +354,11 @@ function AppShell() {
// 5. 隐私模糊plan.md「0.4 隐私模糊」App 进入后台/非活跃时遮盖内容
useEffect(() => {
const sub = AppState.addEventListener('change', (state) => {
// 切到后台/非活跃时立即遮盖,回到前台时移除
// 切到后台/非活跃时立即遮盖并同步持久化,回到前台时移除
setPrivacyOverlay(state !== 'active');
if (state !== 'active') {
flushPersistence().catch(e => logger.error('layout', 'Failed to flush persistence on background', e));
}
if (state === 'active' && phase === 'ready') {
checkAndReloadLedger();
}
@ -407,9 +415,13 @@ function AppShell() {
<Stack.Screen name="automation/index" options={{ headerShown: false }} />
<Stack.Screen name="credit-card/index" options={{ headerShown: false }} />
</Stack>
{/* 隐私遮罩plan.md「0.4」App 切后台时遮盖内容 */}
{/* 隐私遮罩plan.md「0.4」App 切后台时遮盖内容并拦截所有手势 */}
{privacyOverlay && (
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: theme.colors.bgPrimary }} />
<View
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: theme.colors.bgPrimary, zIndex: 99999 }}
pointerEvents="auto"
accessible={false}
/>
)}
</View>
);

View File

@ -5,7 +5,7 @@
* - 5 ///OCR/
* -
* - "处理全部" BillPipeline 稿
* - /稿 mobile.bean
* - /稿 main.bean
* - ScreenshotMonitor
* -
* - /
@ -16,7 +16,7 @@
* - App
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View } from 'react-native';
import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@ -25,6 +25,7 @@ import { useT } from '../../i18n';
import { useAutomationStore } from '../../store/automationStore';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useSettingsStore } from '../../store/settingsStore';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import type { AutomationSource } from '../../store/automationStore';
@ -57,6 +58,9 @@ export default function AutomationScreen() {
const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction);
const floatingBallEnabled = useSettingsStore(s => s.floatingBallEnabled);
const setFloatingBallEnabled = useSettingsStore(s => s.setFloatingBallEnabled);
// 无障碍服务状态
const [serviceRunning, setServiceRunning] = useState(false);
const [pageSignatures, setPageSignatures] = useState<PageSignature[]>([]);
@ -257,7 +261,7 @@ export default function AutomationScreen() {
<FlatList
data={drafts}
keyExtractor={(item, index) => item.draft.sourceEventId ?? `${item.draft.date}-${index}`}
keyExtractor={(item, index) => item.draft.sourceEventIds?.[0] ?? `${item.draft.date}-${index}`}
renderItem={({ item, index }) => (
<Card title={`${item.draft.date} · ${item.draft.narration}`}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
@ -303,6 +307,25 @@ export default function AutomationScreen() {
</Text>
)}
{/* 悬浮球开关 */}
<View style={[styles.switchRow, { marginBottom: 8 }]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('automation.floatingBall')}
</Text>
<Switch
value={floatingBallEnabled}
onValueChange={async (val) => {
setFloatingBallEnabled(val);
const bridge = getAccessibilityBridge();
if (bridge) {
try { await bridge.setFloatingBallEnabled(val); } catch {}
}
}}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* 操作按钮 */}
<View style={{ gap: 8 }}>
{!serviceRunning && (
@ -427,6 +450,7 @@ const styles = StyleSheet.create({
statItem: { alignItems: 'center', gap: 4 },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
statusDot: { width: 8, height: 8, borderRadius: 4 },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
chip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 },
sigRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 4 },
});

View File

@ -1,4 +1,4 @@
/**
/**
* plan.md1.1 category/index+ 1
*
* /+ //
@ -46,7 +46,7 @@ export default function CategoryScreen() {
id: generateId('cat'),
name: values.name || t('common.untitled'),
type: modal.catType,
linkedAccount: values.linkedAccount || 'Expenses:Uncategorized',
linkedAccount: values.linkedAccount || (modal.catType === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized'),
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
} else if (modal?.type === 'edit') {
@ -133,3 +133,4 @@ const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', borderTopWidth: 1, paddingTop: 10 },
addBtn: { flexDirection: 'row', alignItems: 'center', paddingBottom: 10 },
});

View File

@ -4,7 +4,7 @@
* (////) + //
* linkedAccount Beancount Liabilities
*/
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
@ -35,18 +35,21 @@ export default function CreditCardScreen() {
const [modal, setModal] = useState<ModalMode>(null);
/** 计算某账户的当前余额(从交易过账汇总)。 */
const getAccountBalance = (account: string): string => {
let balance = 0;
/** 预计算所有账户余额 Map避免在渲染循环中重复遍历)。 */
const accountBalances = useMemo(() => {
const map = new Map<string, string>();
for (const tx of transactions) {
for (const p of tx.postings) {
if (p.account === account && p.amount) {
balance += parseFloat(p.amount);
if (p.account && p.amount) {
const prev = parseFloat(map.get(p.account) ?? '0');
map.set(p.account, (prev + parseFloat(p.amount)).toFixed(2));
}
}
}
return String(balance.toFixed(2));
};
return map;
}, [transactions]);
const getAccountBalance = (account: string): string => accountBalances.get(account) ?? '0.00';
const handleDelete = (card: CreditCard) => {
Alert.alert(t('creditCard.deleteTitle'), t('creditCard.deleteConfirm', { name: card.name }), [

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState } from 'react';
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
@ -6,23 +6,16 @@ import { Ionicons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy';
import * as XLSX from 'xlsx';
// @ts-ignore
// 直接实例化 GBK 解码器,不修改全局 TextDecoder/TextEncoder
const GbkTextDecoder = (() => {
// @ts-ignore
const origDecoder = global.TextDecoder;
// @ts-ignore
const origEncoder = global.TextEncoder;
// @ts-ignore
global.TextDecoder = undefined;
// @ts-ignore
global.TextEncoder = undefined;
const dec = require('text-encoding-gbk').TextDecoder;
// @ts-ignore
global.TextDecoder = origDecoder;
// @ts-ignore
global.TextEncoder = origEncoder;
return dec;
try {
return require('text-encoding-gbk').TextDecoder;
} catch {
return TextDecoder; // 降级到系统默认
}
})();
import { useLedgerStore } from '../../store/ledgerStore';
import { useImportStore } from '../../store/importStore';
import { useMetadataStore } from '../../store/metadataStore';
@ -244,8 +237,10 @@ export default function ImportScreen() {
// 从待确认中批量移除
confirmDrafts(indices);
// 重新读取最新的 pendingDrafts 计算失败数(避免闭包过时)
const latestDrafts = useImportStore.getState().pendingDrafts;
const successCount = drafts.length;
const failCount = pendingDrafts.length - successCount;
const failCount = latestDrafts.length - successCount;
if (failCount === 0) {
setStatus(t('importFlow.batchAllSuccess', { count: successCount }));
@ -292,7 +287,7 @@ export default function ImportScreen() {
</View>
<FlatList
data={pendingDrafts}
keyExtractor={(item, index) => `${item.draft.sourceEventId || index}-${index}`}
keyExtractor={(item, index) => `${item.draft.sourceEventIds?.[0] || index}-${index}`}
renderItem={({ item, index }) => (
<Card title={`${item.draft.date} · ${item.isTransfer ? t('importFlow.transfer') : t('importFlow.trade')} · ${item.draft.postings[0]?.amount ?? ''}`}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{item.draft.narration}</Text>

View File

@ -188,18 +188,41 @@ export default function PreferencesScreen() {
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700' }]}>
{String(reminderHour).padStart(2, '0')}:{String(reminderMinute).padStart(2, '0')}
</Text>
<Pressable
onPress={async () => {
// 简单的时间调整:小时 +1 循环
const newHour = (reminderHour + 1) % 24;
updateReminderConfig({ reminderHour: newHour });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
}}
style={{ marginLeft: 12, padding: 4 }}
>
<Ionicons name="time-outline" size={20} color={theme.colors.accent} />
</Pressable>
<View style={{ flexDirection: 'row', gap: 4, marginLeft: 12 }}>
<Pressable
onPress={async () => {
const newHour = (reminderHour - 1 + 24) % 24;
updateReminderConfig({ reminderHour: newHour });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
}}
style={{ padding: 4 }}
>
<Ionicons name="remove-circle-outline" size={20} color={theme.colors.accent} />
</Pressable>
<Pressable
onPress={async () => {
const newHour = (reminderHour + 1) % 24;
updateReminderConfig({ reminderHour: newHour });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
}}
style={{ padding: 4 }}
>
<Ionicons name="add-circle-outline" size={20} color={theme.colors.accent} />
</Pressable>
<Pressable
onPress={async () => {
const newMinute = (reminderMinute + 5) % 60;
updateReminderConfig({ reminderMinute: newMinute });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), reminderHour, newMinute);
}}
style={{ padding: 4 }}
>
<Ionicons name="time-outline" size={20} color={theme.colors.accent} />
</Pressable>
</View>
</View>
)}
</Card>

View File

@ -235,9 +235,9 @@ export default function SyncSettingsScreen() {
const content = await FileSystem.readAsStringAsync(file.uri);
const bundle = deserializeBundle(content);
const restoredFiles = restoreFiles(bundle);
// 用恢复的 content 替换当前内容,支持新版 main.bean 并且兼容旧版 mobile.bean
const restoredMobileBean = restoredFiles.find(f => f.path === 'main.bean')?.content || restoredFiles.find(f => f.path === 'mobile.bean')?.content || '';
await replaceMobileBean(restoredMobileBean);
// 用恢复的 content 替换当前内容
const restoredMainBean = restoredFiles.find(f => f.path === 'main.bean')?.content || '';
await replaceMobileBean(restoredMainBean);
// 恢复设置v2
if (bundle.settings) {

View File

@ -21,7 +21,7 @@ import { TransactionCard } from '../../components/TransactionCard';
export default function TransactionDetailScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
@ -97,8 +97,8 @@ export default function TransactionDetailScreen() {
const newTargetRaw = addLinkToRaw(targetTx.raw, linkTag);
let newContent = mobileBean;
newContent = newContent.replace(tx.raw, newCurrentRaw);
newContent = newContent.replace(targetTx.raw, newTargetRaw);
newContent = newContent.replaceAll(tx.raw, newCurrentRaw);
newContent = newContent.replaceAll(targetTx.raw, newTargetRaw);
await replaceMobileBean(newContent);
setLinkModalVisible(false);
@ -132,7 +132,7 @@ export default function TransactionDetailScreen() {
lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), '');
const newRaw = lines.join('\n');
newContent = newContent.replace(companion.raw, newRaw);
newContent = newContent.replaceAll(companion.raw, newRaw);
if (companion.id === tx.id) {
newCurrentRaw = newRaw;
}

View File

@ -16,7 +16,8 @@ import { TagPicker } from '../../components/TagPicker';
import { PostingEditor } from '../../components/PostingEditor';
import { tagsToBeanSyntax } from '../../domain/tags';
import { serializeTransaction } from '../../domain/ledger';
import { buildAndSaveTransaction } from '../../domain/transactionBuilder';
import { buildAndSaveTransaction, buildPostings } from '../../domain/transactionBuilder';
import { addDecimals, compareDecimals } from '../../domain/decimal';
import { matchCategory } from '../../domain/categories';
import { getNativeOcrModule, getNativeOcrBridge } from '../../services/ocrBridge';
import { OcrProcessor } from '../../domain/ocrProcessor';
@ -27,9 +28,11 @@ import type { Posting } from '../../domain/types';
type Direction = 'expense' | 'income' | 'transfer';
let ocrProcessor: OcrProcessor | null = null;
export default function NewTransactionScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const { mode, editId, draftJson } = useLocalSearchParams<{ mode?: string; editId?: string; draftJson?: string }>();
@ -64,10 +67,15 @@ export default function NewTransactionScreen() {
]);
const [isSubmitted, setIsSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const navigation = useNavigation();
const formRef = useRef({ amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted });
formRef.current = { amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted };
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
const { amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted } = formRef.current;
const hasChanges =
amount.trim() !== '' ||
payee.trim() !== '' ||
@ -95,7 +103,7 @@ export default function NewTransactionScreen() {
});
return unsubscribe;
}, [navigation, amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted]);
}, [navigation]);
// === 草稿预填模式 ===
useEffect(() => {
@ -262,8 +270,10 @@ export default function NewTransactionScreen() {
// 3. 走 OcrProcessor 管线 (Layer1 regex → Layer2 → Layer3 AI)
const ocrBridge = getNativeOcrBridge();
const processor = new OcrProcessor(ocrBridge);
const ocrResult = await processor.process(base64);
if (!ocrProcessor) {
ocrProcessor = new OcrProcessor(ocrBridge);
}
const ocrResult = await ocrProcessor.process(base64);
if (ocrResult.event) {
// 4. 预填表单
@ -309,6 +319,9 @@ export default function NewTransactionScreen() {
}, [mode]);
const submit = () => {
if (submitting) return;
setSubmitting(true);
const tagNames = selectedTags.map(tg => tg.name);
if (isAdvanced) {
@ -316,23 +329,26 @@ export default function NewTransactionScreen() {
const validPostings = postings.filter(p => p.account.trim());
if (validPostings.length < 2) {
setStatus(t('transaction.atLeast2Postings'));
setSubmitting(false);
return;
}
// 实时借贷平衡验证
const currencySum: Record<string, number> = {};
const currencySum: Record<string, string[]> = {};
for (const p of validPostings) {
if (p.amount && p.currency) {
const val = parseFloat(p.amount);
if (!isNaN(val)) {
currencySum[p.currency] = (currencySum[p.currency] || 0) + val;
}
currencySum[p.currency] = currencySum[p.currency] || [];
currencySum[p.currency].push(p.amount);
}
}
const isBalanced = Object.values(currencySum).every(sum => Math.abs(sum) < 0.001);
const isBalanced = Object.entries(currencySum).every(([curr, amounts]) => {
const sum = addDecimals(amounts);
return compareDecimals(sum, '0') === 0;
});
if (!isBalanced) {
setStatus(t('transaction.unbalanced'));
setSubmitting(false);
return;
}
@ -351,29 +367,47 @@ export default function NewTransactionScreen() {
const newRaw = serializeTransaction(draft);
editTransaction(oldRaw, newRaw).then(() => {
setIsSubmitted(true);
setSubmitting(false);
setStatus(t('transaction.editSuccess'));
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
}).catch(e => {
setStatus(String(e));
setSubmitting(false);
});
} else {
addTransaction(draft).then(() => {
setIsSubmitted(true);
setSubmitting(false);
setStatus(t('transaction.appended'));
setNarration('');
setSelectedTags([]);
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
}).catch(e => {
setStatus(String(e));
setSubmitting(false);
});
}
} else {
// 简单模式:调用统一的 buildAndSaveTransaction 构建复式 postings 并保存
// 日期格式校验
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
setStatus(t('transaction.invalidDate'));
setSubmitting(false);
return;
}
const amt = amount.trim();
if (!amt) {
setStatus(t('transaction.amountRequired'));
setSubmitting(false);
return;
}
if (direction === 'transfer' && sourceAccount === targetAccount) {
setStatus(t('transaction.sameAccountError'));
setSubmitting(false);
return;
}
@ -381,23 +415,13 @@ export default function NewTransactionScreen() {
if (editId && oldRaw) {
// 编辑模式:由于编辑模式需要保留原始元数据并替换原始代码块,我们仍然就地拼装 draft 并执行 editTransaction
let finalPostings: Posting[];
if (direction === 'expense') {
finalPostings = [
{ account: sourceAccount, amount: `-${amt}`, currency },
{ account: categoryAccount, amount: amt, currency },
];
} else if (direction === 'income') {
finalPostings = [
{ account: sourceAccount, amount: amt, currency },
{ account: categoryAccount, amount: `-${amt}`, currency },
];
} else {
finalPostings = [
{ account: sourceAccount, amount: `-${amt}`, currency },
{ account: targetAccount, amount: amt, currency },
];
}
const finalPostings = buildPostings(
direction,
sourceAccount,
direction === 'transfer' ? targetAccount : categoryAccount,
amt,
currency
);
const draft = {
date,
@ -417,9 +441,13 @@ export default function NewTransactionScreen() {
const newRaw = serializeTransaction(draft);
editTransaction(oldRaw, newRaw).then(() => {
setIsSubmitted(true);
setSubmitting(false);
setStatus(t('transaction.editSuccess'));
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
}).catch(e => {
setStatus(String(e));
setSubmitting(false);
});
} else {
// 新建交易:直接调用统一的共享逻辑函数
buildAndSaveTransaction({
@ -438,15 +466,19 @@ export default function NewTransactionScreen() {
tags: tagNames,
links: editLinks.length > 0 ? editLinks : undefined,
metadata: editMetadata,
}).then(() => {
}, useLedgerStore.getState()).then(() => {
setIsSubmitted(true);
setSubmitting(false);
setStatus(t('transaction.appended'));
setAmount('');
setNarration('');
setSelectedCategory(null);
setSelectedTags([]);
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
}).catch(e => {
setStatus(String(e));
setSubmitting(false);
});
}
}
};
@ -776,7 +808,7 @@ export default function NewTransactionScreen() {
</View>
)}
<Button label={t('transaction.submit')} onPress={submit} />
<Button label={t('transaction.submit')} onPress={submit} disabled={submitting} />
{status ? <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8, fontFamily: theme.typography.bodySmall.fontFamily }]}>{status}</Text> : null}
</ScrollView>
</SafeAreaView>
@ -790,3 +822,4 @@ const styles = StyleSheet.create({
postingBtnRow: { flexDirection: 'row', gap: 8, marginTop: 8 },
detailsToggle: { paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center', marginTop: 4 },
});

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import { calculateAccountTotalBalance, type AccountNode } from '../domain/accountTree';
@ -21,9 +21,9 @@ export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
);
}
function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) {
const AccountTreeNode = React.memo(function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) {
const { theme } = useTheme();
const total = calculateAccountTotalBalance(node);
const total = useMemo(() => calculateAccountTotalBalance(node), [node]);
const isLeaf = node.children.length === 0;
return (
<View>
@ -40,7 +40,7 @@ function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth:
))}
</View>
);
}
});
const styles = StyleSheet.create({
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 4 },

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useCallback, useRef } from 'react';
import { Animated, Pressable, StyleSheet, Text } from 'react-native';
import { useTheme } from '../theme';
@ -14,23 +14,23 @@ export function Button({ label, onPress, variant = 'primary', disabled }: Button
const { theme } = useTheme();
const scale = useRef(new Animated.Value(1)).current;
const handlePressIn = () => {
const handlePressIn = useCallback(() => {
Animated.spring(scale, {
toValue: 0.96,
useNativeDriver: true,
tension: 180,
friction: 7,
}).start();
};
}, [scale]);
const handlePressOut = () => {
const handlePressOut = useCallback(() => {
Animated.spring(scale, {
toValue: 1.0,
useNativeDriver: true,
tension: 180,
friction: 7,
}).start();
};
}, [scale]);
const bg = variant === 'primary' ? theme.colors.accent
: variant === 'danger' ? theme.colors.error

View File

@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import { getMonthGrid, type DayCell } from '../domain/calendarGrid';
@ -25,11 +25,11 @@ export function CalendarView({ transactions, year, month, onDayPress }: Calendar
const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]);
const maxDaily = useMemo(() => Math.max(...dailyMap.values(), 1), [dailyMap]);
const handlePress = (cell: DayCell) => {
const handlePress = useCallback((cell: DayCell) => {
if (!cell.isCurrentMonth) return;
setSelectedDate(cell.date);
onDayPress?.(cell.date);
};
}, [onDayPress]);
return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.sm }]}>

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useCallback, useRef } from 'react';
import { Animated, Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
import { useTheme } from '../theme';
@ -14,23 +14,23 @@ export function Card({ title, children, onPress, style }: CardProps) {
const { theme } = useTheme();
const scale = useRef(new Animated.Value(1)).current;
const handlePressIn = () => {
const handlePressIn = useCallback(() => {
Animated.spring(scale, {
toValue: 0.98,
useNativeDriver: true,
tension: 150,
friction: 7,
}).start();
};
}, [scale]);
const handlePressOut = () => {
const handlePressOut = useCallback(() => {
Animated.spring(scale, {
toValue: 1.0,
useNativeDriver: true,
tension: 150,
friction: 7,
}).start();
};
}, [scale]);
const cardStyle = {
backgroundColor: theme.colors.bgSecondary,

View File

@ -1,4 +1,4 @@
/**
/**
* plan.md CRUD
*
* , + /
@ -18,7 +18,7 @@
* />
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n';
@ -37,6 +37,8 @@ export interface FormField {
keyboardType?: 'default' | 'numeric' | 'decimal-pad' | 'phone-pad';
/** 多行输入。 */
multiline?: boolean;
/** 密码输入(遮蔽文本)。 */
secureTextEntry?: boolean;
}
interface FormModalProps {
@ -52,7 +54,7 @@ interface FormModalProps {
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel }: FormModalProps) {
const { theme } = useTheme();
const t = useT();
const commonStyles = createCommonStyles(theme);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const [values, setValues] = useState<Record<string, string>>({});
// 每次 visible 变为 true 时,用 fields 的 defaultValue 初始化
@ -98,6 +100,7 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
placeholderTextColor={theme.colors.fgSecondary}
keyboardType={f.keyboardType ?? 'default'}
multiline={f.multiline}
secureTextEntry={f.secureTextEntry}
style={commonStyles.input}
/>
</View>

View File

@ -9,6 +9,7 @@ import { authenticate, RealAuthProvider, hashPin, verifyPin } from '../services/
/** SecureStore 中存储 PIN 哈希的 key。 */
const PIN_HASH_KEY = 'app_pin_hash';
const PIN_SALT_KEY = 'app_pin_salt';
interface LockScreenProps {
onUnlock: () => void;
@ -44,7 +45,7 @@ export function LockScreen({ onUnlock }: LockScreenProps) {
});
// 尝试生物识别
handleAuth();
}, []);
}, [handleAuth]);
const handleKeyPress = async (num: string) => {
setError(null);
@ -55,7 +56,8 @@ export function LockScreen({ onUnlock }: LockScreenProps) {
// 验证 PIN
const storedHash = await SecureStore.getItemAsync(PIN_HASH_KEY);
if (storedHash) {
const valid = await verifyPin(nextPin, storedHash);
const storedSalt = await SecureStore.getItemAsync(PIN_SALT_KEY);
const valid = await verifyPin(nextPin, storedHash, storedSalt || undefined);
if (valid) {
onUnlock();
} else {
@ -199,7 +201,7 @@ export function LockScreen({ onUnlock }: LockScreenProps) {
</Text>
</Pressable>
{renderKey('0')}
<Pressable onPress={handleBackspace} style={styles.utilityBtn} accessibilityRole="button" accessibilityLabel="Backspace">
<Pressable onPress={handleBackspace} style={styles.utilityBtn} accessibilityRole="button" accessibilityLabel={t('lockScreen.backspace')}>
<Ionicons name="backspace-outline" size={24} color={theme.colors.fgSecondary} />
</Pressable>
</View>
@ -228,12 +230,14 @@ export function LockScreen({ onUnlock }: LockScreenProps) {
/** 导出 PIN 设置/验证辅助函数供 settings 页面使用。 */
export async function setPin(pin: string): Promise<void> {
const hash = await hashPin(pin);
const { hash, salt } = await hashPin(pin);
await SecureStore.setItemAsync(PIN_HASH_KEY, hash);
await SecureStore.setItemAsync(PIN_SALT_KEY, salt);
}
export async function clearPin(): Promise<void> {
await SecureStore.deleteItemAsync(PIN_HASH_KEY);
await SecureStore.deleteItemAsync(PIN_SALT_KEY);
}
export async function hasPinSet(): Promise<boolean> {

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme, createCommonStyles } from '../theme';
import { BalanceIndicator } from './BalanceIndicator';
@ -12,29 +12,41 @@ interface PostingEditorProps {
accounts?: string[];
}
/** 生成 Posting 的稳定 key基于内容的复合 key。 */
function postingKey(p: Posting, index: number): string {
return `${p.account ?? ''}|${p.amount ?? ''}|${p.currency ?? ''}|${index}`;
}
/** 分类编辑器(主题化):编辑多行 account/amount/currency实时显示平衡状态。 */
export function PostingEditor({ postings, onChange }: PostingEditorProps) {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const postingsRef = useRef(postings);
postingsRef.current = postings;
const update = (index: number, patch: Partial<Posting>) => {
onChange(postings.map((p, i) => i === index ? { ...p, ...patch } : p));
};
// 使用函数式更新,避免 onChange 依赖 postings 导致每次渲染重建
const update = useCallback((index: number, patch: Partial<Posting>) => {
const current = postingsRef.current;
onChange(current.map((p, i) => i === index ? { ...p, ...patch } : p));
}, [onChange]);
// 计算各币种余额
const grouped = new Map<string, string[]>();
for (const p of postings) {
if (p.amount && p.currency) {
grouped.set(p.currency, [...(grouped.get(p.currency) ?? []), p.amount]);
const balances = useMemo(() => {
const grouped = new Map<string, string[]>();
for (const p of postings) {
if (p.amount && p.currency) {
grouped.set(p.currency, [...(grouped.get(p.currency) ?? []), p.amount]);
}
}
}
const balances: Record<string, string> = {};
for (const [cur, amts] of grouped) balances[cur] = addDecimals(amts);
const result: Record<string, string> = {};
for (const [cur, amts] of grouped) result[cur] = addDecimals(amts);
return result;
}, [postings]);
return (
<View style={{ gap: 8 }}>
{postings.map((p, i) => (
<View key={i} style={styles.row}>
<View key={postingKey(p, i)} style={styles.row}>
<TextInput
value={p.account}
onChangeText={v => update(i, { account: v })}
@ -48,7 +60,7 @@ export function PostingEditor({ postings, onChange }: PostingEditorProps) {
placeholder="金额"
keyboardType="decimal-pad"
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace' }]} // 金额输入也采用等宽字体更专业
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace' }]}
/>
<TextInput
value={p.currency ?? ''}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
@ -32,18 +32,20 @@ export function SpeedDial({ actions }: SpeedDialProps) {
const hasActions = actions && actions.length > 0;
const toggle = () => {
const next = !open;
setOpen(next);
Animated.spring(spin, {
toValue: next ? 1 : 0,
useNativeDriver: true,
tension: 80,
friction: 8,
}).start();
};
const toggle = useCallback(() => {
setOpen(prev => {
const next = !prev;
Animated.spring(spin, {
toValue: next ? 1 : 0,
useNativeDriver: true,
tension: 80,
friction: 8,
}).start();
return next;
});
}, [spin]);
const handleAction = (action: SpeedDialAction) => {
const handleAction = useCallback((action: SpeedDialAction) => {
setOpen(false);
Animated.spring(spin, {
toValue: 0,
@ -52,7 +54,7 @@ export function SpeedDial({ actions }: SpeedDialProps) {
friction: 8,
}).start();
action.onPress();
};
}, [spin]);
const mainRotation = spin.interpolate({
inputRange: [0, 1],

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme, createCommonStyles } from '../theme';
import type { Tag } from '../domain/tags';
@ -12,7 +12,7 @@ interface TagPickerProps {
/** 标签选择器(主题化,多选 chip。 */
export function TagPicker({ tags, selectedNames, onToggle }: TagPickerProps) {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
return (
<View style={styles.container}>

View File

@ -10,7 +10,7 @@ interface TransactionCardProps {
}
/** 交易卡片(主题化,展示日期/摘要/金额)。 */
export function TransactionCard({ transaction, onPress }: TransactionCardProps) {
export const TransactionCard = React.memo(function TransactionCard({ transaction, onPress }: TransactionCardProps) {
const { theme } = useTheme();
const t = useT();
const isExpense = transaction.postings.some(p => p.account.startsWith('Expenses'));
@ -59,7 +59,7 @@ export function TransactionCard({ transaction, onPress }: TransactionCardProps)
)}
</Pressable>
);
}
});
const styles = StyleSheet.create({
card: { marginBottom: 8 },

View File

@ -2,7 +2,7 @@
* plan.md5.2
* //
*/
import React from 'react';
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
@ -13,7 +13,7 @@ import type { Transaction } from '../../domain/types';
export function AnnualReport({ transactions, year }: { transactions: Transaction[]; year: number }) {
const { theme } = useTheme();
const t = useT();
const report = generateAnnualReport(transactions, year);
const report = useMemo(() => generateAnnualReport(transactions, year), [transactions, year]);
return (
<View style={styles.container}>

View File

@ -2,7 +2,7 @@
* plan.md5.2
*
*/
import React from 'react';
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
@ -12,7 +12,7 @@ import type { Transaction } from '../../domain/types';
export function CategoryPie({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme();
const t = useT();
const data = groupByCategory(transactions);
const data = useMemo(() => groupByCategory(transactions), [transactions]);
const maxAmount = Math.max(...data.map(d => d.amount), 1);
return (

View File

@ -2,7 +2,7 @@
* plan.md5.2
* RN
*/
import React from 'react';
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
@ -12,7 +12,7 @@ import type { Transaction } from '../../domain/types';
export function MonthlyReport({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme();
const t = useT();
const data = groupByMonth(transactions);
const data = useMemo(() => groupByMonth(transactions), [transactions]);
const maxExpense = Math.max(...data.map(d => d.expense), 1);
return (

View File

@ -1,7 +1,7 @@
/**
* plan.md5.2
*/
import React from 'react';
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
@ -11,7 +11,7 @@ import type { Transaction } from '../../domain/types';
export function NetWorthChart({ transactions, dates }: { transactions: Transaction[]; dates: string[] }) {
const { theme } = useTheme();
const t = useT();
const points = calculateNetWorthTrend(transactions, dates);
const points = useMemo(() => calculateNetWorthTrend(transactions, dates), [transactions, dates]);
const maxAbs = Math.max(...points.map(p => Math.abs(parseFloat(p.netWorth))), 1);
return (

View File

@ -2,7 +2,7 @@
* 线plan.md5.2
* react-native-svg 线
*/
import React from 'react';
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Svg, { Path, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
import { useTheme } from '../../theme';
@ -13,7 +13,7 @@ import { groupByMonth } from '../../domain/chartStats';
export function TrendLine({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme();
const t = useT();
const data = groupByMonth(transactions).slice(-6);
const data = useMemo(() => groupByMonth(transactions).slice(-6), [transactions]);
// 1. 数据配置
const width = 300;

View File

@ -2,7 +2,7 @@
* plan.md5.1
*/
import { addDecimals } from './decimal';
import { addDecimals, compareDecimals, negateDecimal } from './decimal';
import { computeAccountBalances } from './ledger';
import type { Account, LedgerIndex, Transaction } from './types';
@ -21,19 +21,7 @@ export function buildAccountTree(
ledger?: LedgerIndex,
): AccountNode[] {
// 计算每个账户余额
let balances: Map<string, string>;
if (ledger) {
balances = computeAccountBalances(ledger);
} else {
balances = new Map<string, string>();
for (const t of transactions) {
for (const p of t.postings) {
if (p.amount) {
balances.set(p.account, addDecimals([balances.get(p.account) ?? '0', p.amount]));
}
}
}
}
const balances = computeAccountBalances(ledger ?? { files: [], accounts, transactions, balances: [], options: [], diagnostics: [], unsupported: [] });
// 按顶层类型分组Assets/Liabilities/Income/Expenses/Equity
const roots: AccountNode[] = [];
@ -104,24 +92,46 @@ export function calculateReport(
});
}
let income = '0', expense = '0', assets = '0', liabilities = '0';
const incomeAmounts: string[] = [];
const expenseAmounts: string[] = [];
const assetAmounts: string[] = [];
const liabilityAmounts: string[] = [];
for (const t of tx) {
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
if (p.account.startsWith('Income')) income = addDecimals([income, amt < 0 ? p.amount.slice(1) : p.amount]);
if (p.account.startsWith('Expenses') && amt > 0) expense = addDecimals([expense, p.amount]);
if (p.account.startsWith('Assets')) assets = addDecimals([assets, p.amount]);
if (p.account.startsWith('Liabilities')) liabilities = addDecimals([liabilities, p.amount]);
if (p.account.startsWith('Income')) {
incomeAmounts.push(negateDecimal(p.amount));
}
if (p.account.startsWith('Expenses')) {
expenseAmounts.push(p.amount);
}
if (p.account.startsWith('Assets')) {
assetAmounts.push(p.amount);
}
if (p.account.startsWith('Liabilities')) {
liabilityAmounts.push(p.amount);
}
}
}
const income = addDecimals(incomeAmounts.length ? incomeAmounts : ['0']);
const expense = addDecimals(expenseAmounts.length ? expenseAmounts : ['0']);
const assets = addDecimals(assetAmounts.length ? assetAmounts : ['0']);
const liabilitiesSum = addDecimals(liabilityAmounts.length ? liabilityAmounts : ['0']);
// Beancount 复式记账中 Liabilities 账户正常余额为负数(与 Assets 相反)。
// liabilitiesSum 保持原始符号,用于 netWorth = assets + liabilitiesSum 的正确计算。
// liabilities 字段取绝对值供 UI 展示(用户看到的负债是正数)。
const liabilities = compareDecimals(liabilitiesSum, '0') < 0 ? negateDecimal(liabilitiesSum) : liabilitiesSum;
const netWorth = addDecimals([assets, liabilitiesSum]);
return {
income,
expense,
netIncome: addDecimals([income, `-${expense}`]),
netIncome: addDecimals([income, negateDecimal(expense)]),
assets,
liabilities: liabilities.startsWith('-') ? liabilities.slice(1) : liabilities,
netWorth: addDecimals([assets, liabilities.startsWith('-') ? liabilities.slice(1) : `-${liabilities}`]),
liabilities,
netWorth,
};
}

View File

@ -9,23 +9,20 @@
import { detectDirection } from './constants';
import { negateDecimal } from './decimal';
import type { ImportedEvent } from './types';
import { splitCsvLine, pickFirst } from './csvUtils';
/** 通用 CSV 行解析(与 statements.ts 一致的列提取逻辑)。 */
/** 通用 CSV 行解析(引号感知,支持含逗号字段)。 */
function parseCsvRows(content: string): Record<string, string>[] {
const lines = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n').filter(l => l.trim());
const headerLine = lines.find(l => /交易|金额|时间|日期|余额|商户|对方/i.test(l));
if (!headerLine) throw new Error('未找到账单表头');
const headers = headerLine.split(',').map(h => h.trim().replace(/^"|"$/g, ''));
const headers = splitCsvLine(headerLine).map(h => h.trim().replace(/^"|"$/g, ''));
return lines.slice(lines.indexOf(headerLine) + 1).map(line => {
const values = line.split(',').map(v => v.trim().replace(/^"|"$/g, ''));
const values = splitCsvLine(line).map(v => v.trim().replace(/^"|"$/g, ''));
return Object.fromEntries(headers.map((h, i) => [h, values[i] ?? '']));
}).filter(r => Object.values(r).some(Boolean));
}
function pickFirst(row: Record<string, string>, keys: string[]): string {
return keys.map(k => row[k]).find(v => v && v.trim())?.trim() ?? '';
}
/** 招商银行 CSV 适配器。 */
export function parseCmbCsv(content: string): ImportedEvent[] {
const rows = parseCsvRows(content);

View File

@ -56,6 +56,7 @@ export abstract class BaseOpenAIProvider implements AiProvider {
});
if (!response.ok) throw new Error(`AI 请求失败: ${response.status}`);
const data = await response.json() as { choices: { message: { content: string } }[] };
if (!data.choices?.length) throw new Error('AI 响应无有效选择');
return data.choices[0].message.content;
}
}
@ -70,8 +71,8 @@ export function removeThink(text: string): string {
/** 隐私脱敏plan.md「8.0 隐私脱敏」)。 */
export function sanitizeForAi(text: string): string {
return text
.replace(/\b\d{17}(\d|X)\b(?![\d])/g, '[身份证]') // 身份证恰好18位必须在银行卡号之前匹配
.replace(/\b\d{16,19}\b/g, '[卡号]') // 银行卡号
.replace(/\b\d{17,18}(\d|X)\b/g, '[身份证]') // 身份证
.replace(/1[3-9]\d{9}/g, '[手机号]') // 手机号
.replace(/\b\d{6}\b/g, (match, offset, str) => {
const ctx = String(str).slice(Math.max(0, offset - 20), offset + 20);

View File

@ -2,7 +2,7 @@
* plan.md5.2
*/
import { addDecimals } from './decimal';
import { addDecimals, compareDecimals, subtractDecimals, negateDecimal } from './decimal';
import type { Transaction } from './types';
export interface AnnualReport {
@ -29,54 +29,55 @@ export function generateAnnualReport(
// 单次遍历:一次性计算所有聚合数据
const incomeAmounts: string[] = [];
const expenseAmounts: string[] = [];
const byCategory = new Map<string, string>();
const byCategory = new Map<string, string[]>();
const monthlyIncome = new Map<string, string[]>();
const monthlyExpense = new Map<string, string[]>();
const byDayOfWeek = new Array(7).fill(0).map(() => [] as string[]);
let largest: Transaction | null = null;
let maxAmount = 0;
let maxAmount = '0';
const uniqueDays = new Set<string>();
for (const t of yearTx) {
uniqueDays.add(t.date.slice(0, 10));
const dateStr = t.date.slice(0, 10);
const month = t.date.slice(5, 7);
uniqueDays.add(dateStr);
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
// 收入
if (p.account.startsWith('Income')) {
const abs = amt < 0 ? p.amount.slice(1) : p.amount;
incomeAmounts.push(abs);
const negated = negateDecimal(p.amount);
incomeAmounts.push(negated);
if (!monthlyIncome.has(month)) monthlyIncome.set(month, []);
monthlyIncome.get(month)!.push(abs);
monthlyIncome.get(month)!.push(negated);
}
// 支出
if (p.account.startsWith('Expenses') && amt > 0) {
if (p.account.startsWith('Expenses')) {
expenseAmounts.push(p.amount);
const prev = byCategory.get(p.account) ?? '0';
byCategory.set(p.account, addDecimals([prev, p.amount]));
const existing = byCategory.get(p.account) ?? [];
existing.push(p.amount);
byCategory.set(p.account, existing);
if (!monthlyExpense.has(month)) monthlyExpense.set(month, []);
monthlyExpense.get(month)!.push(p.amount);
// 星期分布
const dow = new Date(t.date.slice(0, 10)).getDay();
const dow = new Date(dateStr).getDay();
byDayOfWeek[dow].push(p.amount);
// 最大交易
if (amt > maxAmount) {
maxAmount = amt;
// 最大交易decimal-safe 比较)
if (compareDecimals(p.amount, maxAmount) > 0) {
maxAmount = p.amount;
largest = t;
}
}
}
}
const totalIncome = addDecimals(incomeAmounts);
const totalExpense = addDecimals(expenseAmounts);
const netIncome = addDecimals([totalIncome, totalExpense]);
const totalIncome = addDecimals(incomeAmounts.length ? incomeAmounts : ['0']);
const totalExpense = addDecimals(expenseAmounts.length ? expenseAmounts : ['0']);
const netIncome = subtractDecimals(totalIncome, totalExpense);
// 月度趋势(单次构建,无循环 filter
const monthlyTrend = Array.from({ length: 12 }, (_, i) => {
@ -89,15 +90,17 @@ export function generateAnnualReport(
});
// Top 分类
const totalExpenseForPct = expenseAmounts.length > 0 ? addDecimals(expenseAmounts) : '0';
const topCategories = [...byCategory.entries()]
.map(([category, amount]) => ({
category,
amount,
percentage: parseFloat(totalExpenseForPct) > 0
? Math.round((parseFloat(amount) / parseFloat(totalExpenseForPct)) * 10000) / 100
: 0,
}))
.map(([category, amounts]) => {
const amount = addDecimals(amounts);
return {
category,
amount,
percentage: parseFloat(totalExpense) > 0
? Math.round((parseFloat(amount) / parseFloat(totalExpense)) * 10000) / 100
: 0,
};
})
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount))
.slice(0, 10);

View File

@ -1,7 +1,7 @@
import { classifyWithCategories } from './rules';
import { classifyWithCategories, inferPostingAmounts } from './rules';
import { batchDedup, dedupAgainstHistory, type DedupConfig } from './dedup';
import { recognizeTransfers, type TransferConfig, type TransferPair } from './transferRecognizer';
import { compareDecimals, negateDecimal } from './decimal';
import { compareDecimals } from './decimal';
import type { Category } from './categories';
import type { ImportedEvent, LedgerIndex, Rule, Transaction, TransactionDraft } from './types';
@ -16,7 +16,7 @@ import type { ImportedEvent, LedgerIndex, Rule, Transaction, TransactionDraft }
* 2. +
* 3.
* 4. + Uncategorized
* 5. 稿 MobileBeanStore
* 5. 稿 LedgerStore
*/
export interface PipelineContext {
@ -55,7 +55,6 @@ export interface PipelineResult {
function isTransferInHistory(transfer: TransferPair, history: Transaction[]): boolean {
const draft = transfer.draft;
const draftDate = draft.date;
const draftAmount = draft.postings[1]?.amount ?? '0'; // 收入方金额(正数)
const draftFromAccount = draft.postings[0]?.account;
const draftToAccount = draft.postings[1]?.account;
@ -63,19 +62,21 @@ function isTransferInHistory(transfer: TransferPair, history: Transaction[]): bo
return history.some(t => {
if (t.date !== draftDate) return false;
if (t.postings.length !== 2) return false;
// 检查是否包含转账双方的账户
const pFrom = t.postings.find(p => p.account === draftFromAccount);
const pTo = t.postings.find(p => p.account === draftToAccount);
if (!pFrom || !pTo || !pFrom.amount || !pTo.amount) return false;
// 检查两个 posting 账户是否匹配(顺序可能不同)
const tAccounts = t.postings.map(p => p.account);
const accountsMatch =
(tAccounts[0] === draftFromAccount && tAccounts[1] === draftToAccount) ||
(tAccounts[0] === draftToAccount && tAccounts[1] === draftFromAccount);
// 检查金额是否匹配(由于可能含有手续费,转出或转入任何一方的绝对金额匹配即可)
const draftAmt = (draft.postings[1].amount ?? '0').replace(/^-/, '');
const pFromAbs = pFrom.amount.replace(/^-/, '');
const pToAbs = pTo.amount.replace(/^-/, '');
if (!accountsMatch) return false;
const amtMatch =
compareDecimals(pFromAbs, draftAmt) === 0 ||
compareDecimals(pToAbs, draftAmt) === 0;
// 检查金额是否匹配
const tAmount = t.postings[1]?.amount ?? '0';
return compareDecimals(tAmount, draftAmount) === 0;
return amtMatch;
});
}
@ -143,15 +144,13 @@ export class BillPipeline {
if (event.sourceAccount && event.categoryAccount) {
// 预填账户(手动/拍照入口):直接构造 draft跳过自动分类
const amount = event.amount.startsWith('-') ? event.amount.slice(1) : event.amount;
const sourceAmount = event.direction === 'income' || event.direction === 'refund' ? amount : negateDecimal(amount);
const categoryAmount = negateDecimal(sourceAmount);
const { sourceAmount, categoryAmount } = inferPostingAmounts(event);
result = {
draft: {
date: event.occurredAt.slice(0, 10),
payee: event.counterparty || undefined,
narration: event.memo || event.counterparty || '手动记账',
sourceEventId: event.id,
sourceEventIds: [event.id],
postings: [
{ account: event.sourceAccount, amount: sourceAmount, currency: event.currency },
{ account: event.categoryAccount, amount: categoryAmount, currency: event.currency },

View File

@ -1,9 +1,9 @@
/**
/**
* plan.md5.5
* 1 .bean/
*/
import { addDecimals, subtractDecimals } from './decimal';
import { addDecimals, compareDecimals, divideDecimals, subtractDecimals, toDateString } from './decimal';
import type { Transaction } from './types';
export interface Budget {
@ -33,22 +33,41 @@ export function calculateBudgetProgress(
currentDate: string,
): BudgetProgress {
const periodTx = filterByPeriod(transactions, budget.period, currentDate, budget.categoryAccount);
const spent = periodTx.reduce((sum, t) => {
const expense = t.postings
.filter(p => p.amount && p.amount.startsWith('-'))
.reduce((s, p) => addDecimals([s, p.amount!.replace('-', '')]), '0');
return addDecimals([sum, expense]);
}, '0');
const spentAmounts: string[] = [];
for (const t of periodTx) {
for (const p of t.postings) {
if (!p.amount) continue;
const target = budget.categoryAccount;
if (target) {
if (p.account === target || p.account.startsWith(target + ':')) {
spentAmounts.push(p.amount);
}
} else {
if (p.account.startsWith('Expenses:')) {
spentAmounts.push(p.amount);
}
}
}
}
const spent = addDecimals(spentAmounts.length ? spentAmounts : ['0']);
const remaining = subtractDecimals(budget.amount, spent);
const percentage = parseFloat(budget.amount) > 0 ? (parseFloat(spent) / parseFloat(budget.amount)) * 100 : 0;
// 使用 divideDecimals 计算百分比,避免 parseFloat 精度问题
let percentage = 0;
if (compareDecimals(budget.amount, '0') > 0) {
try {
const ratio = divideDecimals(spent, budget.amount, 8);
percentage = parseFloat(ratio) * 100;
} catch {
percentage = 0;
}
}
return {
budget,
spent,
remaining,
percentage: Math.round(percentage * 100) / 100,
overBudget: parseFloat(remaining) < 0,
overBudget: remaining.startsWith('-'),
};
}
@ -87,11 +106,13 @@ export function getPeriodRange(period: Budget['period'], currentDate: string): {
case 'yearly':
return { start: `${year}-01-01`, end: `${year}-12-31` };
case 'monthly':
default:
return { start: `${year}-${String(month + 1).padStart(2, '0')}-01`, end: `${year}-${String(month + 1).padStart(2, '0')}-31` };
default: {
// 下月第 0 天 = 本月最后一天
const lastDay = new Date(year, month + 1, 0);
return {
start: `${year}-${String(month + 1).padStart(2, '0')}-01`,
end: toDateString(lastDay),
};
}
}
}
function toDateString(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

View File

@ -46,9 +46,10 @@ export function matchCategory(text: string, categories: Category[]): CategoryMat
);
if (byKeyword) return { category: byKeyword, matchedBy: 'keyword' };
// 3. 模糊匹配(包含关系,双向
// 3. 模糊匹配(包含关系,双向,最小长度 2 避免单字符误匹配
const fuzzy = categories.find(c =>
normalized.includes(c.name) || c.name.includes(normalized)
(normalized.length >= 2 && normalized.includes(c.name)) ||
(c.name.length >= 2 && c.name.includes(normalized))
);
if (fuzzy) return { category: fuzzy, matchedBy: 'fuzzy' };

View File

@ -1,63 +1,85 @@
/**
* plan.md5.2
* components/charts/ 使 react-native
*
* 使 addDecimalsdecimal string
* number SVG
*/
import { addDecimals, negateDecimal, compareDecimals } from './decimal';
import type { Transaction } from './types';
/** 按月分组统计。 */
export function groupByMonth(transactions: Transaction[]): { month: string; income: number; expense: number }[] {
const byMonth = new Map<string, { income: number; expense: number }>();
const byMonth = new Map<string, { income: string[]; expense: string[] }>();
for (const t of transactions) {
const month = t.date.slice(0, 7);
const cur = byMonth.get(month) ?? { income: 0, expense: 0 };
const cur = byMonth.get(month) ?? { income: [], expense: [] };
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
if (p.account.startsWith('Income')) cur.income += -amt;
if (p.account.startsWith('Expenses')) cur.expense += amt;
if (p.account.startsWith('Income')) {
cur.income.push(negateDecimal(p.amount));
}
if (p.account.startsWith('Expenses')) {
cur.expense.push(p.amount);
}
}
byMonth.set(month, cur);
}
return [...byMonth.entries()]
.map(([month, v]) => ({ month, ...v }))
.map(([month, v]) => ({
month,
income: parseFloat(addDecimals(v.income.length ? v.income : ['0'])),
expense: parseFloat(addDecimals(v.expense.length ? v.expense : ['0'])),
}))
.sort((a, b) => a.month.localeCompare(b.month));
}
/** 按分类统计支出。 */
export function groupByCategory(transactions: Transaction[]): { category: string; amount: number; percentage: number }[] {
const byCategory = new Map<string, number>();
let total = 0;
const byCategory = new Map<string, string[]>();
const allExpenses: string[] = [];
for (const t of transactions) {
for (const p of t.postings) {
if (p.amount && p.account.startsWith('Expenses')) {
const amt = parseFloat(p.amount);
byCategory.set(p.account, (byCategory.get(p.account) ?? 0) + amt);
total += amt;
const existing = byCategory.get(p.account) ?? [];
existing.push(p.amount);
byCategory.set(p.account, existing);
allExpenses.push(p.amount);
}
}
}
const totalStr = addDecimals(allExpenses.length ? allExpenses : ['0']);
const total = parseFloat(totalStr);
return [...byCategory.entries()]
.map(([category, amount]) => ({
category,
amount,
percentage: total > 0 ? Math.round((amount / total) * 100) : 0,
}))
.map(([category, amounts]) => {
const amountStr = addDecimals(amounts);
const amount = parseFloat(amountStr);
return {
category,
amount,
percentage: total > 0 ? Math.round((amount / total) * 100) : 0,
};
})
.sort((a, b) => b.amount - a.amount);
}
/** 按日期统计支出。 */
export function dailyExpenseMap(transactions: Transaction[]): Map<string, number> {
const byDate = new Map<string, number>();
const byDate = new Map<string, string[]>();
for (const t of transactions) {
const date = t.date.slice(0, 10);
let expense = byDate.get(date) ?? 0;
const existing = byDate.get(date) ?? [];
for (const p of t.postings) {
if (p.amount && p.account.startsWith('Expenses') && parseFloat(p.amount) > 0) {
expense += parseFloat(p.amount);
if (p.amount && p.account.startsWith('Expenses')) {
existing.push(p.amount);
}
}
if (expense > 0) byDate.set(date, expense);
if (existing.length > 0) byDate.set(date, existing);
}
return byDate;
const result = new Map<string, number>();
for (const [date, amounts] of byDate) {
result.set(date, parseFloat(addDecimals(amounts)));
}
return result;
}

View File

@ -9,7 +9,11 @@ import type { Direction } from './types';
// ─── 支付 App 包名 ───────────────────────────────────────────────
/** 包名 → 友好显示名(用于 UI 展示)。 */
/**
* App
* Kotlin Config Plugin expo prebuild
* `npx expo prebuild`
*/
export const PAYMENT_PACKAGES: Record<string, string> = {
'com.eg.android.AlipayGphone': '支付宝',
'com.tencent.mm': '微信',

View File

@ -4,7 +4,7 @@
* UI // .bean
*/
import { addDecimals } from './decimal';
import { addDecimals, subtractDecimals, toDateString } from './decimal';
import type { Transaction } from './types';
export interface CreditCard {
@ -30,10 +30,13 @@ export interface BillingPeriod {
export function calculateBillingPeriod(card: CreditCard, currentDate: Date): BillingPeriod {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// 限制账单日/还款日不超过 28防止月份溢出
const billingDay = Math.min(card.billingDay, 28);
const paymentDay = Math.min(card.paymentDay, 28);
// 账单周期:上月账单日 ~ 本月账单日
const periodStart = new Date(year, month - 1, card.billingDay);
const periodEnd = new Date(year, month, card.billingDay);
const dueDate = new Date(year, month, card.paymentDay);
const periodStart = new Date(year, month - 1, billingDay);
const periodEnd = new Date(year, month, billingDay);
const dueDate = new Date(year, month, paymentDay);
return {
periodStart: toDateString(periodStart),
periodEnd: toDateString(periodEnd),
@ -50,7 +53,7 @@ export function calculateStatementAmount(
const periodTx = transactions.filter(t => {
const date = t.date.slice(0, 10);
return date >= period.periodStart && date < period.periodEnd
&& t.postings.some(p => p.account.includes(card.linkedAccount));
&& t.postings.some(p => p.account === card.linkedAccount || p.account.startsWith(card.linkedAccount + ':'));
});
return periodTx.reduce((sum, t) => {
const expense = t.postings
@ -66,10 +69,6 @@ export function calculateAvailableCredit(
currentBalance: string,
): string {
// balance 为欠款(正数),可用 = 额度 - 欠款
const available = parseFloat(card.creditLimit) - parseFloat(currentBalance.replace('-', ''));
return available.toFixed(2);
}
function toDateString(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const balanceAbs = currentBalance.startsWith('-') ? currentBalance.slice(1) : currentBalance;
return subtractDecimals(card.creditLimit, balanceAbs);
}

49
src/domain/csvUtils.ts Normal file
View File

@ -0,0 +1,49 @@
/**
* CSV
*
* statements.ts adapters.ts CSV
* "含,逗号"
*/
/** 引号感知的 CSV 行拆分:正确处理 "含,逗号" 字段。 */
export function splitCsvLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"') {
if (i + 1 < line.length && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = false;
}
} else {
current += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
result.push(current.trim());
current = '';
} else {
current += ch;
}
}
}
result.push(current.trim());
return result;
}
/** 解析 CSV 表头行。 */
export function parseCsvHeaders(line: string): string[] {
return splitCsvLine(line).map(v => v.replace(/^"|"$/g, ''));
}
/** 从行对象中取第一个非空值。 */
export function pickFirst(row: Record<string, string>, keys: string[]): string {
return keys.map(k => row[k]).find(v => v && v.trim())?.trim() ?? '';
}

View File

@ -3,7 +3,7 @@
* BeeCount v30 nativeAmount +
*/
import { compareDecimals } from './decimal';
import { compareDecimals, divideDecimals, multiplyDecimals, parseDecimal } from './decimal';
export interface ExchangeRate {
from: string;
@ -38,7 +38,17 @@ export function convertCurrency(
if (from === to) return amount;
const rate = rates.find(r => r.from === from && r.to === to);
if (!rate) throw new Error(`${from}${to} 的汇率`);
return (parseFloat(amount) * parseFloat(rate.rate)).toFixed(2);
const result = multiplyDecimals(amount, rate.rate);
// 货币结果保留两位小数(使用 string decimal 精确截断,避免 parseFloat 精度风险)
const parsed = parseDecimal(result);
// 乘以 100 取整再除以 100等效于截断到 2 位小数
const scaled = (parsed.coefficient * 100n) / (10n ** BigInt(parsed.scale));
// 手动格式化保留尾随零货币显示惯例720 → 720.00
const sign = scaled < 0n ? '-' : '';
const abs = scaled < 0n ? -scaled : scaled;
const intPart = abs / 100n;
const fracPart = abs % 100n;
return `${sign}${intPart}.${fracPart.toString().padStart(2, '0')}`;
}
/**
@ -72,7 +82,7 @@ export function buildTransactionCurrency(
?? rates.find(r => r.from === baseCurrency && r.to === currencyCode);
if (!rate) throw new Error(`${currencyCode}/${baseCurrency} 汇率`);
// 正向汇率直接用,反向取倒数
const effectiveRate = rate.from === currencyCode ? rate.rate : (1 / parseFloat(rate.rate)).toFixed(6);
const effectiveRate = rate.from === currencyCode ? rate.rate : divideDecimals('1', rate.rate);
return { currencyCode, nativeAmount, exchangeRate: effectiveRate, baseCurrency };
}

View File

@ -1,4 +1,4 @@
/** Exact fixed-point helpers. Values are decimal strings, never JS numbers. */
/** Exact fixed-point helpers. Values are decimal strings, never JS numbers. */
const DECIMAL_RE = /^[+-]?\d+(\.\d+)?$/;
export function parseDecimal(value: string): { coefficient: bigint; scale: number } {
const normalized = value.trim().replace(/,/g, '');
@ -24,9 +24,31 @@ export function compareDecimals(a: string, b: string): number {
const left = x.coefficient * 10n ** BigInt(scale - x.scale); const right = y.coefficient * 10n ** BigInt(scale - y.scale);
return left === right ? 0 : left > right ? 1 : -1;
}
export function negateDecimal(value: string): string { return value.startsWith('-') ? value.slice(1) : `-${value}`; }
/** 取反(零值特殊处理,避免产生 "-0")。 */
export function negateDecimal(value: string): string {
if (value === '0' || value === '-0') return '0';
return value.startsWith('-') ? value.slice(1) : `-${value}`;
}
export function subtractDecimals(a: string, b: string): string { return addDecimals([a, negateDecimal(b)]); }
export function multiplyDecimals(a: string, b: string): string {
const x = parseDecimal(a); const y = parseDecimal(b);
return formatDecimal(x.coefficient * y.coefficient, x.scale + y.scale);
}
/** 除法a ÷ b结果保留 maxScale 位小数(默认 6适用于汇率。 */
export function divideDecimals(a: string, b: string, maxScale = 6): string {
const x = parseDecimal(a); const y = parseDecimal(b);
if (y.coefficient === 0n) throw new Error('除数不能为零');
const precision = 10n ** BigInt(maxScale);
const num = x.coefficient * precision * 10n ** BigInt(y.scale);
const den = y.coefficient * 10n ** BigInt(x.scale);
return formatDecimal(num / den, maxScale);
}
/** Date → "YYYY-MM-DD"(本地时区)。 */
export function toDateString(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}

View File

@ -1,4 +1,4 @@
import { compareDecimals } from './decimal';
import { compareDecimals, parseDecimal, formatDecimal } from './decimal';
import type { ImportedEvent, Transaction } from './types';
/**
@ -58,7 +58,14 @@ function normalize(item: ImportedEvent | DedupComparable): DedupComparable {
/** 生成去重指纹(时间 + 金额 + 对手方)。 */
export function dedupFingerprint(event: ImportedEvent): string {
const date = event.occurredAt.slice(0, 16);
const amount = Math.abs(parseFloat(event.amount)).toFixed(2);
// 使用 parseDecimal 归一化金额,确保不同格式/精度可匹配
let amount: string;
try {
const parsed = parseDecimal(event.amount);
amount = formatDecimal(parsed.coefficient < 0n ? -parsed.coefficient : parsed.coefficient, parsed.scale);
} catch {
amount = event.amount.replace(/^-/, '');
}
return `${date}|${amount}|${event.counterparty}`;
}
@ -71,30 +78,33 @@ export function checkDuplicate(
existing: (ImportedEvent | DedupComparable)[],
config: DedupConfig = DEFAULT_DEDUP_CONFIG,
): DedupResult {
const eventAmountAbs = Math.abs(parseFloat(event.amount)).toFixed(2);
const eventSign = parseFloat(event.amount) >= 0 ? 1 : -1;
const eventAmountAbs = event.amount.replace(/^-/, '');
const maxDiff = config.timeWindowMinutes * 60 * 1000;
const existingNorm = existing.map(normalize);
const eventTimeStr = event.occurredAt;
const hasTimeEvent = eventTimeStr.includes(':');
for (const ex of existingNorm) {
const exTimeStr = ex.time;
const toISO = (s: string) => s.includes('T') ? s : s.replace(' ', 'T');
let eventTime = NaN;
if (hasTimeEvent) {
eventTime = Date.parse(toISO(eventTimeStr));
}
for (const item of existing) {
const isEvent = 'occurredAt' in item;
const exTimeStr = isEvent ? item.occurredAt : item.time;
const exAmount = isEvent ? item.amount : item.amount;
const exCounterparty = isEvent ? item.counterparty : item.counterparty;
const hasTimeEx = exTimeStr.includes(':');
const isSameDay = eventTimeStr.substring(0, 10) === exTimeStr.substring(0, 10);
const exAmountAbs = Math.abs(parseFloat(ex.amount)).toFixed(2);
const exSign = parseFloat(ex.amount) >= 0 ? 1 : -1;
if (eventSign !== exSign) {
continue;
}
const exAmountAbs = exAmount.replace(/^-/, '');
if (!hasTimeEvent || !hasTimeEx) {
// 至少一方是日期粒度(例如历史账本数据)
if (isSameDay && compareDecimals(eventAmountAbs, exAmountAbs) === 0) {
const sameCounterparty = event.counterparty && ex.counterparty &&
event.counterparty === ex.counterparty;
const sameCounterparty = event.counterparty && exCounterparty &&
event.counterparty === exCounterparty;
if (sameCounterparty) {
return {
isDuplicate: true,
@ -102,7 +112,9 @@ export function checkDuplicate(
reason: `同天发生、金额相同、对手方一致(${exTimeStr}`,
};
}
if (!event.counterparty && !ex.counterparty) {
// 双方都无对手方信息 → 低置信度重复
// 仅一方有对手方 → 不判定重复(保留条,避免误杀)
if (!event.counterparty && !exCounterparty) {
return {
isDuplicate: true,
confidence: 'low',
@ -114,8 +126,11 @@ export function checkDuplicate(
}
// 双方均有时分秒,按精确时间窗匹配
const eventTime = Date.parse(eventTimeStr);
const exTime = Date.parse(exTimeStr);
const exTime = Date.parse(toISO(exTimeStr));
// 防护Date.parse 返回 NaN 时跳过(避免无效时间格式绕过窗口检查)
if (isNaN(eventTime) || isNaN(exTime)) continue;
const timeDiff = Math.abs(eventTime - exTime);
// 时间完全相同 → 高置信度重复
@ -130,8 +145,8 @@ export function checkDuplicate(
// 时间窗口内 + 金额匹配
if (timeDiff <= maxDiff && compareDecimals(eventAmountAbs, exAmountAbs) === 0) {
// 有对手方且一致 → 中置信度重复
const sameCounterparty = event.counterparty && ex.counterparty &&
event.counterparty === ex.counterparty;
const sameCounterparty = event.counterparty && exCounterparty &&
event.counterparty === exCounterparty;
if (sameCounterparty) {
return {
isDuplicate: true,
@ -151,6 +166,22 @@ export function checkDuplicate(
return { isDuplicate: false, confidence: 'none' };
}
/** Helper function to format Date as YYYY-MM-DD */
function formatDateString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* timeWindowMinutes /
* 5 0 720 12 1
*/
function dateWindowDays(timeWindowMinutes: number): number {
return Math.ceil(timeWindowMinutes / (24 * 60));
}
/**
*
*
@ -161,13 +192,29 @@ export function batchDedup(
): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } {
const accepted: ImportedEvent[] = [];
const duplicates: ImportedEvent[] = [];
const acceptedByDate = new Map<string, ImportedEvent[]>();
const windowDays = dateWindowDays(config.timeWindowMinutes);
for (const event of events) {
const result = checkDuplicate(event, accepted, config);
const eventDate = event.occurredAt.slice(0, 10);
const [y, m, d] = eventDate.split('-').map(Number);
// 收集 [eventDate - windowDays, eventDate + windowDays] 范围内的已接受事件
const candidates: ImportedEvent[] = [];
for (let offset = -windowDays; offset <= windowDays; offset++) {
const dt = formatDateString(new Date(y, m - 1, d + offset));
const list = acceptedByDate.get(dt);
if (list) candidates.push(...list);
}
const result = checkDuplicate(event, candidates, config);
if (result.isDuplicate) {
duplicates.push(event);
} else {
accepted.push(event);
const list = acceptedByDate.get(eventDate) ?? [];
list.push(event);
acceptedByDate.set(eventDate, list);
}
}
@ -176,7 +223,7 @@ export function batchDedup(
/**
*
* mobile.bean
* main.bean
*/
export function dedupAgainstHistory(
events: ImportedEvent[],
@ -185,14 +232,38 @@ export function dedupAgainstHistory(
): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } {
const accepted: ImportedEvent[] = [];
const duplicates: ImportedEvent[] = [];
const historyForCompare = history.map(t => ({
time: t.date,
amount: t.postings[0]?.amount ?? '0',
counterparty: t.payee ?? '',
}));
const windowDays = dateWindowDays(config.timeWindowMinutes);
// Group history by date YYYY-MM-DD
const historyByDate = new Map<string, DedupComparable[]>();
for (const t of history) {
const dateStr = t.date.slice(0, 10);
const sourcePosting = t.postings.find(p => p.amount && (
p.account.startsWith('Assets:') || p.account.startsWith('Liabilities:')
)) ?? t.postings[0];
const comparable: DedupComparable = {
time: dateStr,
amount: (sourcePosting?.amount ?? '0').replace(/^-/, ''),
counterparty: t.payee ?? '',
};
const list = historyByDate.get(dateStr) ?? [];
list.push(comparable);
historyByDate.set(dateStr, list);
}
for (const event of events) {
const result = checkDuplicate(event, historyForCompare, config);
const eventDate = event.occurredAt.slice(0, 10);
const [y, m, d] = eventDate.split('-').map(Number);
// 收集 [eventDate - windowDays, eventDate + windowDays] 范围内的历史交易
const candidates: DedupComparable[] = [];
for (let offset = -windowDays; offset <= windowDays; offset++) {
const dt = formatDateString(new Date(y, m - 1, d + offset));
const list = historyByDate.get(dt);
if (list) candidates.push(...list);
}
const result = checkDuplicate(event, candidates, config);
if (result.isDuplicate) {
duplicates.push(event);
} else {

View File

@ -1,4 +1,4 @@
import { addDecimals } from './decimal';
import { addDecimals } from './decimal';
import type { Account, BalanceAssertion, Diagnostic, LedgerFile, LedgerIndex, Option, Posting, Transaction, TransactionDraft, ValidationResult } from './types';
/**
@ -16,7 +16,7 @@ const BALANCE = /^(\d{4}-\d{2}-\d{2})\s+balance\s+([^\s;]+)\s+(-?[+-]?[\d,]+(?:\
const OPTION = /^option\s+"([^"]+)"\s+"((?:[^"\\]|\\.)*)"/;
const PAD = /^(\d{4}-\d{2}-\d{2})\s+pad\s+([^\s]+)\s+([^\s]+)/;
const NOTE = /^(\d{4}-\d{2}-\d{2})\s+note\s+([^\s]+)\s+"((?:[^"\\]|\\.)*)"/;
const DATE_DIRECTIVE = /^\d{4}-\d{2}-\d{2}\s+(commodity|price|event|document|query|custom|location|metad|query|plugin)\b/;
const DATE_DIRECTIVE = /^\d{4}-\d{2}-\d{2}\s+(commodity|price|event|document|query|custom|location|plugin)\b/;
// posting账户 + 可选金额/币种 + 可选 cost {...} + 可选 price @
const POSTING = /^\s+([^\s;]+)(?:\s+(-?[+-]?[\d,]+(?:\.\d+)?))?(?:\s+([A-Z][A-Z0-9_-]*))?(.*)$/;
// posting 行的 metadata key: "value"
@ -38,14 +38,48 @@ const parseMetaLine = (line: string): [string, string] | null => {
const SUPPORTED_DIRECTIVE = /^\d{4}-\d{2}-\d{2}\s+(open|close|balance)\b/;
export function parseLedger(files: LedgerFile[]): LedgerIndex {
export interface ParseLedgerOptions {
/** 是否解析 include 指令(默认 false由调用方处理多文件。 */
resolveIncludes?: boolean;
}
export function parseLedger(files: LedgerFile[], options?: ParseLedgerOptions): LedgerIndex {
const resolveIncludes = options?.resolveIncludes ?? false;
const accounts = new Map<string, Account>();
const transactions: Transaction[] = [];
const balances: BalanceAssertion[] = [];
const options: Option[] = [];
const parsedOptions: Option[] = [];
const diagnostics: Diagnostic[] = [];
const unsupported: Diagnostic[] = [];
// 构建文件查找表,用于解析 include 指令(仅 resolveIncludes=true 时使用)
const fileMap = new Map<string, LedgerFile>();
if (resolveIncludes) {
for (const f of files) {
const basename = f.path.split(/[/\\]/).pop() ?? f.path;
fileMap.set(basename, f);
fileMap.set(f.path, f);
}
}
/** 解析 include 指令引用的文件内容。递归深度限制 10 防止循环引用。 */
const resolveInclude = (includePath: string, depth = 0): string => {
if (depth > 10) return '';
const included = fileMap.get(includePath);
if (!included) return '';
const lines = included.content.replace(/\r\n/g, '\n').split('\n');
const resolved: string[] = [];
for (const l of lines) {
const includeMatch = l.match(/^include\s+"([^"]+)"/);
if (includeMatch) {
resolved.push(resolveInclude(includeMatch[1], depth + 1));
} else {
resolved.push(l);
}
}
return resolved.join('\n');
};
for (const file of files) {
const lines = file.content.replace(/\r\n/g, '\n').split('\n');
let index = 0;
@ -62,7 +96,7 @@ export function parseLedger(files: LedgerFile[]): LedgerIndex {
// option 指令(无日期前缀)
const opt = line.match(OPTION);
if (opt) {
options.push({ key: opt[1], value: opt[2], source: file.path, line: index + 1 });
parsedOptions.push({ key: opt[1], value: opt[2], source: file.path, line: index + 1 });
index++;
continue;
}
@ -169,17 +203,34 @@ export function parseLedger(files: LedgerFile[]): LedgerIndex {
continue;
}
// include 指令(文件级,由调用方处理多文件)
if (/^include\s+"/.test(line)) { index++; continue; }
// include 指令:解析引用文件并合并内容
const includeMatch = line.match(/^include\s+"([^"]+)"/);
if (includeMatch) {
const includedContent = resolveInclude(includeMatch[1]);
if (includedContent) {
const includedFile: LedgerFile = { path: includeMatch[1], content: includedContent };
const includedIndex = parseLedger([includedFile], { resolveIncludes });
for (const [name, acc] of includedIndex.accounts) accounts.set(name, acc);
transactions.push(...includedIndex.transactions);
balances.push(...includedIndex.balances);
parsedOptions.push(...includedIndex.options);
diagnostics.push(...includedIndex.diagnostics);
unsupported.push(...includedIndex.unsupported);
}
index++;
continue;
}
// 未识别行
index++;
}
}
return { files, accounts, transactions, balances, options, diagnostics, unsupported };
return { files, accounts, transactions, balances, options: parsedOptions, diagnostics, unsupported };
}
function isZeroBalance(s: string): boolean { return s === '0' || s === '-0'; }
export function validateTransaction(draft: TransactionDraft, ledger: LedgerIndex): ValidationResult {
const errors: string[] = [];
const grouped = new Map<string, string[]>();
@ -203,7 +254,7 @@ export function validateTransaction(draft: TransactionDraft, ledger: LedgerIndex
if (emptyAmountCount === 0) {
for (const [currency, amounts] of grouped) {
balances[currency] = addDecimals(amounts);
if (balances[currency] !== '0') errors.push(`${currency} 不平衡:${balances[currency]}`);
if (!isZeroBalance(balances[currency])) errors.push(`${currency} 不平衡:${balances[currency]}`);
}
} else if (emptyAmountCount > 1) {
errors.push('交易中最多只能有一条分录省略金额');
@ -220,39 +271,33 @@ export function serializeTransaction(draft: TransactionDraft): string {
const tagsStr = (draft.tags ?? []).map(t => ` #${t}`).join('');
const linksStr = (draft.links ?? []).map(l => ` ^${l}`).join('');
const header = `${draft.date} ${flag}${draft.payee ? ` "${draft.payee}"` : ''} "${draft.narration}"${tagsStr}${linksStr}`;
const postingLines = draft.postings.map(p => {
// 构建 posting 行(含 posting 级 metadata避免 splice 的脆弱性
const allPostingLines: string[] = [];
for (const p of draft.postings) {
let line = ` ${p.account}`;
if (p.amount) {
line += ` ${p.amount}`;
if (p.currency) line += ` ${p.currency}`;
}
// 输出 cost {...}
if (p.cost) line += ` {${p.cost}}`;
// 输出 price @
if (p.price) line += ` @ ${p.price}`;
return line;
}).join('\n');
// 输出 posting 级 metadata每个 posting 的 metadata 作为独立行跟在对应 posting 后)
const lines = postingLines.split('\n');
const postingMetaLines: string[] = [];
draft.postings.forEach((p, i) => {
allPostingLines.push(line);
// posting 级 metadata 紧跟在 posting 行后
if (p.metadata) {
for (const [k, v] of Object.entries(p.metadata)) {
// 在对应 posting 行后插入 metadata 行
lines.splice(i + 1 + postingMetaLines.length, 0, ` ${k}: "${v}"`);
postingMetaLines.push('placeholder');
allPostingLines.push(` ${k}: "${v}"`);
}
}
});
}
return `${header}\n${metaLines}${lines.join('\n')}\n`;
return `${header}\n${metaLines}${allPostingLines.join('\n')}\n`;
}
/** 计算包含 balance 断言在内的各账户实时余额。 */
export function computeAccountBalances(ledger: LedgerIndex): Map<string, string> {
const balances = new Map<string, string>();
// 1. 找出每个账户最晚的 balance 余额断言
const latestBalances = new Map<string, { amount: string; date: string }>();
for (const b of ledger.balances) {
@ -267,16 +312,14 @@ export function computeAccountBalances(ledger: LedgerIndex): Map<string, string>
balances.set(account, info.amount);
}
// 3. 累加所有在此断言日期当天或之后的交易(若无断言则从 0 累加所有交易)
// 3. 累加交易(仅对有断言的账户做日期过滤,无断言的账户从 0 累加所有交易)
for (const t of ledger.transactions) {
for (const p of t.postings) {
if (!p.amount) continue;
const assertion = latestBalances.get(p.account);
if (assertion && t.date < assertion.date) {
continue; // 交易在断言日期之前,已被断言包含,跳过
}
if (assertion && t.date < assertion.date) continue;
balances.set(p.account, addDecimals([balances.get(p.account) ?? '0', p.amount]));
}
}

View File

@ -1,11 +1,36 @@
import { serializeTransaction, validateTransaction } from './ledger';
import type { LedgerIndex, TransactionDraft } from './types';
export interface MobileCommit { content: string; diff: string; sourceEventId?: string; }
export function commitMobileTransaction(draft: TransactionDraft, ledger: LedgerIndex, currentMobileBean: string): MobileCommit {
const result = validateTransaction(draft, ledger); if (!result.valid) throw new Error(result.errors.join('\n'));
const diff = serializeTransaction(draft); return { content: `${currentMobileBean.trimEnd()}${currentMobileBean.trim() ? '\n\n' : ''}${diff}`, diff, sourceEventId: draft.sourceEventId };
export interface MobileCommit {
content: string;
diff: string;
sourceEventIds?: string[];
}
export function exportLedgerBundle(ledger: LedgerIndex, mobileBean: string): Record<string, string> {
return Object.fromEntries([...ledger.files.filter(file => file.path !== 'mobile.bean'), { path: 'mobile.bean', content: mobileBean }].map(file => [file.path, file.content]));
export function commitMobileTransaction(
draft: TransactionDraft,
ledger: LedgerIndex,
currentMobileBean: string,
): MobileCommit {
const result = validateTransaction(draft, ledger);
if (!result.valid) throw new Error(result.errors.join('\n'));
const diff = serializeTransaction(draft);
const separator = currentMobileBean.trim() ? '\n\n' : '';
return {
content: `${currentMobileBean.trimEnd()}${separator}${diff}`,
diff,
sourceEventIds: draft.sourceEventIds,
};
}
export function exportLedgerBundle(
ledger: LedgerIndex,
mainBean: string,
): Record<string, string> {
return Object.fromEntries(
[
...ledger.files.filter(file => file.path !== 'main.bean'),
{ path: 'main.bean', content: mainBean },
].map(file => [file.path, file.content]),
);
}

View File

@ -4,9 +4,9 @@ import type { LedgerIndex, TransactionDraft } from './types';
import type { MobileCommit } from './mobile';
/**
* mobile.bean
* main.bean
*
* plan.md0.5 mobile.bean
*
* - Promise
* - + rename
* - tmp
@ -16,7 +16,7 @@ import type { MobileCommit } from './mobile';
/** 持久化后端抽象(生产环境用 expo-file-system测试用内存实现。 */
export interface MobileBeanBackend {
/** 读取当前 mobile.bean 内容;不存在返回空串。 */
/** 读取当前 main.bean 内容;不存在返回空串。 */
read(): Promise<string>;
/** 原子写入:写 tmp → rename保证中途崩溃不损坏原文件。 */
writeAtomic(content: string): Promise<void>;
@ -65,7 +65,7 @@ export class MemoryBackend implements MobileBeanBackend {
}
/**
* mobile.bean
* main.bean
*
*
* const store = new MobileBeanStore(backend);
@ -78,15 +78,24 @@ export class MobileBeanStore {
private chain: Promise<unknown> = Promise.resolve();
private initialized = false;
private _recovered = false;
private _initPromise: Promise<void> | null = null;
constructor(private readonly backend: MobileBeanBackend) {}
/** 启动时调用:恢复崩溃残留 + 加载当前内容。 */
/** 启动时调用:恢复崩溃残留 + 加载当前内容。并发调用共享同一 Promise。 */
async init(): Promise<void> {
if (this.initialized) return;
this._recovered = await this.backend.recoverIfNeeded();
this.current = await this.backend.read();
this.initialized = true;
if (this._initPromise) return this._initPromise;
this._initPromise = (async () => {
this._recovered = await this.backend.recoverIfNeeded();
this.current = await this.backend.read();
this.initialized = true;
})().catch(err => {
// 失败后清除 promise允许重试
this._initPromise = null;
throw err;
});
return this._initPromise;
}
/** 上次 init 是否检测到崩溃残留并恢复。 */
@ -94,27 +103,39 @@ export class MobileBeanStore {
return this._recovered;
}
/** 当前内存中的 mobile.bean 内容init 后可用)。 */
/** 当前内存中的 main.bean 内容init 后可用)。 */
getContent(): string {
return this.current;
}
/** 从 backend 重新读取内容以更新内存缓存(用于外部修改感知)。 */
async reload(): Promise<void> {
this.current = await this.backend.read();
}
/**
* mobile.bean
* main.bean
*
*/
append(draft: TransactionDraft, ledger: LedgerIndex): Promise<MobileCommit> {
return this.enqueue(async () => {
const commit = commitMobileTransaction(draft, ledger, this.current);
const prev = this.current;
this.current = commit.content;
await this.backend.writeAtomic(this.current);
try {
await this.backend.writeAtomic(this.current);
} catch (e) {
// 写入失败时回滚内存状态,防止内存与磁盘不一致
this.current = prev;
throw e;
}
return commit;
});
}
/** 用已解析的事件 id 标记来源(便于去重查询关联到已入账交易)。 */
appendWithSource(draft: TransactionDraft, ledger: LedgerIndex, sourceEventId?: string): Promise<MobileCommit> {
if (sourceEventId) draft = { ...draft, sourceEventId };
appendWithSource(draft: TransactionDraft, ledger: LedgerIndex, sourceEventIds?: string[]): Promise<MobileCommit> {
if (sourceEventIds) draft = { ...draft, sourceEventIds };
return this.append(draft, ledger);
}
@ -124,8 +145,31 @@ export class MobileBeanStore {
*/
replace(content: string): Promise<void> {
return this.enqueue(async () => {
const prev = this.current;
this.current = content;
await this.backend.writeAtomic(this.current);
try {
await this.backend.writeAtomic(this.current);
} catch (e) {
this.current = prev;
throw e;
}
});
}
/**
* read-modify-write
* autoOpenAccounts/adjustAccountBalance
*/
modifyContent(fn: (current: string) => string): Promise<void> {
return this.enqueue(async () => {
const prev = this.current;
this.current = fn(this.current);
try {
await this.backend.writeAtomic(this.current);
} catch (e) {
this.current = prev;
throw e;
}
});
}
@ -153,10 +197,10 @@ export class MobileBeanStore {
/**
* id
* sourceEventId imported_events.id
* sourceEventIds imported_events.id
*/
export function extractSourceEventId(draft: TransactionDraft): string | undefined {
return draft.sourceEventId;
export function extractSourceEventIds(draft: TransactionDraft): string[] | undefined {
return draft.sourceEventIds;
}
/** 便捷类型:存储 + 后端 的工厂(生产环境注入 FileSystemBackend。 */

View File

@ -2,7 +2,7 @@
* plan.md5.2
*/
import { addDecimals, subtractDecimals } from './decimal';
import { addDecimals, subtractDecimals, compareDecimals, negateDecimal } from './decimal';
import type { Transaction } from './types';
export interface NetWorthPoint {
@ -43,8 +43,8 @@ export function calculateNetWorth(
const assets = calculateBalanceByType(filtered, 'Assets');
const liabilities = calculateBalanceByType(filtered, 'Liabilities');
// 负债账户正常是负余额,取绝对值
const liabAbs = liabilities.startsWith('-') ? liabilities.slice(1) : liabilities;
const netWorth = subtractDecimals(assets, liabAbs.startsWith('-') ? liabAbs.slice(1) : liabAbs);
const liabAbs = compareDecimals(liabilities, '0') < 0 ? negateDecimal(liabilities) : liabilities;
const netWorth = subtractDecimals(assets, liabAbs);
return { assets, liabilities: liabAbs, netWorth };
}

View File

@ -6,6 +6,7 @@
*/
import { detectDirection, PAYMENT_PACKAGE_SET } from './constants';
import { negateDecimal } from './decimal';
import type { Direction, ImportedEvent } from './types';
/** 规则匹配结果。 */
@ -86,9 +87,9 @@ export function matchOcrRule(text: string, packageName?: string): OcrRuleMatch {
const card = extract(rule.extractors.card, text);
const direction = detectDirection(text);
// 过滤掉千分位逗号并解析金额
const parsedAmount = Math.abs(parseFloat(amountRaw.replace(/,/g, '')));
const amount = direction === 'expense' ? `-${parsedAmount}` : String(parsedAmount);
// 过滤掉千分位逗号,保持字符串精确运算(不使用 parseFloat
const cleanAmount = amountRaw.replace(/,/g, '');
const amount = direction === 'expense' ? negateDecimal(cleanAmount) : cleanAmount;
const event: ImportedEvent = {
id: `ocr:${rule.name}:${time || Date.now()}:${amountRaw}`,
@ -120,10 +121,10 @@ function normalizeTime(time: string): string {
normalized = `${year}-${normalized}`;
}
// 规范化月份和日期 (例如 2026-7-13 -> 2026-07-13)
// 规范化月份和日期 (例如 2026-7-13 -> 2026-07-13, 2026-7-1 -> 2026-07-01)
return normalized
.replace(/-(\d)-/g, '-0$1-')
.replace(/-(\d) /, '-0$1 ')
.replace(/-(\d)(?=-)/g, '-0$1') // 月补零(后面还有 -
.replace(/-(\d)(?=\s|$)/g, '-0$1') // 日补零(后面是空格或结尾)
.trim();
}
@ -136,7 +137,7 @@ function normalizeTime(time: string): string {
const DETAIL_PAGE_LABELS = [
'付款方式', '支付时间', '交易时间', '创建时间', '完成时间',
'交易成功', '交易关闭', '支付成功', '退款成功', '待发货', '已发货',
'商品说明', '商品名称', '订单号', '商家订单号', '收款方全称', '收款万全称',
'商品说明', '商品名称', '订单号', '商家订单号', '收款方全称',
'账单分类', '支付奖励', '计入收支', '退款单号', '物流单号',
];
@ -295,15 +296,15 @@ export function parseOcrBill(text: string, packageName?: string): ImportedEvent
}
// 3. 退化:提取首个金额 + 推断方向,支持千分位逗号
const amountMatch = text.match(/(?:¥|¥|金额|支付金额)\s*([\d,]+(?:\.\d+)?)/) ?? text.match(/([\d,]+(?:\.\d+)?)\s*(?:元)/);
const amountMatch = text.match(/(?:¥|¥|金额|支付金额|扣款|消费|支付|支出|收入|退款|付款)\s*([\d,]+(?:\.\d+)?)/) ?? text.match(/([\d,]+(?:\.\d+)?)\s*(?:元)/);
if (!amountMatch) return null;
const amount = parseFloat(amountMatch[1].replace(/,/g, ''));
const amountStr = amountMatch[1].replace(/,/g, '');
const direction = detectDirection(text);
return {
id: `ocr:fallback:${Date.now()}:${amountMatch[1]}`,
occurredAt: new Date().toISOString().slice(0, 16),
amount: direction === 'expense' ? `-${amount}` : String(amount),
amount: direction === 'expense' ? negateDecimal(amountStr) : amountStr,
currency: 'CNY',
direction,
counterparty: '',
@ -311,3 +312,4 @@ export function parseOcrBill(text: string, packageName?: string): ImportedEvent
raw: { text, packageName: packageName ?? '' },
};
}

View File

@ -1,4 +1,4 @@
/**
/**
* OCR plan.md3.1 +3.9 JS
*
* Layer 1: 规则匹配
@ -44,14 +44,24 @@ export interface OcrProcessorConfig {
landscapeDnd: boolean;
}
/** 队列中等待处理的请求。 */
interface QueuedRequest {
imageBase64: string;
packageName?: string;
isLandscape: boolean;
resolve: (result: OcrProcessResult) => void;
}
/**
* OCR +
*
* AutoAccounting ocrDoing
* 使 Promise
*/
export class OcrProcessor {
private processedHashes = new Set<string>();
private processedHashes = new Map<string, number>(); // hash -> timestamp for LRU
private processing = false;
private queue: QueuedRequest[] = [];
constructor(
private readonly ocrEngine: OcrEngine,
@ -61,9 +71,36 @@ export class OcrProcessor {
/**
* /
* ocrDoing
*
*/
async process(
process(
imageBase64: string,
packageName?: string,
isLandscape = false,
): Promise<OcrProcessResult> {
return new Promise(resolve => {
this.queue.push({ imageBase64, packageName, isLandscape, resolve });
this.processQueue();
});
}
private async processQueue(): Promise<void> {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
const request = this.queue.shift()!;
try {
const result = await this.doProcess(request.imageBase64, request.packageName, request.isLandscape);
request.resolve(result);
} catch {
request.resolve({ event: null, layer: 'none', skipped: 'non-bill' });
} finally {
this.processing = false;
this.processQueue();
}
}
private async doProcess(
imageBase64: string,
packageName?: string,
isLandscape = false,
@ -76,24 +113,25 @@ export class OcrProcessor {
// 去重守卫:同一图片不重复处理
const imageHash = hash(imageBase64.slice(0, IMAGE_HASH_TRUNCATE_LEN));
if (this.processedHashes.has(imageHash)) {
// LRU: 更新时间戳
this.processedHashes.set(imageHash, Date.now());
return { event: null, layer: 'none', skipped: 'dedup' };
}
// 串行化ocrDoing
if (this.processing) {
return { event: null, layer: 'none', skipped: 'busy' };
}
this.processing = true;
try {
// Layer 2: OCR 识别Layer 1 基于文本,需先 OCR
const ocrText = await this.ocrEngine.recognizeText(imageBase64);
// OCR 成功后才标记已处理,避免异常时永久阻断重试
this.processedHashes.add(imageHash);
if (this.processedHashes.size > DEDUP_CACHE_MAX) {
const first = this.processedHashes.values().next().value;
if (first) this.processedHashes.delete(first);
this.processedHashes.set(imageHash, Date.now());
// LRU 淘汰:超出阈值 20% 时批量清理最旧条目
if (this.processedHashes.size > DEDUP_CACHE_MAX * 1.2) {
const entries = [...this.processedHashes.entries()]
.sort((a, b) => a[1] - b[1]);
const toDelete = entries.slice(0, entries.length - DEDUP_CACHE_MAX);
for (const [key] of toDelete) {
this.processedHashes.delete(key);
}
}
if (!ocrText || !ocrText.trim()) {
// 无文本,尝试 Layer 3
@ -101,7 +139,7 @@ export class OcrProcessor {
}
// 针对账单详情页进行特殊路由:若判断是结构化详情页,直接绕过宽松的 Layer 1 规则匹配,
// 以避免“银行卡”等规则在详情页上误提取例如误将“消费1次”提取为“-1元”)。
// 以避免"银行卡"等规则在详情页上误提取(例如误将"消费1次"提取为"-1元")。
const isDetailPage = checkIsDetailPage(ocrText);
if (!isDetailPage) {
// Layer 1: 规则匹配(基于 OCR 文本)
@ -119,8 +157,8 @@ export class OcrProcessor {
// 非账单内容billGuard尝试 Layer 3
return await this.tryLayer3(imageBase64, ocrText);
} finally {
this.processing = false;
} catch {
return { event: null, layer: 'none', skipped: 'non-bill' };
}
}
@ -141,6 +179,7 @@ export class OcrProcessor {
reset(): void {
this.processedHashes.clear();
this.processing = false;
this.queue = [];
}
}
@ -165,3 +204,4 @@ export class MockAiVisionProvider implements AiVisionProvider {
return this.response;
}
}

View File

@ -22,7 +22,17 @@ export function getDueRecurring(
recurring: RecurringTransaction[],
currentDate: string,
): RecurringTransaction[] {
return recurring.filter(r => r.enabled && r.nextDueDate <= currentDate);
const current = Date.parse(currentDate);
if (isNaN(current)) {
// 回退到字符串比较
return recurring.filter(r => r.enabled && r.nextDueDate <= currentDate);
}
return recurring.filter(r => {
if (!r.enabled) return false;
const due = Date.parse(r.nextDueDate);
if (isNaN(due)) return r.nextDueDate <= currentDate;
return due <= current;
});
}
/** 计算下次到期日期。 */
@ -31,14 +41,37 @@ export function calculateNextDueDate(
interval: number,
lastDueDate: string,
): string {
const date = new Date(lastDueDate);
const date = new Date(lastDueDate + 'T00:00:00'); // 强制本地时间,避免 toISOString 的 UTC 偏移
switch (frequency) {
case 'daily': date.setDate(date.getDate() + interval); break;
case 'weekly': date.setDate(date.getDate() + 7 * interval); break;
case 'monthly': date.setMonth(date.getMonth() + interval); break;
case 'yearly': date.setFullYear(date.getFullYear() + interval); break;
case 'monthly': {
// 先设为 1 日避免 setMonth 溢出(如 1月31日 + 1月 → 3月3日
const targetMonth = date.getMonth() + interval;
const originalDay = date.getDate();
date.setDate(1);
date.setMonth(targetMonth);
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
date.setDate(Math.min(originalDay, lastDay));
break;
}
case 'yearly': {
// 避免闰年 2月29日 加 1 年变成 3月1日应为 2月28日
const targetYear = date.getFullYear() + interval;
const originalMonth = date.getMonth();
const originalDay = date.getDate();
date.setDate(1);
date.setFullYear(targetYear);
date.setMonth(originalMonth);
const lastDay = new Date(date.getFullYear(), originalMonth + 1, 0).getDate();
date.setDate(Math.min(originalDay, lastDay));
break;
}
}
return date.toISOString().slice(0, 10);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
/** 生成到期实例(把 nextDueDate 设为 draft.date。 */

View File

@ -32,13 +32,20 @@ const PLACEHOLDERS: Record<string, (e: ImportedEvent) => string> = {
'【备注】': (e) => e.memo || '',
};
/** 预编译占位符正则,避免运行时循环创建 RegExp */
const PLACEHOLDER_REGEXES = Object.fromEntries(
Object.keys(PLACEHOLDERS).map(ph => [
ph,
new RegExp(ph.replace(/[【】]/g, '\\$&'), 'g')
])
);
/** 生成备注:替换模板中的占位符。 */
export function generateRemark(template: string, event: ImportedEvent): string {
let remark = template;
for (const [placeholder, resolver] of Object.entries(PLACEHOLDERS)) {
// 转义【】用于正则
const escaped = placeholder.replace(/[【】]/g, '\\$&');
remark = remark.replace(new RegExp(escaped, 'g'), resolver(event));
const rx = PLACEHOLDER_REGEXES[placeholder];
remark = remark.replace(rx, resolver(event));
}
// 相邻重复归一化(如"买菜买菜"→"买菜"
remark = normalizeAdjacentDuplicates(remark);

View File

@ -11,6 +11,7 @@
*/
import type { ImportedEvent } from './types';
import { matchesStaticConditions } from './rules';
export interface EnhancedRule {
id: string;
@ -51,15 +52,23 @@ export interface JsRuleExecutor {
/**
* JS Function
* QuickJS
*
*
*/
export class FunctionSandboxExecutor implements JsRuleExecutor {
execute(jsCode: string, data: Record<string, unknown>): RuleExecutionResult {
try {
// 注入 data 和 print返回 JSON
const fn = new Function('data', 'print', `"use strict";\n${jsCode}`);
// 冻结 data 防止规则代码修改传入对象
const frozenData = Object.freeze({ ...data });
// 冻结的空全局对象,阻止访问 window/globalThis/require 等
const frozenGlobal = Object.freeze({});
// TODO: 生产环境应替换为 QuickJS 隔离执行
const fn = new Function('data', 'print', 'global', `with(global) {\n${jsCode}\n}`);
const output: string[] = [];
const print = (s: string) => { output.push(s); };
fn(data, print);
const start = Date.now();
fn(frozenData, print, frozenGlobal);
if (Date.now() - start > 50) return { matched: false };
// 规则通过 print(JSON.stringify(result)) 输出
const json = output.find(o => o.trim().startsWith('{'));
if (!json) return { matched: false };
@ -98,29 +107,19 @@ export class RuleEngine {
if (rule.jsCode) {
const result = this.executor.execute(rule.jsCode, this.eventToData(event));
if (result.matched) {
rule.hits++;
rule.lastHitAt = new Date().toISOString();
return { rule, result };
return { rule: { ...rule, hits: rule.hits + 1, lastHitAt: new Date().toISOString() }, result };
}
} else {
// 无 JS 代码,静态匹配成功即命中
rule.hits++;
rule.lastHitAt = new Date().toISOString();
return { rule, result: { matched: true } };
return { rule: { ...rule, hits: rule.hits + 1, lastHitAt: new Date().toISOString() }, result: { matched: true } };
}
}
return null;
}
/** 静态条件预筛选(参考 rules.ts 的 matches)。 */
/** 静态条件预筛选(复用 rules.ts 的公共实现)。 */
private matchStaticConditions(rule: EnhancedRule, event: ImportedEvent): boolean {
const contains = (input: string, query?: string) => !query || input.toLowerCase().includes(query.toLowerCase());
return (!rule.direction || rule.direction === event.direction)
&& (!rule.currency || rule.currency === event.currency)
&& contains(event.counterparty, rule.counterpartyContains)
&& contains(event.memo, rule.memoContains)
&& (!rule.minAmount || parseFloat(event.amount.replace('-', '')) >= parseFloat(rule.minAmount))
&& (!rule.maxAmount || parseFloat(event.amount.replace('-', '')) <= parseFloat(rule.maxAmount));
return matchesStaticConditions(event, rule);
}
/** 把 ImportedEvent 转为规则执行数据。 */

View File

@ -3,6 +3,14 @@ import { matchCategory, resolveCategoryAccount, type Category } from './categori
import type { Classification, ImportedEvent, LedgerIndex, Rule, TransactionDraft } from './types';
import { generateRemark } from './remarkTemplate';
/** 根据事件方向推断 posting 金额符号(正数=绝对金额)。 */
export function inferPostingAmounts(event: ImportedEvent): { sourceAmount: string; categoryAmount: string } {
const amount = event.amount.startsWith('-') ? event.amount.slice(1) : event.amount;
const sourceAmount = event.direction === 'income' || event.direction === 'refund' ? amount : negateDecimal(amount);
const categoryAmount = negateDecimal(sourceAmount);
return { sourceAmount, categoryAmount };
}
function getNarration(ruleNarration: string | undefined, event: ImportedEvent): string {
if (ruleNarration) {
return ruleNarration.includes('【') ? generateRemark(ruleNarration, event) : ruleNarration;
@ -16,11 +24,26 @@ function getNarration(ruleNarration: string | undefined, event: ImportedEvent):
}
const contains = (input: string, query?: string) => !query || input.toLowerCase().includes(query.toLowerCase());
function matches(event: ImportedEvent, rule: Rule): boolean {
/** 静态条件预筛选(规则匹配共用)。 */
export function matchesStaticConditions(
event: ImportedEvent,
opts: { direction?: string; currency?: string; counterpartyContains?: string; memoContains?: string; minAmount?: string; maxAmount?: string },
): boolean {
if (opts.direction && opts.direction !== event.direction) return false;
if (opts.currency && opts.currency !== event.currency) return false;
if (!contains(event.counterparty, opts.counterpartyContains)) return false;
if (!contains(event.memo, opts.memoContains)) return false;
const absAmount = event.amount.startsWith('-') ? event.amount.slice(1) : event.amount;
return (!rule.direction || rule.direction === event.direction) &&
(!rule.currency || rule.currency === event.currency) && contains(event.counterparty, rule.counterpartyContains) && contains(event.memo, rule.memoContains) &&
(!rule.minAmount || compareDecimals(absAmount, rule.minAmount) >= 0) && (!rule.maxAmount || compareDecimals(absAmount, rule.maxAmount) <= 0);
if (opts.minAmount && compareDecimals(absAmount, opts.minAmount) < 0) return false;
if (opts.maxAmount && compareDecimals(absAmount, opts.maxAmount) > 0) return false;
return true;
}
function matches(event: ImportedEvent, rule: Rule): boolean {
return matchesStaticConditions(event, rule);
}
function first(row: Record<string, string>, keys: string[]): string {
for (const k of keys) {
@ -30,14 +53,16 @@ function first(row: Record<string, string>, keys: string[]): string {
}
function sanitizeAccountSegment(name: string): string {
// 1. 将括号和中括号替换为连字符 -
let cleaned = name.replace(/[\(\)\[\]]/g, '-');
// 1. 将括号、中括号和空格替换为连字符 -
let cleaned = name.replace(/[\(\)\[\]\s]/g, '-');
// 2. 将连续的多个连字符合并为一个
cleaned = cleaned.replace(/-+/g, '-');
// 3. 移除首尾的连字符
cleaned = cleaned.replace(/^-|-$/g, '');
// 4. 仅保留字母、数字、中文和连字符 -
cleaned = cleaned.replace(/[^\w\u4e00-\u9fa5-]/g, '');
// 5. 再次合并并移除首尾的连字符(防止非字符被移去后产生连续/首尾的连字符)
cleaned = cleaned.replace(/-+/g, '-').replace(/^-|-$/g, '');
return cleaned.trim();
}
@ -97,9 +122,7 @@ export function classifyWithCategories(
// 1. 先尝试规则匹配
const sorted = [...rules].sort((a, b) => b.priority - a.priority);
const rule = sorted.find(item => matches(event, item));
const amount = event.amount.startsWith('-') ? event.amount.slice(1) : event.amount;
const sourceAmount = event.direction === 'income' || event.direction === 'refund' ? amount : negateDecimal(amount);
const categoryAmount = negateDecimal(sourceAmount);
const { sourceAmount, categoryAmount } = inferPostingAmounts(event);
// 资金来源账户(与 classify 一致)
const sourceAccountRaw = resolveSourceAccount(event, rule);
@ -127,7 +150,7 @@ export function classifyWithCategories(
// 智能还款识别(花呗还款/信用卡还款属于负债偿还,目标账户为 Liabilities
const fullText = [event.raw?.text, event.counterparty, event.memo].filter(Boolean).join(' ');
let isRepayment = false;
if (/还款/.test(fullText)) {
if (event.direction !== 'refund' && /还款/.test(fullText)) {
if (/花呗/.test(fullText)) {
categoryAccount = 'Liabilities:支付宝花呗';
categoryValid = true;
@ -162,7 +185,7 @@ export function classifyWithCategories(
payee: event.counterparty || undefined,
narration,
tags: rule?.tags,
sourceEventId: event.id,
sourceEventIds: [event.id],
postings: [
{ account: sourceAccountRaw, amount: sourceAmount, currency: event.currency },
{ account: categoryAccount, amount: categoryAmount, currency: event.currency },
@ -181,9 +204,22 @@ export function classifyWithCategories(
/** A transfer is deliberately kept as a candidate until the user confirms both legs. */
export function classifyTransferPair(left: ImportedEvent, right: ImportedEvent, leftAccount: string, rightAccount: string): TransactionDraft {
if (left.currency !== right.currency || left.amount.replace('-', '') !== right.amount.replace('-', '')) throw new Error('转账候选金额或币种不一致');
const amount = left.amount.startsWith('-') ? left.amount.slice(1) : left.amount;
const leftAbs = left.amount.startsWith('-') ? left.amount.slice(1) : left.amount;
const rightAbs = right.amount.startsWith('-') ? right.amount.slice(1) : right.amount;
if (left.currency !== right.currency || compareDecimals(leftAbs, rightAbs) !== 0) {
throw new Error('转账候选金额或币种不一致');
}
const amount = leftAbs;
const from = left.amount.startsWith('-') ? leftAccount : rightAccount;
const to = left.amount.startsWith('-') ? rightAccount : leftAccount;
return { date: (left.occurredAt < right.occurredAt ? left.occurredAt : right.occurredAt).slice(0, 10), narration: `转账:${from}${to}`, sourceEventId: `${left.id}|${right.id}`, postings: [{ account: from, amount: `-${amount}`, currency: left.currency }, { account: to, amount, currency: left.currency }] };
return {
date: (left.occurredAt < right.occurredAt ? left.occurredAt : right.occurredAt).slice(0, 10),
narration: `转账:${from}${to}`,
sourceEventIds: [left.id, right.id],
postings: [
{ account: from, amount: `-${amount}`, currency: left.currency },
{ account: to, amount, currency: left.currency },
],
};
}

View File

@ -1,18 +1,76 @@
import { detectDirection } from './constants';
import { compareDecimals, negateDecimal } from './decimal';
import { hash } from './ledger';
import type { Direction, ImportedEvent } from './types';
import { splitCsvLine } from './csvUtils';
export type StatementAdapter = 'alipay-csv-v1' | 'wechat-csv-v1' | 'bank-csv-v1';
type Row = Record<string, string>;
const columns = (line: string) => line.split(',').map(value => value.trim().replace(/^"|"$/g, ''));
const first = (row: Row, keys: string[]) => keys.map(key => row[key]).find(Boolean)?.trim() ?? '';
const digest = (value: string) => { let h = 0; for (const c of value) h = (h * 31 + c.charCodeAt(0)) | 0; return `fp-${(h >>> 0).toString(16)}`; };
function parseCsv(content: string): Row[] {
const lines = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n').filter(line => line.trim());
const headerLine = lines.find(line => /时间|date/i.test(line) && /金额|amount/i.test(line)); if (!headerLine) throw new Error('未找到账单表头');
const headers = columns(headerLine); return lines.slice(lines.indexOf(headerLine) + 1).map(line => {
const values = columns(line); return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']));
const cleanContent = content.replace(/^\uFEFF/, '');
const rows: string[][] = [];
let currentRow: string[] = [];
let currentCell = '';
let inQuotes = false;
for (let i = 0; i < cleanContent.length; i++) {
const ch = cleanContent[i];
if (inQuotes) {
if (ch === '"') {
if (i + 1 < cleanContent.length && cleanContent[i + 1] === '"') {
currentCell += '"';
i++;
} else {
inQuotes = false;
}
} else {
currentCell += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
currentRow.push(currentCell.trim());
currentCell = '';
} else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && cleanContent[i + 1] === '\n') {
i++;
}
currentRow.push(currentCell.trim());
currentCell = '';
if (currentRow.some(Boolean)) {
rows.push(currentRow);
}
currentRow = [];
} else {
currentCell += ch;
}
}
}
if (currentCell || currentRow.length > 0) {
currentRow.push(currentCell.trim());
if (currentRow.some(Boolean)) {
rows.push(currentRow);
}
}
const headerRowIndex = rows.findIndex(row =>
row.some(cell => /时间|date/i.test(cell)) &&
row.some(cell => /金额|amount/i.test(cell))
);
if (headerRowIndex === -1) {
throw new Error('未找到账单表头');
}
const headers = rows[headerRowIndex].map(h => h.replace(/^"|"$/g, ''));
const dataRows = rows.slice(headerRowIndex + 1);
return dataRows.map(row => {
return Object.fromEntries(headers.map((header, index) => [header, row[index] ?? '']));
}).filter(row => Object.values(row).some(Boolean));
}
/** CSV 方向判定:先尝试统一方向检测(组合 flow+txType再回退到金额符号与 flow 关键词。 */
@ -21,36 +79,35 @@ function direction(flow: string, txType: string, amount: string): Direction {
const detected = detectDirection(combined, 'income');
if (detected !== 'income') return detected;
// CSV 特有回退:金额为负 或 flow 含支出关键词 → expense
return amount.startsWith('-') || /支出|付款|消费|收入?支出.*支出/i.test(flow) ? 'expense' : 'income';
return amount.startsWith('-') || /支出|付款|消费/i.test(flow) ? 'expense' : 'income';
}
function mapRowToEvent(row: Row, index: number): ImportedEvent {
let amount = first(row, ['金额(元)', '金额', '交易金额', '金额(元)', 'Amount', 'amount']).replace(/[¥¥\s]/g, '').replace(/,/g, '');
const flow = first(row, ['收/支', '收支类型', '资金方向', 'Type', 'type']);
const txType = first(row, ['交易类型', '商品分类', '交易分类']);
if (!amount) throw new Error(`${index + 1} 行缺少金额`);
if (/支出|付款|消费|转出/i.test(flow) && !amount.startsWith('-')) amount = negateDecimal(amount);
const occurredAt = first(row, ['交易创建时间', '交易时间', '交易日期', '时间', 'Date', 'date']).replace(/[/.]/g, '-').slice(0, 19);
const externalId = first(row, ['交易订单号', '交易单号', '商户订单号', '流水号', 'Transaction ID', 'id']);
const counterparty = first(row, ['交易对方', '交易对手', '对方账户', '商户名称', 'Counterparty', 'merchant']);
const rawMemo = first(row, ['商品', '商品说明', '商品名称', '备注', '交易描述', 'Memo', 'description']);
const category = first(row, ['商品分类', '交易分类', '分类', 'Category', 'category']);
const method = first(row, ['收/付款方式', '收付款方式', '支付方式', 'Payment Method', 'method']);
const memo = [category, rawMemo, method ? `[${method}]` : ''].filter(s => s && s.trim() !== '').join(' - ');
const eventDirection = direction(flow, txType, amount);
const id = externalId ? `csv:${externalId}` : `fp-${hash([occurredAt, amount, eventDirection, counterparty, memo].join('|'))}`;
const isDateValid = /^\d{4}-\d{2}-\d{2}/.test(occurredAt);
const finalOccurredAt = isDateValid ? occurredAt : new Date().toISOString().slice(0, 19).replace('T', ' ');
const currency = first(row, ['币种', 'Currency', 'currency']) || 'CNY';
return { id, occurredAt: finalOccurredAt, amount, currency, direction: eventDirection, counterparty, memo, externalId: externalId || undefined, raw: row };
}
export function importStatement(content: string, adapter: StatementAdapter): ImportedEvent[] {
const rows = parseCsv(content);
return rows.map((row, index) => {
let amount = first(row, ['金额(元)', '金额', '交易金额', '金额(元)', 'Amount', 'amount']).replace(/[¥¥\s]/g, '').replace(/,/g, '');
const flow = first(row, ['收/支', '收支类型', '资金方向', 'Type', 'type']);
const txType = first(row, ['交易类型', '商品分类', '交易分类']);
if (!amount) throw new Error(`${index + 1} 行缺少金额`);
if (/支出|付款|消费|转出/i.test(flow) && !amount.startsWith('-')) amount = negateDecimal(amount);
const occurredAt = first(row, ['交易创建时间', '交易时间', '交易日期', '时间', 'Date', 'date']).replace(/[/.]/g, '-').slice(0, 19);
const externalId = first(row, ['交易订单号', '交易单号', '商户订单号', '流水号', 'Transaction ID', 'id']);
const counterparty = first(row, ['交易对方', '交易对手', '对方账户', '商户名称', 'Counterparty', 'merchant']);
const rawMemo = first(row, ['商品', '商品说明', '商品名称', '备注', '交易描述', 'Memo', 'description']);
const category = first(row, ['商品分类', '交易分类', '分类', 'Category', 'category']);
const method = first(row, ['收/付款方式', '收付款方式', '支付方式', 'Payment Method', 'method']);
const memo = [category, rawMemo, method ? `[${method}]` : ''].filter(s => s && s.trim() !== '').join(' - ');
const eventDirection = direction(flow, txType, amount);
const id = externalId ? `csv:${externalId}` : digest([occurredAt, amount, eventDirection, counterparty, memo].join('|'));
const isDateValid = /^\d{4}-\d{2}-\d{2}/.test(occurredAt);
const finalOccurredAt = isDateValid ? occurredAt : new Date().toISOString().slice(0, 19).replace('T', ' ');
return { id, occurredAt: finalOccurredAt, amount, currency: first(row, ['币种', 'Currency', 'currency']) || 'CNY', direction: eventDirection, counterparty, memo, externalId: externalId || undefined, raw: row };
});
return rows.map((row, index) => mapRowToEvent(row, index));
}
export function deduplicate(events: ImportedEvent[], existingIds: Set<string>): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } {
const seen = new Set(existingIds); const accepted: ImportedEvent[] = []; const duplicates: ImportedEvent[] = [];
for (const event of events) { if (seen.has(event.id)) duplicates.push(event); else { seen.add(event.id); accepted.push(event); } }
return { accepted, duplicates };
}
export function canPairTransfer(a: ImportedEvent, b: ImportedEvent): boolean {
const days = Math.abs(Date.parse(a.occurredAt) - Date.parse(b.occurredAt)) / 86400000;
return a.direction === 'transfer' && b.direction === 'transfer' && a.currency === b.currency && compareDecimals(a.amount, negateDecimal(b.amount)) === 0 && days <= 3;
}

View File

@ -28,9 +28,9 @@ export interface SyncConflict {
/** 同步后端抽象。 */
export interface SyncBackend {
readonly type: 'git' | 'webdav' | 'icloud' | 'supabase' | 's3' | 'none';
/** 上传 mobile.bean 全量快照。 */
/** 上传 main.bean 全量快照。 */
push(content: string): Promise<void>;
/** 拉取 mobile.bean 全量快照。 */
/** 拉取 main.bean 全量快照。 */
pull(): Promise<string | null>;
/** 获取远程最后修改时间。 */
getRemoteLastModified(): Promise<string | null>;
@ -42,6 +42,7 @@ export async function snapshotSync(
localContent: string,
lastSyncTime: string,
onConflict?: (conflict: SyncConflict) => Promise<SyncConflict>,
lastLocalModified?: string,
): Promise<{ action: 'pushed' | 'pulled' | 'conflict' | 'noop'; content: string }> {
const remoteModified = await backend.getRemoteLastModified();
const remoteContent = await backend.pull();
@ -59,13 +60,15 @@ export async function snapshotSync(
// 远程有更新且本地也有更新 → 冲突
const remoteChanged = remoteModified !== null && remoteModified > lastSyncTime;
const localChanged = true; // 进入此函数说明本地有内容
const localChanged = lastLocalModified !== undefined
? lastLocalModified > lastSyncTime
: true; // 未提供时默认为有变更(向后兼容)
if (remoteChanged && localChanged) {
if (onConflict) {
const conflict: SyncConflict = {
entityType: 'mobile.bean',
entityId: 'mobile.bean',
entityType: 'main.bean',
entityId: 'main.bean',
local: localContent,
remote: remoteContent,
resolution: 'manual',

View File

@ -1,5 +1,5 @@
import { useLedgerStore } from '../store/ledgerStore';
import type { Posting, TransactionDraft } from './types';
import type { LedgerIndex, Posting, TransactionDraft } from './types';
import { parseDecimal } from './decimal';
export interface DraftInput {
date: string;
@ -15,18 +15,55 @@ export interface DraftInput {
metadata?: Record<string, string>;
}
/** 事务存储抽象(供 buildAndSaveTransaction 使用,保持领域层纯函数)。 */
export interface TransactionStore {
ledger: LedgerIndex | null;
autoOpenAccounts(accounts: string[]): Promise<void>;
addTransaction(draft: TransactionDraft, dedupConfig?: unknown, force?: boolean): Promise<unknown>;
}
export function buildPostings(
direction: 'income' | 'expense' | 'transfer',
sourceAccount: string,
categoryAccount: string,
amount: string,
currency: string
): Posting[] {
if (direction === 'expense') {
return [
{ account: sourceAccount, amount: `-${amount}`, currency },
{ account: categoryAccount, amount, currency },
];
} else if (direction === 'income') {
return [
{ account: sourceAccount, amount, currency },
{ account: categoryAccount, amount: `-${amount}`, currency },
];
} else {
return [
{ account: sourceAccount, amount: `-${amount}`, currency },
{ account: categoryAccount, amount, currency },
];
}
}
/**
*
* (new.tsx) 使
* "记一笔" (new.tsx) 使
*/
export async function buildAndSaveTransaction(input: DraftInput, force?: boolean): Promise<void> {
export async function buildAndSaveTransaction(input: DraftInput, store: TransactionStore, force?: boolean): Promise<void> {
const amt = input.amount.trim();
if (!amt) {
throw new Error('金额不能为空');
}
const parsedAmount = parseFloat(amt);
if (isNaN(parsedAmount) || parsedAmount <= 0) {
try {
const parsed = parseDecimal(amt);
if (parsed.coefficient <= 0n) {
throw new Error('金额数值必须大于 0');
}
} catch (e) {
if (e instanceof Error && e.message === '金额数值必须大于 0') throw e;
throw new Error('金额数值必须大于 0');
}
@ -51,7 +88,6 @@ export async function buildAndSaveTransaction(input: DraftInput, force?: boolean
const currency = input.currency || 'CNY';
// 2. 自动开户容错:如果账本中不存在对应账户,自动调用 autoOpenAccounts 写入 open 指令
const store = useLedgerStore.getState();
const ledger = store.ledger;
if (ledger) {
const unopened: string[] = [];
@ -67,26 +103,7 @@ export async function buildAndSaveTransaction(input: DraftInput, force?: boolean
}
// 根据交易方向,自动构建标准的复式记账 postings
let postings: Posting[] = [];
if (input.direction === 'expense') {
// 支出: 来源账户 扣减(负), 费用账户 增加(正)
postings = [
{ account: input.sourceAccount, amount: `-${amt}`, currency },
{ account: input.categoryAccount, amount: amt, currency },
];
} else if (input.direction === 'income') {
// 收入: 来源账户 增加(正), 收入账户 扣减(负)
postings = [
{ account: input.sourceAccount, amount: amt, currency },
{ account: input.categoryAccount, amount: `-${amt}`, currency },
];
} else {
// 转账: 来源账户 扣减(负), 目标账户 增加(正)
postings = [
{ account: input.sourceAccount, amount: `-${amt}`, currency },
{ account: input.categoryAccount, amount: amt, currency },
];
}
const postings = buildPostings(input.direction, input.sourceAccount, input.categoryAccount, amt, currency);
const draft: TransactionDraft = {
date: normalizedDate,

View File

@ -1,6 +1,7 @@
import { compareDecimals } from './decimal';
import type { ImportedEvent, TransactionDraft } from './types';
import type { ImportedEvent, TransactionDraft } from './types';
import { resolveSourceAccount } from './rules';
import { parseDecimal, formatDecimal } from './decimal';
/**
* AutoAccounting TransferRecognizer
@ -53,6 +54,17 @@ function isBalanceSheetAccount(account: string): boolean {
return account.startsWith('Assets:') || account.startsWith('Liabilities:');
}
/** 归一化金额绝对值字符串,确保不同精度/格式可配对(例如 100 和 100.00)。 */
function normalizeAmount(amt: string): string {
const abs = amt.startsWith('-') ? amt.slice(1) : amt;
try {
const parsed = parseDecimal(abs);
return formatDecimal(parsed.coefficient, parsed.scale);
} catch {
return abs;
}
}
/**
* Income + Expend Transfer
*
@ -68,12 +80,30 @@ export function recognizeTransfers(
const windowMs = config.timeWindowHours * 3600 * 1000;
// 收入事件和支出事件分别收集(转账 = 一收一支配对)
const incomes = events.filter(e => e.direction === 'income' && !used.has(e.id));
const expenses = events.filter(e => e.direction === 'expense' && !used.has(e.id));
const incomes = events.filter(e =>
(e.direction === 'income' || e.direction === 'transfer') && !used.has(e.id)
);
const expenses = events.filter(e =>
(e.direction === 'expense' || e.direction === 'transfer') && !used.has(e.id)
);
// 构建支出事件的金额索引:按 "amount|currency" 分组,加速配对查找
const expenseByAmount = new Map<string, ImportedEvent[]>();
for (const expense of expenses) {
const absAmount = normalizeAmount(expense.amount);
const key = `${absAmount}|${expense.currency}`;
const list = expenseByAmount.get(key);
if (list) list.push(expense);
else expenseByAmount.set(key, [expense]);
}
for (const income of incomes) {
if (used.has(income.id)) continue;
const match = findPair(income, expenses, used, windowMs);
const incomeAbs = normalizeAmount(income.amount);
const key = `${incomeAbs}|${income.currency}`;
const candidates = expenseByAmount.get(key);
if (!candidates) continue;
const match = findPair(income, candidates, used, windowMs);
if (match) {
transfers.push(match);
used.add(match.leftEventId); // 支出方
@ -92,19 +122,18 @@ function findPair(
windowMs: number,
): TransferPair | null {
const incomeTime = Date.parse(income.occurredAt);
// decimal-safe 绝对值:避免 parseFloat 精度丢失
const incomeAbs = income.amount.startsWith('-') ? income.amount.slice(1) : income.amount;
// 防护Date.parse 返回 NaN 时跳过(避免无效时间格式绕过窗口检查)
if (isNaN(incomeTime)) return null;
const incomeAbs = normalizeAmount(income.amount);
for (const expense of candidates) {
if (used.has(expense.id)) continue;
if (income.currency !== expense.currency) continue;
// 金额匹配绝对值相等decimal-safe
const expenseAbs = expense.amount.startsWith('-') ? expense.amount.slice(1) : expense.amount;
if (compareDecimals(incomeAbs, expenseAbs) !== 0) continue;
// 时间窗口
const expenseTime = Date.parse(expense.occurredAt);
// 防护Date.parse 返回 NaN 时跳过
if (isNaN(expenseTime)) continue;
if (Math.abs(incomeTime - expenseTime) > windowMs) continue;
// 置信度评估
@ -122,7 +151,7 @@ function findPair(
const draft: TransactionDraft = {
date: (income.occurredAt < expense.occurredAt ? income.occurredAt : expense.occurredAt).slice(0, 10),
narration: `转账:${fromAccount}${toAccount}`,
sourceEventId: `${expense.id}|${income.id}`,
sourceEventIds: [expense.id, income.id],
postings: [
{ account: fromAccount, amount: `-${amount}`, currency: income.currency },
{ account: toAccount, amount, currency: income.currency },
@ -146,8 +175,11 @@ function assessConfidence(
income: ImportedEvent,
expense: ImportedEvent,
): { level: TransferConfidence; reason: string } {
// 不同对手方匹配 → 中置信度
// 不同对手方匹配 → 高/中置信度
if (income.counterparty && expense.counterparty) {
if (income.counterparty === expense.counterparty) {
return { level: 'high', reason: '对手方完全一致' };
}
if (income.counterparty.includes(expense.counterparty) || expense.counterparty.includes(income.counterparty)) {
return { level: 'medium', reason: '对手方匹配' };
}

View File

@ -26,7 +26,7 @@ export interface LedgerIndex {
options: Option[]; // option 指令(如 operating_currency
diagnostics: Diagnostic[]; unsupported: Diagnostic[];
}
export interface TransactionDraft { date: string; payee?: string; narration: string; flag?: string; postings: Posting[]; tags?: string[]; links?: string[]; sourceEventId?: string; metadata?: Record<string, string>; }
export interface TransactionDraft { date: string; payee?: string; narration: string; flag?: string; postings: Posting[]; tags?: string[]; links?: string[]; sourceEventIds?: string[]; metadata?: Record<string, string>; }
export interface ValidationResult { valid: boolean; errors: string[]; balances: Record<string, string>; }
export interface ImportedEvent {
id: string; occurredAt: string; amount: string; currency: string; direction: Direction;

View File

@ -3,8 +3,8 @@
*/
export default {
app: {
name: 'Bean Mobile',
tagline: 'Simple, offline mobile bookkeeping. Your privacy is protected, and all data is securely stored locally.',
name: 'DriftLedger',
tagline: 'Floating life\'s daily routine, all clear in one stroke. Simple, offline mobile bookkeeping.',
statusParsed: 'Ledger loaded. Your transactions are safely stored locally.',
},
common: {
@ -606,7 +606,7 @@ export default {
manualOcr: 'Manual OCR',
manualOcrTriggered: 'Recognition triggered, please wait',
manualOcrFail: 'Failed to trigger recognition',
whitelistTitle: 'Payment App Whitelist (auto-recognition only for these apps)',
whitelistTitle: 'Payment App Whitelist (for notification filtering and name display)',
whitelistEmpty: 'No whitelist',
rememberedPages: 'Remembered Pages (%{count})',
clearAll: 'Clear All',
@ -614,6 +614,7 @@ export default {
clearPagesConfirm: 'Clear all remembered pages? Auto-recognition will stop.',
bridgeUnavailable: 'Accessibility bridge unavailable (rebuild APK required)',
currentApp: 'Current App',
floatingBall: 'Floating Bill Assistant',
},
lockScreen: {
locked: 'App Locked',
@ -626,6 +627,7 @@ export default {
pinFail: 'Incorrect passcode, try again',
useBio: 'Use Biometrics',
clear: 'Clear',
backspace: 'Backspace',
},
error: {
retry: 'Retry',

View File

@ -5,8 +5,8 @@
*/
export default {
app: {
name: 'Bean Mobile',
tagline: '简单纯粹的离线移动端记账应用。保护您的隐私,所有数据安全储存在本地账本中。',
name: '浮记',
tagline: '浮生日常,一笔了然。简单纯粹的离线移动端记账应用,所有数据安全储存在本地账本中。',
statusParsed: '账本已加载。您的记账记录将安全保存在本地。',
},
common: {
@ -608,7 +608,7 @@ export default {
manualOcr: '手动识别',
manualOcrTriggered: '已触发识别,请稍候',
manualOcrFail: '触发识别失败',
whitelistTitle: '支付 App 白名单(仅这些 App 触发自动识别',
whitelistTitle: '支付 App 白名单(用于通知过滤和名称显示',
whitelistEmpty: '无白名单',
rememberedPages: '已记住的页面 (%{count})',
clearAll: '清空',
@ -616,6 +616,7 @@ export default {
clearPagesConfirm: '确定清空所有已记住的页面吗?清空后将不再自动识别。',
bridgeUnavailable: '无障碍桥接不可用(需重新编译 APK',
currentApp: '当前 App',
floatingBall: '悬浮记账助手',
},
lockScreen: {
locked: '应用已锁定',
@ -628,6 +629,7 @@ export default {
pinFail: '密码错误,请重试',
useBio: '使用生物识别',
clear: '清除',
backspace: '退格',
},
error: {
retry: '重试',

View File

@ -58,22 +58,30 @@ export interface NativeAccessibilityBridge {
direction: string,
draftId: string
): Promise<boolean>;
setFloatingBallEnabled(enabled: boolean): Promise<boolean>;
}
/**
* AccessibilityBridge
* RN Android null
* require
*/
let _bridge: NativeAccessibilityBridge | null = null;
let _bridgeTried = false;
export function getAccessibilityBridge(): NativeAccessibilityBridge | null {
try {
const { Platform, NativeModules } = require('react-native');
if (Platform.OS !== 'android') return null;
const mod = (NativeModules as { AccessibilityBridge?: NativeAccessibilityBridge }).AccessibilityBridge;
return mod ?? null;
} catch {
// 非 RN 环境测试react-native 不可用
return null;
if (!_bridgeTried) {
_bridgeTried = true;
try {
const { Platform, NativeModules } = require('react-native');
if (Platform.OS === 'android') {
const mod = (NativeModules as { AccessibilityBridge?: NativeAccessibilityBridge }).AccessibilityBridge;
_bridge = mod ?? null;
}
} catch {
// 非 RN 环境测试react-native 不可用
}
}
return _bridge;
}
/** 包名 → 友好名称映射(数据源见 constants.ts PAYMENT_PACKAGES。 */

View File

@ -0,0 +1,150 @@
/**
* /
*
* automationPipeline.ts
* - / ImportedEvent
* - RN/Expo
*/
import { compareDecimals } from '../domain/decimal';
import type { ImportedEvent } from '../domain/types';
import { logger } from '../utils/logger';
/**
*
* "交易单号" "退款单号" "本服务由财付通提供"
*/
function parseWechatTexts(texts: string[], packageName: string): ImportedEvent | null {
if (!texts.includes('交易单号') && !texts.includes('退款单号') && !texts.includes('本服务由财付通提供')) {
return null;
}
let amountStr = '0';
let merchant = '';
let time = '';
let direction: 'expense' | 'income' = 'expense';
const amountPattern = /^([+-])?(\d+(\.\d{1,2})?)$/;
for (const text of texts) {
const match = text.match(amountPattern);
if (match) {
amountStr = match[2];
if (match[1] === '+') direction = 'income';
break;
}
}
const amtIndex = texts.findIndex(t => amountPattern.test(t));
if (amtIndex > 0) {
merchant = texts[amtIndex - 1];
} else {
merchant = texts[0] || '';
}
merchant = merchant.split(',')[0].replace(/-退款$/, '');
const timeLabelIndex = texts.findIndex(t => t.includes('时间'));
if (timeLabelIndex !== -1 && timeLabelIndex + 1 < texts.length) {
time = texts[timeLabelIndex + 1];
} else {
time = new Date().toISOString().replace('T', ' ').substring(0, 19);
}
if (compareDecimals(amountStr, '0') > 0 && merchant) {
return {
id: 'nodes_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9),
occurredAt: time,
amount: amountStr,
currency: 'CNY',
direction,
counterparty: merchant,
memo: texts.includes('已退款') ? '退款' : '微信支付',
raw: { packageName },
};
}
return null;
}
/**
*
* "创建时间" "账单详情" "对此订单有疑问"
*/
function parseAlipayTexts(texts: string[], packageName: string): ImportedEvent | null {
if (!texts.includes('创建时间') && !texts.includes('账单详情') && !texts.includes('对此订单有疑问')) {
return null;
}
let amountStr = '0';
let merchant = '';
let time = '';
let direction: 'expense' | 'income' = 'expense';
let memo = '支付宝账单';
const amtWithYuanPattern = /^([+-])?(\d+(\.\d+)?)元$/;
for (const text of texts) {
const match = text.match(amtWithYuanPattern);
if (match) {
amountStr = match[2];
if (match[1] === '+') {
direction = 'income';
} else if (texts.some(t => t.includes('收款') || t.includes('收入') || t.includes('收益') || t.includes('收到') || t.includes('红包'))) {
direction = 'income';
}
break;
}
}
const amtIndex = texts.findIndex(t => amtWithYuanPattern.test(t));
if (amtIndex > 0) {
merchant = texts[amtIndex - 1];
} else {
merchant = texts[0] || '';
}
const timeLabelIndex = texts.indexOf('创建时间');
if (timeLabelIndex !== -1 && timeLabelIndex + 1 < texts.length) {
time = texts[timeLabelIndex + 1];
} else {
time = new Date().toISOString().replace('T', ' ').substring(0, 19);
}
const memoLabelIndex = texts.indexOf('商品说明');
if (memoLabelIndex !== -1 && memoLabelIndex + 1 < texts.length) {
memo = texts[memoLabelIndex + 1];
}
const catLabelIndex = texts.indexOf('账单分类');
if (catLabelIndex !== -1 && catLabelIndex + 1 < texts.length) {
const rawCat = texts[catLabelIndex + 1];
if (rawCat.includes('理财') || rawCat.includes('收益') || memo.includes('收益发放') || rawCat.includes('红包')) {
direction = 'income';
}
}
if (compareDecimals(amountStr, '0') > 0 && merchant) {
return {
id: 'nodes_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9),
occurredAt: time,
amount: amountStr,
currency: 'CNY',
direction,
counterparty: merchant,
memo,
raw: { packageName },
};
}
return null;
}
/**
* OCR
* @returns ImportedEvent null
*/
export function parseAccessibilityTexts(texts: string[], packageName: string): ImportedEvent | null {
if (packageName === 'com.tencent.mm') {
return parseWechatTexts(texts, packageName);
}
if (packageName === 'com.eg.android.AlipayGphone') {
return parseAlipayTexts(texts, packageName);
}
return null;
}

View File

@ -16,7 +16,6 @@ import { getNativeOcrBridge } from './ocrBridge';
import { AiVisionProcessor } from '../ocr/AiVisionProcessor';
import type { AiVisionProvider } from '../domain/ocrProcessor';
import { useSettingsStore } from '../store/settingsStore';
import { useAutomationStore } from '../store/automationStore';
import { logger } from '../utils/logger';
import { Alert, AppState, DeviceEventEmitter } from 'react-native';
import { router } from 'expo-router';
@ -24,26 +23,43 @@ import { useLedgerStore } from '../store/ledgerStore';
import { useMetadataStore } from '../store/metadataStore';
import { sharedPipeline } from '../domain/pipelineSingleton';
import type { ImportedEvent } from '../domain/types';
import type { ProcessedDraft } from '../domain/billPipeline';
import { getAccessibilityBridge } from './accessibilityBridge';
export interface PendingDraft {
draft: any;
event: any;
}
export const pendingDrafts = new Map<string, PendingDraft>();
// 从拆分模块导入
import {
pendingDrafts,
evictOldestPendingDraft,
buildFloatingBillData,
} from './floatingBillManager';
import {
loadProcessedTxKeys,
addProcessedTxKey,
hasProcessedTxKey,
removeProcessedTxKey,
clearProcessedTxKeys,
} from './txKeyStore';
import { parseAccessibilityTexts } from './accessibilityParser';
// 全局单监听器:响应悬浮卡片的确认入账请求,支持精确 draftId 匹配和防错强制写入
DeviceEventEmitter.addListener('billingConfirmed', async (res) => {
// ---- 向后兼容 re-export_layout.tsx 等调用方使用) ----
export { pendingDrafts } from './floatingBillManager';
export { loadProcessedTxKeys } from './txKeyStore';
export type { PendingDraft } from './floatingBillManager';
// ---- 全局监听器 ----
const BILLING_LISTENER_KEY = 'billingConfirmed';
let billingListener: { remove(): void } | null = null;
async function handleBillingConfirmed(res: { draftId?: string; confirmed?: boolean; amount?: string; time?: string; direction?: string; merchant?: string; narration?: string; category?: string; account?: string }) {
const { draftId, confirmed } = res;
if (!draftId) return;
if (!confirmed) {
// 用户点击了忽略,清除去重缓存
const pending = pendingDrafts.get(draftId);
if (pending) {
const { event } = pending;
const txKey = `${event.occurredAt.substring(0, 10)}_${event.amount}_${event.counterparty}`;
processedTxKeys.delete(txKey);
removeProcessedTxKey(txKey);
pendingDrafts.delete(draftId);
logger.info('automationPipeline', `[浮窗忽略] 用户取消入账,清除去重缓存: ${txKey}`);
}
@ -52,7 +68,7 @@ DeviceEventEmitter.addListener('billingConfirmed', async (res) => {
const pending = pendingDrafts.get(draftId);
if (pending) {
pendingDrafts.delete(draftId); // 移出 Map 防止内存泄漏
pendingDrafts.delete(draftId);
} else {
logger.warn('automationPipeline', `[浮窗入账] 未找到待挂起的确认交易草稿启用容错备选参数写入。draftId: ${draftId}`);
}
@ -65,55 +81,47 @@ DeviceEventEmitter.addListener('billingConfirmed', async (res) => {
const links = pending?.draft?.links || undefined;
const metadata = pending?.draft?.metadata || undefined;
// 后台入账为了防范去重机制把正常的同天等额交易阻断,遇到冲突直接调用 force 强制写入
await buildAndSaveTransaction({
date: draftDate,
amount: res.amount,
amount: res.amount ?? '0',
payee: res.merchant || undefined,
narration: res.narration || undefined,
categoryAccount: res.category,
sourceAccount: res.account,
categoryAccount: res.category ?? 'Expenses:未分类',
sourceAccount: res.account ?? 'Assets:未知',
direction,
currency,
tags,
links,
metadata,
}, true); // force = true
logger.info('automationPipeline', `[浮窗入账] 交易已成功在后台确认入账:${res.amount} CNY`);
}, useLedgerStore.getState(), false);
logger.info('automationPipeline', `[浮窗入账] 交易已成功在后台确认入账:${res.amount ?? '0'} CNY`);
} catch (e) {
logger.error('automationPipeline', '后台浮窗入账失败', e);
}
});
/** 原生截图事件格式BillingAccessibilityService + ScreenshotModule 推送)。 */
export interface NativeScreenshotEvent {
/** 图片 base64可含 data:image/... 前缀)。 */
base64?: string;
/** 图片 URIScreenshotModule 可选)。 */
uri?: string;
/** 来源包名。 */
packageName?: string;
/** 截图时间戳ms。 */
timestamp?: number;
/** 显示名ScreenshotModule 可选)。 */
displayName?: string;
}
let ocrProcessor: OcrProcessor | null = null;
function ensureBillingListener(): void {
if (billingListener) return;
// 热重载时先移除旧监听器,防止重复注册
DeviceEventEmitter.removeAllListeners(BILLING_LISTENER_KEY);
billingListener = DeviceEventEmitter.addListener(BILLING_LISTENER_KEY, handleBillingConfirmed);
}
// ---- OcrProcessor 单例 ----
let ocrProcessor: OcrProcessor | null = null;
let lastSettingsRef: ReturnType<typeof useSettingsStore.getState> | null = null;
/**
* / OcrProcessor
* - OCR NativeOcrBridge PP-OCRv5 Module
* - AI Provider AI + API Key AiVisionProcessor
*/
function getOcrProcessor(): OcrProcessor | null {
if (ocrProcessor) return ocrProcessor;
// 检查配置是否变更,变更时重建处理器(使用引用比较避免每次拼接字符串)
const settings = useSettingsStore.getState();
if (ocrProcessor && settings === lastSettingsRef) return ocrProcessor;
lastSettingsRef = settings;
try {
const ocrEngine = getNativeOcrBridge();
const settings = useSettingsStore.getState();
// settings already obtained above for config check
// AI Vision Provider仅当用户启用 AI 且配置了 API Key
let aiProvider: AiVisionProvider | undefined;
let aiVisionEnabled = false;
if (settings.aiEnabled && settings.aiApiKey && settings.aiBaseUrl) {
@ -137,15 +145,16 @@ function getOcrProcessor(): OcrProcessor | null {
}
}
/**
* base64 OcrProcessor ImportedEvent automationStore
*
*
* 1. base64 base64ScreenshotModule base64
* 2. OcrProcessor
* 3. addDetected('screenshot', event) automationStore
* 4.
*/
// ---- 截图处理 ----
export interface NativeScreenshotEvent {
base64?: string;
uri?: string;
packageName?: string;
timestamp?: number;
displayName?: string;
}
export async function processScreenshotEvent(event: NativeScreenshotEvent): Promise<OcrProcessResult | null> {
const base64 = event.base64;
if (!base64) {
@ -174,7 +183,8 @@ export async function processScreenshotEvent(event: NativeScreenshotEvent): Prom
}
}
/** 实时账单事件处理函数:进行分类/账户推荐匹配并直接询问用户是否入账 */
// ---- 实时账单处理(前台弹窗/后台浮窗) ----
export async function handleIncomingBillEvent(source: string, event: ImportedEvent, rawText: string) {
const ledger = useLedgerStore.getState().ledger;
if (!ledger) {
@ -182,13 +192,12 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
return;
}
// 交易级全局去重:避免对于已处理过(确认/忽略/进入过流程)的相同交易重复拉起悬浮卡片
const txKey = `${event.occurredAt.substring(0, 10)}_${event.amount}_${event.counterparty}`;
if (processedTxKeys.has(txKey)) {
if (hasProcessedTxKey(txKey)) {
logger.info('automationPipeline', `[实时入账去重] 过滤相同交易: ${txKey} (当前运行已处理过,拦截弹窗)`);
return;
}
processedTxKeys.add(txKey);
addProcessedTxKey(txKey);
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
@ -217,8 +226,7 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
const sourceAccount = draft.draft.postings[0]?.account ?? 'Assets:Unknown';
const targetAccount = draft.draft.postings[1]?.account ?? 'Expenses:未分类';
// 详细的日志控制台输出,包含原始 OCR 结果、OCR 清洗后结果、账单识别结果及参数填充和分类推荐
logger.info('automationPipeline',
logger.info('automationPipeline',
`\n================ [新账单实时解析成功] ================\n` +
`【1. 原始 OCR/消息文本】:\n` +
` 来源: ${source}\n` +
@ -237,7 +245,7 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
`=====================================================`
);
// 若 App 在后台运行,显示浮窗提示,不打扰当前应用前台进程
// 后台模式:浮窗
if (AppState.currentState !== 'active') {
logger.info('automationPipeline', `[实时入账] 检测到 App 处于后台,正在触发系统悬浮浮窗进行记账确认`);
const bridge = getAccessibilityBridge();
@ -245,72 +253,23 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
try {
const shortTargetAccount = targetAccount.split(':').pop() || targetAccount;
const timeStr = draft.draft.date + ' ' + (draft.draft.narration || '');
const { sortedCategories, sortedAccounts } = buildFloatingBillData(targetAccount, sourceAccount, ledger);
// 1. 获取最近交易的历史记录,构建分类与账户的频次字典以支持智能排序 (MRU)
const transactions = useLedgerStore.getState().ledger?.transactions || [];
const categoryFreq: Record<string, number> = {};
const accountFreq: Record<string, number> = {};
const recentTx = transactions.slice(-50);
for (const tx of recentTx) {
for (const posting of tx.postings) {
const acct = posting.account;
if (acct.startsWith('Expenses:') || acct.startsWith('Income:')) {
categoryFreq[acct] = (categoryFreq[acct] || 0) + 1;
} else if (acct.startsWith('Assets:') || acct.startsWith('Liabilities:')) {
accountFreq[acct] = (accountFreq[acct] || 0) + 1;
}
}
}
// 2. 分类按“推荐匹配 + 历史使用频次”混合打分排序
const categories = useMetadataStore.getState().categories;
const sortedCategories = [...categories].map(cat => {
let score = categoryFreq[cat.linkedAccount] || 0;
if (cat.linkedAccount === targetAccount) {
score += 1000; // 推荐分类给予最高优先级
}
return {
id: cat.id,
name: cat.name,
account: cat.linkedAccount,
type: cat.type,
score
};
}).sort((a, b) => b.score - a.score)
.map(({ id, name, account, type }) => ({ id, name, account, type }));
// 3. 资产账户按“推荐匹配 + 历史使用频次”混合打分排序
const accountsMap = ledger.accounts;
let assetAccounts = Array.from(accountsMap.keys()).filter(a => a.startsWith('Assets') || a.startsWith('Liabilities'));
if (assetAccounts.length === 0) {
assetAccounts = ['Assets:支付宝余额', 'Assets:微信零钱', 'Assets:现金'];
}
const sortedAccounts = assetAccounts.map(acct => {
let score = accountFreq[acct] || 0;
if (acct === sourceAccount) {
score += 1000; // 推荐账户给予最高优先级
}
return { acct, score };
}).sort((a, b) => b.score - a.score)
.map(({ acct }) => acct);
// 生成唯一 id 并缓存该待处理的交易草稿
const draftId = Math.random().toString(36).substring(2, 9) + Date.now().toString(36);
evictOldestPendingDraft();
pendingDrafts.set(draftId, { draft: draft.draft, event });
await bridge.showFloatingBill(
amount,
draft.draft.payee || shortTargetAccount,
timeStr,
amount,
draft.draft.payee || shortTargetAccount,
timeStr,
event.raw?.packageName || '',
sortedCategories,
sortedAccounts,
event.direction || 'expense',
draftId
draftId,
);
// 2分钟后自动清除缓存防止内存泄漏
setTimeout(() => {
if (pendingDrafts.has(draftId)) {
pendingDrafts.delete(draftId);
@ -323,7 +282,6 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
}
}
// 浮窗不可用时的兜底:将 App 拉起至前台
logger.info('automationPipeline', `[实时入账] 降级拉起 App 前台...`);
const bridgeForFallback = getAccessibilityBridge();
if (bridgeForFallback) {
@ -336,7 +294,7 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
}
}
// 前台模式:弹窗提示
// 前台模式:弹窗
Alert.alert(
'🌟 识别到新账单',
`【账单解析】\n` +
@ -361,9 +319,7 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
logger.info('automationPipeline', `[实时入账] 用户选择修改交易,跳转编辑页`);
router.push({
pathname: '/transaction/new',
params: {
draftJson: JSON.stringify(draft.draft)
}
params: { draftJson: JSON.stringify(draft.draft) }
});
}
},
@ -386,167 +342,37 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
}
}
/** 重置 OcrProcessor 去重状态(切换页面/应用重启时)。 */
export function resetAutomationPipeline(): void {
ocrProcessor?.reset();
ocrProcessor = null;
processedTxKeys.clear();
}
// ---- 无障碍文本解析(委托给 accessibilityParser ----
const processedTxKeys = new Set<string>();
/**
* OCR
*
*
* @param texts
* @param packageName
* @returns
*/
export async function parseAndProcessAccessibilityTexts(texts: string[], packageName: string): Promise<boolean> {
let event: ImportedEvent | null = null;
if (packageName === 'com.tencent.mm') {
// 微信账单详情匹配
// 特征:包含 "交易单号" 或 "退款单号" 或 "本服务由财付通提供"
if (!texts.includes('交易单号') && !texts.includes('退款单号') && !texts.includes('本服务由财付通提供')) {
return false;
}
let amount = 0;
let merchant = '';
let time = '';
let direction: 'expense' | 'income' = 'expense';
// 1. 匹配金额(比如 "+15.50"、"15.5"、"15.50"
const amountPattern = /^([+-])?(\d+(\.\d{1,2})?)$/;
for (const text of texts) {
const match = text.match(amountPattern);
if (match) {
amount = parseFloat(match[2]);
if (match[1] === '+') direction = 'income';
break;
}
}
// 2. 匹配商户(微信详情金额通常在其右上方/左下方,这里取金额前面一项作为商户)
const amtIndex = texts.findIndex(t => amountPattern.test(t));
if (amtIndex > 0) {
merchant = texts[amtIndex - 1];
} else {
merchant = texts[0] || '';
}
// 清洗退款等标识
merchant = merchant.split(',')[0].replace(/-退款$/, '');
// 3. 匹配时间
const timeLabelIndex = texts.findIndex(t => t.includes('时间'));
if (timeLabelIndex !== -1 && timeLabelIndex + 1 < texts.length) {
time = texts[timeLabelIndex + 1];
} else {
time = new Date().toISOString().replace('T', ' ').substring(0, 19);
}
if (amount > 0 && merchant) {
event = {
id: 'nodes_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9),
occurredAt: time,
amount: amount.toFixed(2),
currency: 'CNY',
direction,
counterparty: merchant,
memo: texts.includes('已退款') ? '退款' : '微信支付',
raw: { packageName }
};
}
} else if (packageName === 'com.eg.android.AlipayGphone') {
// 支付宝账单详情匹配
// 特征:包含 "创建时间" 或 "账单详情" 或 "对此订单有疑问"
if (!texts.includes('创建时间') && !texts.includes('账单详情') && !texts.includes('对此订单有疑问')) {
return false;
}
let amount = 0;
let merchant = '';
let time = '';
let direction: 'expense' | 'income' = 'expense';
let memo = '支付宝账单';
// 1. 匹配金额(如 "0.03元"、"75.4元"、"15.50元"
const amtWithYuanPattern = /^([+-])?(\d+(\.\d+)?)元$/;
for (const text of texts) {
const match = text.match(amtWithYuanPattern);
if (match) {
amount = parseFloat(match[2]);
if (match[1] === '+') {
direction = 'income';
} else if (texts.some(t => t.includes('收款') || t.includes('收入') || t.includes('收益') || t.includes('收到') || t.includes('红包'))) {
direction = 'income';
}
break;
}
}
// 2. 匹配商户(通常是列表第一项)
const amtIndex = texts.findIndex(t => amtWithYuanPattern.test(t));
if (amtIndex > 0) {
merchant = texts[amtIndex - 1];
} else {
merchant = texts[0] || '';
}
// 3. 匹配时间
const timeLabelIndex = texts.indexOf('创建时间');
if (timeLabelIndex !== -1 && timeLabelIndex + 1 < texts.length) {
time = texts[timeLabelIndex + 1];
} else {
time = new Date().toISOString().replace('T', ' ').substring(0, 19);
}
// 4. 提取商品说明/备注
const memoLabelIndex = texts.indexOf('商品说明');
if (memoLabelIndex !== -1 && memoLabelIndex + 1 < texts.length) {
memo = texts[memoLabelIndex + 1];
}
// 提取账单分类
const catLabelIndex = texts.indexOf('账单分类');
let category = 'Expenses:Other';
if (catLabelIndex !== -1 && catLabelIndex + 1 < texts.length) {
const rawCat = texts[catLabelIndex + 1];
if (rawCat.includes('理财') || rawCat.includes('收益') || memo.includes('收益发放') || rawCat.includes('红包')) {
category = 'Income:Investment';
direction = 'income';
}
}
if (amount > 0 && merchant) {
event = {
id: 'nodes_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9),
occurredAt: time,
amount: amount.toFixed(2),
currency: 'CNY',
direction,
counterparty: merchant,
memo,
raw: { packageName }
};
}
}
const event = parseAccessibilityTexts(texts, packageName);
if (event) {
// 交易级去重:本次运行期间如果已处理过相同日期、金额和商户的交易,则直接忽略(避免在详情页重复拉起卡片)
const txKey = `${event.occurredAt.substring(0, 10)}_${event.amount}_${event.counterparty}`;
if (processedTxKeys.has(txKey)) {
if (hasProcessedTxKey(txKey)) {
logger.info('automationPipeline', `[直接文本解析去重] 过滤相同交易: ${txKey} (已确认/忽略过,跳过浮窗)`);
return true; // 返回 true 以阻止降级触发 OCR
return true;
}
logger.info('automationPipeline', `[直接文本解析成功] 发现有效交易: ${event.counterparty} | ${event.amount} | ${event.occurredAt}`);
await handleIncomingBillEvent('accessibility_nodes', event, JSON.stringify(texts));
return true;
}
return false;
}
// ---- 重置 ----
export function resetAutomationPipeline(): void {
ocrProcessor?.reset();
ocrProcessor = null;
clearProcessedTxKeys();
if (billingListener) {
billingListener.remove();
billingListener = null;
}
}
// 模块加载时注册全局监听器(幂等,热重载时先清理旧的)
ensureBillingListener();

View File

@ -14,7 +14,8 @@ export interface BackupBundle {
version: 1 | 2;
createdAt: string;
files: { path: string; content: string }[];
mobileBean: string;
/** main.bean 的内容v1 格式字段名为 mobileBean此处做兼容。 */
mainBean: string;
/** v2 新增:用户设置(语言/主题等,不含敏感字段)。 */
settings?: Record<string, unknown>;
/** v2 新增:元数据(分类/标签/预算/信用卡/规则)。 */
@ -33,7 +34,7 @@ export interface BackupBackend {
/** 创建备份 bundle含设置和元数据。 */
export function createBackupBundle(
files: LedgerFile[],
mobileBean: string,
mainBean: string,
settings?: Record<string, unknown>,
metadata?: Record<string, unknown>,
): BackupBundle {
@ -41,7 +42,7 @@ export function createBackupBundle(
version: 2,
createdAt: new Date().toISOString(),
files: files.filter(f => f.path !== 'main.bean'),
mobileBean,
mainBean,
settings,
metadata,
};
@ -52,13 +53,16 @@ export function serializeBundle(bundle: BackupBundle): string {
return JSON.stringify(bundle, null, 2);
}
/** 反序列化 + 校验(兼容 v1 和 v2)。 */
/** 反序列化 + 校验(兼容 v1 和 v2,以及旧版 mobileBean 字段名)。 */
export function deserializeBundle(json: string): BackupBundle {
const data = JSON.parse(json) as BackupBundle;
const data = JSON.parse(json) as Record<string, unknown>;
if (data.version !== 1 && data.version !== 2) throw new Error(`不支持的备份版本: ${data.version}`);
if (!Array.isArray(data.files)) throw new Error('备份文件格式错误files 非数组');
if (typeof data.mobileBean !== 'string') throw new Error('备份文件格式错误mobileBean 非字符串');
return data;
// 兼容旧版 mobileBean 字段名
const mainBean = (typeof data.mainBean === 'string' ? data.mainBean : undefined)
?? (typeof data.mobileBean === 'string' ? data.mobileBean as string : undefined);
if (typeof mainBean !== 'string') throw new Error('备份文件格式错误mainBean 非字符串');
return { ...data, mainBean } as BackupBundle;
}
/** 计算备份的校验和(用于完整性校验)。 */
@ -70,6 +74,6 @@ export function bundleChecksum(bundle: BackupBundle): string {
export function restoreFiles(bundle: BackupBundle): LedgerFile[] {
return [
...bundle.files,
{ path: 'main.bean', content: bundle.mobileBean },
{ path: 'main.bean', content: bundle.mainBean },
];
}

View File

@ -1,4 +1,4 @@
/**
/**
* plan.md9.5 +9.6 UI
*
* AutoAccounting ErrorActivity + Bugly
@ -19,7 +19,7 @@ export interface SentryBridge {
captureMessage(message: string, level?: 'info' | 'warning' | 'error'): void;
}
/** 内存崩溃日志(测试/demo 用,重启丢失)。 */
/** 崩溃记录。 */
export interface CrashRecord {
timestamp: string;
message: string;
@ -34,6 +34,12 @@ export class CrashReporter {
private maxRecords = 50;
private sentry: SentryBridge | null = null;
private installed = false;
private fs: CrashFs | null = null;
/** 注入文件系统后端(生产环境用 expo-file-system测试用内存实现。 */
setFs(fs: CrashFs): void {
this.fs = fs;
}
/** 安装全局未捕获异常捕获。 */
install(): void {
@ -77,6 +83,9 @@ export class CrashReporter {
}
logger.error('crash', record.message, { stack: record.stack, source: record.source });
// 持久化到文件(异步,不阻塞)
this.persistToFs();
// 转发到 Sentry若已注入
if (this.sentry) {
const error = record.stack ? new Error(record.message) : record.message;
@ -84,6 +93,32 @@ export class CrashReporter {
}
}
/** 持久化崩溃记录到文件。 */
private persistToFs(): void {
if (!this.fs) return;
try {
const content = JSON.stringify(this.records);
this.fs.writeFile(content).catch(e => {
logger.warn('crash', `持久化崩溃日志失败: ${e}`);
});
} catch {
// 静默失败
}
}
/** 从文件加载崩溃记录App 启动时调用)。 */
async loadFromFs(): Promise<void> {
if (!this.fs) return;
try {
const content = await this.fs.readFile();
if (content) {
this.records = JSON.parse(content);
}
} catch {
// 文件不存在或解析失败,保持空记录
}
}
/** 手动捕获异常(已处理的错误)。 */
captureException(error: unknown, context?: Record<string, unknown>): void {
this.recordCrash({
@ -116,6 +151,7 @@ export class CrashReporter {
/** 清空记录。 */
clear(): void {
this.records = [];
this.persistToFs();
}
/** 导出崩溃日志为文本(用户可分享给开发者)。 */
@ -126,6 +162,12 @@ export class CrashReporter {
}
}
/** 文件系统抽象(使 CrashReporter 可单测)。 */
export interface CrashFs {
readFile(): Promise<string>;
writeFile(content: string): Promise<void>;
}
/** 全局崩溃上报单例。 */
export const crashReporter = new CrashReporter();

View File

@ -54,6 +54,13 @@ function parseDraftParams(params: string[]): Partial<TransactionDraft> {
if (sp.get('narration')) draft.narration = sp.get('narration')!;
const date = sp.get('date');
if (date && /^\d{4}-\d{2}-\d{2}$/.test(date)) draft.date = date;
const amount = sp.get('amount');
if (amount && /^\d+(\.\d+)?$/.test(amount)) {
draft.postings = [
{ account: 'Assets:Unknown', amount: `-${amount}`, currency: 'CNY' },
{ account: 'Expenses:未分类', amount, currency: 'CNY' },
];
}
return draft;
}

View File

@ -83,7 +83,7 @@ export async function exportToExcelMultiSheet(
for (const t of transactions) {
for (const p of t.postings) {
if (!p.amount) continue;
const account = p.account.split(':')[0]; // 顶层类型
const account = p.account; // 完整账户名
const cur = balances.get(account) ?? { income: 0, expense: 0 };
const amt = parseFloat(p.amount);
if (amt > 0) cur.income += amt;

View File

@ -1,12 +1,10 @@
/**
* expo-file-system MobileBeanBackend
*
* MemoryBackend mobile.bean
* - read() documentDirectory/mobile.bean
* MemoryBackend main.bean
* - read() documentDirectory/main.bean
* - writeAtomic() tmp tmp
* - recoverIfNeeded() tmp
*
* plan.md0.5 mobile.bean
* - recoverIfNeeded() of tmp
*/
import * as FileSystem from 'expo-file-system/legacy';
@ -17,7 +15,7 @@ const FILE_PATH = (FileSystem.documentDirectory ?? '') + 'main.bean';
const TMP_PATH = (FileSystem.documentDirectory ?? '') + 'main.bean.tmp';
/**
* mobile.bean
* main.bean
*
* .tmp rename
* expo-file-system moveAsync

View File

@ -0,0 +1,71 @@
/**
* 稿App
*
* automationPipeline.ts
* - 稿 MapLRU + 2
* -
*/
import { useMetadataStore } from '../store/metadataStore';
import type { ImportedEvent, LedgerIndex, TransactionDraft } from '../domain/types';
export interface PendingDraft {
draft: TransactionDraft;
event: ImportedEvent;
}
export const pendingDrafts = new Map<string, PendingDraft>();
/** pendingDrafts 最大容量,超出时清理最旧的条目。 */
const PENDING_DRAFTS_MAX = 50;
export function evictOldestPendingDraft(): void {
if (pendingDrafts.size <= PENDING_DRAFTS_MAX) return;
const oldestKey = pendingDrafts.keys().next().value;
if (oldestKey) pendingDrafts.delete(oldestKey);
}
/**
*
*/
export function buildFloatingBillData(
targetAccount: string,
sourceAccount: string,
ledger: LedgerIndex,
) {
const transactions = ledger.transactions;
const categoryFreq: Record<string, number> = {};
const accountFreq: Record<string, number> = {};
for (const tx of transactions.slice(-50)) {
for (const posting of tx.postings) {
const acct = posting.account;
if (acct.startsWith('Expenses:') || acct.startsWith('Income:')) {
categoryFreq[acct] = (categoryFreq[acct] || 0) + 1;
} else if (acct.startsWith('Assets:') || acct.startsWith('Liabilities:')) {
accountFreq[acct] = (accountFreq[acct] || 0) + 1;
}
}
}
const categories = useMetadataStore.getState().categories;
const sortedCategories = [...categories].map(cat => {
let score = categoryFreq[cat.linkedAccount] || 0;
if (cat.linkedAccount === targetAccount) score += 1000;
return { id: cat.id, name: cat.name, account: cat.linkedAccount, type: cat.type, score };
}).sort((a, b) => b.score - a.score)
.map(({ id, name, account, type }) => ({ id, name, account, type }));
let assetAccounts = Array.from(ledger.accounts.keys()).filter((a: string) => a.startsWith('Assets') || a.startsWith('Liabilities'));
if (assetAccounts.length === 0) {
assetAccounts = ['Assets:支付宝余额', 'Assets:微信零钱', 'Assets:现金'];
}
const sortedAccounts = assetAccounts.map((acct: string) => {
let score = accountFreq[acct] || 0;
if (acct === sourceAccount) score += 1000;
return { acct, score };
}).sort((a, b) => b.score - a.score)
.map(({ acct }) => acct);
return { sortedCategories, sortedAccounts };
}

View File

@ -206,7 +206,7 @@ export class ExpoMaintenanceFs implements MaintenanceFs {
* VACUUM/integrityCheck
*/
export class ExpoMaintenanceDb implements MaintenanceDb {
constructor(private readonly mobileBeanContent: string = '') {}
constructor(private readonly mainBeanContent: string = '') {}
async vacuum(): Promise<void> {
// .bean 是文本文件,无需 VACUUM。保留接口兼容。
@ -214,9 +214,10 @@ export class ExpoMaintenanceDb implements MaintenanceDb {
async integrityCheck(): Promise<string[]> {
const issues: string[] = [];
if (!this.mobileBeanContent.trim()) {
issues.push('mobile.bean 为空');
if (!this.mainBeanContent.trim()) {
issues.push('main.bean 为空');
}
return issues;
}
}

View File

@ -17,13 +17,20 @@ export interface AuthProvider {
/**
* expo-local-authentication import
* require
*/
let _localAuth: any = null;
let _localAuthTried = false;
function getLocalAuth(): any | null {
try {
return require('expo-local-authentication');
} catch {
return null;
if (!_localAuthTried) {
_localAuthTried = true;
try {
_localAuth = require('expo-local-authentication');
} catch {
_localAuth = null;
}
}
return _localAuth;
}
/**
@ -73,23 +80,51 @@ export async function authenticate(auth: AuthProvider): Promise<AuthResult> {
: { success: false, reason: 'cancelled' };
}
/** PIN 哈希(存储用,非加密级但足够防止明文)。 */
export async function hashPin(pin: string): Promise<string> {
// 简单哈希(生产可用 SHA-256 via expo-crypto这里用 SubtleCrypto 若可用,否则降级)
if (typeof crypto !== 'undefined' && crypto.subtle) {
const data = new TextEncoder().encode(pin);
const buf = await crypto.subtle.digest('SHA-256', data);
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
/** 生成随机 salt16 字节 hex)。 */
function generateSalt(): string {
const bytes = new Uint8Array(16);
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
}
// 降级:简单 FNV
let h = 2166136261;
for (const c of pin) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
return (h >>> 0).toString(16);
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
/** 验证 PIN。 */
export async function verifyPin(pin: string, storedHash: string): Promise<boolean> {
return (await hashPin(pin)) === storedHash;
/** PIN 哈希结果(含 salt。 */
export interface PinHashResult {
hash: string;
salt: string;
}
/**
* PIN SHA-256
* @param existingSalt salt
*/
export async function hashPin(pin: string, existingSalt?: string): Promise<PinHashResult> {
const salt = existingSalt ?? generateSalt();
const salted = salt + pin;
if (typeof crypto !== 'undefined' && crypto.subtle) {
const data = new TextEncoder().encode(salted);
const buf = await crypto.subtle.digest('SHA-256', data);
const hash = [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
return { hash, salt };
}
// 降级salt 增强 FNV-1a生产环境应始终走 SHA-256 路径)
let h = 2166136261;
for (const c of salted) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
return { hash: (h >>> 0).toString(16), salt };
}
/** 验证 PIN支持旧版无 salt 哈希的迁移)。 */
export async function verifyPin(pin: string, storedHash: string, salt?: string): Promise<boolean> {
if (salt) {
const { hash } = await hashPin(pin, salt);
return hash === storedHash;
}
// 旧版无 salt 哈希兼容:先尝试无 salt 验证(传入空字符串盐,保持 legacyHash 计算时不生成新盐)
const { hash: legacyHash } = await hashPin(pin, '');
return legacyHash === storedHash;
}
/** 是否应在恢复时锁屏(基于超时配置)。 */

View File

@ -10,6 +10,7 @@
*/
import { generateAnnualReport, type AnnualReport } from '../domain/annualReport';
import { addDecimals, negateDecimal, compareDecimals } from '../domain/decimal';
import type { Transaction } from '../domain/types';
export type PosterType = 'year' | 'month' | 'ledger';
@ -76,11 +77,12 @@ export function calculatePosterStats(config: PosterConfig): PosterStats {
for (const t of tx) {
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
if (p.account.startsWith('Income') && amt < 0) income = addStr(income, p.amount.slice(1));
if (p.account.startsWith('Expenses') && amt > 0) {
expense = addStr(expense, p.amount);
byCategory.set(p.account, addStr(byCategory.get(p.account) ?? '0', p.amount));
if (p.account.startsWith('Income')) {
income = addDecimals([income, negateDecimal(p.amount)]);
}
if (p.account.startsWith('Expenses')) {
expense = addDecimals([expense, p.amount]);
byCategory.set(p.account, addDecimals([byCategory.get(p.account) ?? '0', p.amount]));
}
}
}
@ -95,10 +97,6 @@ export function calculatePosterStats(config: PosterConfig): PosterStats {
};
}
function addStr(a: string, b: string): string {
return (parseFloat(a) + parseFloat(b)).toFixed(2);
}
/**
*
*/

View File

@ -1,4 +1,4 @@
/**
/**
* plan.md4.2 +4.3
*
* AutoAccounting SmsReceiver
@ -34,7 +34,8 @@ export function parseSms(event: SmsEvent): ImportedEvent | null {
?? body.match(/(\d+(?:\.\d+)?)\s*元/);
if (!amountMatch) return null;
const amount = parseFloat(amountMatch[1]);
// 使用字符串保持精度,避免 parseFloat 精度丢失
const amountStr = amountMatch[1];
// 卡号提取
const cardMatch = body.match(/尾号\s*(\d{4})/);
@ -50,7 +51,7 @@ export function parseSms(event: SmsEvent): ImportedEvent | null {
return {
id: `sms:${hash(event.sender + body)}`,
occurredAt,
amount: direction === 'expense' ? `-${amount}` : String(amount),
amount: direction === 'expense' ? `-${amountStr}` : amountStr,
currency: 'CNY',
direction,
counterparty: extractCounterparty(body),
@ -74,8 +75,9 @@ function normalizeSmsTime(time: string): string {
const parsedMonth = parseInt(month, 10);
const currentMonth = new Date().getMonth() + 1; // 1-indexed
// 跨年边界判定如果解析出的短信月份是12月但当前系统已经是1月说明是去年年底的交易年份减一
if (parsedMonth === 12 && currentMonth === 1) {
// 跨年边界判定:如果短信月份比当前月份大 3 以上,说明是去年的短信
// 例如:当前 1 月,短信 11 月 → 去年 11 月
if (parsedMonth > currentMonth + 3) {
year--;
}
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')} ${hour.padStart(2, '0')}:${minute}`;
@ -93,6 +95,9 @@ export class SmsChannel {
private md5Hashes = new Set<string>();
private handler: ((event: ImportedEvent, raw: SmsEvent) => void) | null = null;
/** md5Hashes 最大容量,超出时淘汰最旧条目。 */
private static MAX_HASHES = 1000;
constructor(
private readonly nativeListener: SmsListener | null = null,
private keywordFilter: KeywordFilter | null = null,
@ -122,12 +127,17 @@ export class SmsChannel {
return { processed: false, reason: '关键词过滤' };
}
// 3. MD5 去重
// 3. MD5 去重(带容量上限)
const md5 = hash(event.sender + event.body);
if (this.md5Hashes.has(md5)) {
return { processed: false, reason: 'MD5 去重' };
}
this.md5Hashes.add(md5);
// LRU 淘汰:超出容量时删除最早插入的条目
if (this.md5Hashes.size > SmsChannel.MAX_HASHES) {
const oldest = this.md5Hashes.values().next().value;
if (oldest) this.md5Hashes.delete(oldest);
}
// 4. 解析
const bill = parseSms(event);

View File

@ -4,9 +4,9 @@
* GitBackend //
* - MockGitBackend
* - ExpoGitBackend HTTPfetch + Basic Auth
* mobile.bean "原始内容 PUT + GET"
* main.bean "原始内容 PUT + GET"
*
* mobile.bean
* main.bean
*/
import { logger } from '../../utils/logger';
@ -83,7 +83,7 @@ export async function syncWithGit(
}
/**
* mobile.bean union
* main.bean union
* Git 3-way merge
*/
export function mergeMobileBean(
@ -92,18 +92,15 @@ export function mergeMobileBean(
): { content: string; conflicts: string[] } {
if (local === remote) return { content: local, conflicts: [] };
const localLines = new Set(local.split('\n').filter(l => l.trim()));
const remoteLines = remote.split('\n').filter(l => l.trim());
const localLines = local.split('\n');
const remoteLines = remote.split('\n');
const remoteSet = new Set(remoteLines);
const conflicts: string[] = [];
// 简单策略:以 remote 为基础,追加 local 中独有的行
// 以 remote 为基础,追加 local 中独有的非空
const merged = [...remoteLines];
for (const line of remoteLines) {
if (localLines.has(line)) localLines.delete(line); // 已存在
}
// local 独有的行追加到末尾
for (const line of local.split('\n')) {
if (line.trim() && !remoteLines.includes(line)) {
for (const line of localLines) {
if (line.trim() && !remoteSet.has(line)) {
merged.push(line);
}
}
@ -147,13 +144,13 @@ export class MockGitBackend implements GitBackend {
/**
* Git HTTP
*
* mobile.bean git HTTP GET
* main.bean git HTTP GET
* HTTP PUT GitHub Raw / Gitea / HTTP
*
* HTTP Basic Auth username + token/password
* URL raw URL
* https://raw.githubusercontent.com/user/repo/main/mobile.bean
* https://my-server.com/ledger/mobile.bean
* https://raw.githubusercontent.com/user/repo/main/main.bean
* https://my-server.com/ledger/main.bean
*/
export class ExpoGitBackend implements GitBackend {
private remoteContent = '';
@ -195,7 +192,7 @@ export class ExpoGitBackend implements GitBackend {
}
async readFile(path: string): Promise<string> {
// pull 后的远程内容
if (path !== 'main.bean') throw new Error(`Unsupported file: ${path}`);
return this.remoteContent;
}
@ -213,3 +210,4 @@ export class ExpoGitBackend implements GitBackend {
return { Authorization: `Basic ${token}` };
}
}

View File

@ -2,7 +2,7 @@
* WebDAV plan.md6.3 WebDAV
*
* BeeCount + AutoAccounting WebDAVManager
* - / mobile.bean
* - / main.bean
* - lastModified
* - N
*
@ -17,7 +17,7 @@ export interface WebDAVConfig {
url: string; // 如 https://dav.example.com/beancount/
username: string;
password: string;
remotePath: string; // 如 /mobile.bean
remotePath: string; // 如 /main.bean
backupKeepCount: number; // 备份保留数,默认 10
}
@ -57,10 +57,16 @@ export class WebDAVClient {
this.authHeader = 'Basic ' + encodeBase64(`${config.username}:${config.password}`);
}
private buildUrl(path: string): string {
const url = this.config.url.endsWith('/') ? this.config.url.slice(0, -1) : this.config.url;
const p = path.startsWith('/') ? path : '/' + path;
return url + p;
}
/** 获取远程文件信息Last-Modified / 大小)。 */
async stat(): Promise<{ lastModified: string | null; exists: boolean }> {
try {
const resp = await this.fetchImpl(this.config.url + this.config.remotePath, {
const resp = await this.fetchImpl(this.buildUrl(this.config.remotePath), {
method: 'PROPFIND',
headers: {
'Authorization': this.authHeader,
@ -82,7 +88,7 @@ export class WebDAVClient {
/** 下载文件内容。 */
async get(): Promise<string | null> {
try {
const resp = await this.fetchImpl(this.config.url + this.config.remotePath, {
const resp = await this.fetchImpl(this.buildUrl(this.config.remotePath), {
method: 'GET',
headers: { 'Authorization': this.authHeader },
});
@ -95,7 +101,7 @@ export class WebDAVClient {
/** 上传文件内容。 */
async put(content: string): Promise<void> {
const resp = await this.fetchImpl(this.config.url + this.config.remotePath, {
const resp = await this.fetchImpl(this.buildUrl(this.config.remotePath), {
method: 'PUT',
headers: {
'Authorization': this.authHeader,
@ -109,7 +115,7 @@ export class WebDAVClient {
/** 列出备份目录。 */
async listBackups(backupDir: string): Promise<string[]> {
try {
const resp = await this.fetchImpl(this.config.url + backupDir, {
const resp = await this.fetchImpl(this.buildUrl(backupDir), {
method: 'PROPFIND',
headers: {
'Authorization': this.authHeader,
@ -129,7 +135,7 @@ export class WebDAVClient {
/** 删除文件。 */
async delete(path: string): Promise<void> {
await this.fetchImpl(this.config.url + path, {
await this.fetchImpl(this.buildUrl(path), {
method: 'DELETE',
headers: { 'Authorization': this.authHeader },
});
@ -160,7 +166,13 @@ export async function syncWithWebDAV(
}
// 冲突检测:远程和本地都有变更
const remoteChanged = remoteInfo.lastModified && remoteInfo.lastModified > lastSyncTime;
// 将 HTTP 日期格式转为可比较的 ISO 字符串
const parseHttpDate = (s: string): number => {
const d = new Date(s);
return isNaN(d.getTime()) ? 0 : d.getTime();
};
const remoteChanged = remoteInfo.lastModified &&
parseHttpDate(remoteInfo.lastModified) > parseHttpDate(lastSyncTime);
if (remoteChanged) {
return {
action: 'conflict',

View File

@ -0,0 +1,72 @@
/**
* LRU +
*
* automationPipeline.ts
* - ++
* - LRU 500
* - App
*/
import * as FileSystem from 'expo-file-system/legacy';
import { logger } from '../utils/logger';
const processedTxKeys = new Set<string>();
/** 最大容量,超出时清理最旧的条目。 */
const PROCESSED_TX_KEYS_MAX = 500;
/** 持久化文件路径。 */
const TX_KEYS_FILE = (FileSystem.documentDirectory ?? '') + 'processed_tx_keys.json';
const TX_KEYS_TMP = TX_KEYS_FILE + '.tmp';
/** 加载已处理的交易键App 启动时调用)。 */
export async function loadProcessedTxKeys(): Promise<void> {
try {
const info = await FileSystem.getInfoAsync(TX_KEYS_FILE);
if (info.exists) {
const content = await FileSystem.readAsStringAsync(TX_KEYS_FILE);
const keys: string[] = JSON.parse(content);
for (const k of keys) processedTxKeys.add(k);
}
} catch (e) {
logger.warn('txKeyStore', `加载 processedTxKeys 失败: ${e}`);
}
}
/** 保存已处理的交易键到磁盘原子写入tmp + rename。 */
function saveProcessedTxKeys(): void {
const keys = Array.from(processedTxKeys);
const content = JSON.stringify(keys);
FileSystem.writeAsStringAsync(TX_KEYS_TMP, content)
.then(() => FileSystem.moveAsync({ from: TX_KEYS_TMP, to: TX_KEYS_FILE }))
.catch(e => {
logger.warn('txKeyStore', `保存 processedTxKeys 失败: ${e}`);
});
}
/** 添加已处理的交易键(带 LRU 淘汰 + 持久化)。 */
export function addProcessedTxKey(key: string): void {
if (processedTxKeys.size >= PROCESSED_TX_KEYS_MAX) {
const oldest = processedTxKeys.keys().next().value;
if (oldest) processedTxKeys.delete(oldest);
}
processedTxKeys.add(key);
saveProcessedTxKeys();
}
/** 检查交易键是否已处理。 */
export function hasProcessedTxKey(key: string): boolean {
return processedTxKeys.has(key);
}
/** 删除交易键(用户忽略交易时调用)。 */
export function removeProcessedTxKey(key: string): void {
processedTxKeys.delete(key);
saveProcessedTxKeys();
}
/** 清空所有已处理的交易键。 */
export function clearProcessedTxKeys(): void {
processedTxKeys.clear();
saveProcessedTxKeys();
}

View File

@ -30,6 +30,8 @@ export interface AutomationState {
drafts: ProcessedDraft[];
/** 是否正在处理。 */
isProcessing: boolean;
/** 处理出错时的错误信息。 */
error: string | null;
/** 各入口的统计。 */
stats: Record<AutomationSource, number>;
@ -60,6 +62,7 @@ export const useAutomationStore = create<AutomationState>((set, get) => ({
detected: [],
drafts: [],
isProcessing: false,
error: null,
stats: { ocr: 0, notification: 0, sms: 0, screenshot: 0, manual: 0 },
addDetected: (source, event) => {
@ -77,19 +80,22 @@ export const useAutomationStore = create<AutomationState>((set, get) => ({
processAll: async (ledger, rules, categories, history, dedupConfig, transferConfig) => {
const { detected } = get();
if (detected.length === 0) return;
set({ isProcessing: true });
const snapshotLength = detected.length;
set({ isProcessing: true, error: null });
try {
const events = detected.map(d => d.event);
const result = await pipeline.process(events, {
ledger, rules, categories, history, dedupConfig, transferConfig,
});
// 只清空已处理的事件,保留处理期间新增的
const { detected: currentDetected } = get();
set({
drafts: result.drafts,
detected: [],
detected: currentDetected.slice(snapshotLength),
isProcessing: false,
});
} catch {
set({ isProcessing: false });
} catch (e) {
set({ isProcessing: false, error: e instanceof Error ? e.message : String(e) });
}
},

View File

@ -16,7 +16,7 @@ import type { StatementAdapter } from '../domain/statements';
*
* - CSV ImportedEvent
* - BillPipeline
* - 稿 mobile.bean
* - 稿 main.bean
*
* pipeline
*/
@ -42,7 +42,7 @@ export interface ImportState {
importCsv: (content: string, adapter: StatementAdapter) => ImportedEvent[];
/** 走 BillPipeline 处理当前事件(转账识别→去重→分类→生成草稿)。 */
process: (ledger: LedgerIndex, history: Transaction[], dedupConfig?: DedupConfig, transferConfig?: TransferConfig) => Promise<PipelineResult>;
/** 确认单笔草稿(从 pendingDrafts 移除,由调用方写入 mobile.bean。 */
/** 确认单笔草稿(从 pendingDrafts 移除,由调用方写入 main.bean。 */
confirmDraft: (index: number) => ProcessedDraft | null;
/** 批量确认草稿(从 pendingDrafts 移除指定索引列表)。 */
confirmDrafts: (indices: number[]) => void;
@ -103,16 +103,17 @@ export const useImportStore = create<ImportState>((set, get) => ({
const { pendingDrafts } = get();
const draft = pendingDrafts[index];
if (!draft) return null;
const next = pendingDrafts.filter((_, i) => i !== index);
// 用 draft 引用而非索引过滤,避免竞态
const next = pendingDrafts.filter((d) => d !== draft);
set({ pendingDrafts: next });
return draft;
},
confirmDrafts: (indices) => {
const { pendingDrafts } = get();
const setOfIndices = new Set(indices);
const next = pendingDrafts.filter((_, i) => !setOfIndices.has(i));
set({ pendingDrafts: next });
// 使用引用过滤而非索引过滤,避免并发覆盖
const toRemove = new Set(indices.map(i => pendingDrafts[i]));
set({ pendingDrafts: pendingDrafts.filter(d => !toRemove.has(d)) });
},
rejectDraft: (index) => {

View File

@ -2,6 +2,7 @@ import { create } from 'zustand';
import { parseLedger, getDefaultCurrency, serializeTransaction } from '../domain/ledger';
import { createMobileStore, MemoryBackend, type MobileBeanBackend, type MobileBeanStore } from '../domain/mobileStore';
import { checkDuplicate, type DedupConfig } from '../domain/dedup';
import { toDateString } from '../domain/decimal';
import type { LedgerFile, LedgerIndex, TransactionDraft } from '../domain/types';
import type { MobileCommit } from '../domain/mobile';
import { logger } from '../utils/logger';
@ -11,7 +12,7 @@ import { logger } from '../utils/logger';
*
*
* - / .bean LedgerIndex
* - MobileBeanStore mobile.bean
* - MobileBeanStore main.bean
* - storage
*
* 使 store MemoryBackend
@ -20,25 +21,25 @@ import { logger } from '../utils/logger';
export interface LedgerState {
/** 当前账本索引(解析结果)。 */
ledger: LedgerIndex | null;
/** mobile.bean 写入存储(并发安全)。 */
/** main.bean 写入存储(并发安全)。 */
mobileStore: MobileBeanStore | null;
/** mobile.bean 当前内容(便于 UI 直接读取)。 */
/** main.bean 当前内容(便于 UI 直接读取)。 */
mobileBean: string;
isLoading: boolean;
error: string | null;
// actions
/** 加载账本文件并初始化 mobile.bean 存储。 */
/** 加载账本文件并初始化 main.bean 存储。 */
loadLedger: (files: LedgerFile[], backend?: MobileBeanBackend) => Promise<void>;
/** 追加一笔交易到 mobile.bean并发安全含去重检查。 */
/** 追加一笔交易到 main.bean并发安全含去重检查。 */
addTransaction: (draft: TransactionDraft, dedupConfig?: DedupConfig, force?: boolean) => Promise<MobileCommit>;
/** 刷新账本(重新解析,用于文件变更后)。 */
refresh: () => Promise<void>;
/** 直接替换 mobile.bean 内容(恢复/导入场景)。 */
/** 直接替换 main.bean 内容(恢复/导入场景)。 */
replaceMobileBean: (content: string) => Promise<void>;
/** 自动开户(在 mobile.bean 中插入 open 指令并重解析)。 */
/** 自动开户(在 main.bean 中插入 open 指令并重解析)。 */
autoOpenAccounts: (accounts: string[]) => Promise<void>;
/** 自动销户(在 mobile.bean 中插入 close 指令并重解析)。 */
/** 自动销户(在 main.bean 中插入 close 指令并重解析)。 */
autoCloseAccount: (accountName: string) => Promise<void>;
/** 调整账户余额(写入 pad & balance 对并重解析)。 */
adjustAccountBalance: (accountName: string, balance: string, date: string) => Promise<void>;
@ -46,7 +47,7 @@ export interface LedgerState {
appendTransactionsBatch: (drafts: TransactionDraft[]) => Promise<void>;
/** 编辑交易:用新 raw 替换旧 raw返回新交易 id。 */
editTransaction: (oldRaw: string, newRaw: string) => Promise<string>;
/** 删除交易:从 mobileBean 中移除指定 raw 块。 */
/** 删除交易:从 mainBean 中移除指定 raw 块。 */
deleteTransaction: (raw: string) => Promise<void>;
/** 重置到初始状态。 */
reset: () => void;
@ -55,8 +56,13 @@ export interface LedgerState {
/** 当前加载的账本文件(用于 refresh。 */
let loadedFiles: LedgerFile[] = [];
/** 重新解析 mainBean 内容为 LedgerIndex。 */
function reparseLedger(mainBean: string): LedgerIndex {
return parseLedger([{ path: 'main.bean', content: mainBean }]);
}
/**
* mobileBean read-modify-write
* mainBean read-modify-write
*
* autoOpenAccounts/adjustAccountBalance zustand get().mobileBean
* mobileStore.replace()mobileStore.enqueue
@ -133,12 +139,12 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
const commit = await mobileStore.append(draft, ledger);
const mobileBean = commit.content;
// 重新解析,让 ledger.transactions 包含新追加的交易
const newLedger = parseLedger([{ path: 'main.bean', content: mobileBean }]);
const newLedger = reparseLedger(mobileBean);
// 详细记录入账信息日志
logger.info('ledgerStore', `确认入账成功 [日期: ${draft.date}, 商户/交易对手: ${draft.payee ?? '无'}, 叙述: ${draft.narration}, 金额: ${draft.postings[0]?.amount ?? '0'} ${draft.postings[0]?.currency ?? 'CNY'}, 账户: ${draft.postings[0]?.account}]`);
if (get().mobileStore !== mobileStore) return commit;
set({ mobileBean, ledger: newLedger });
return commit;
});
@ -147,9 +153,11 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
refresh: async () => {
return withLock(async () => {
const { mobileStore } = get();
if (loadedFiles.length === 0) return;
const mobileBean = mobileStore?.getContent() ?? '';
const ledger = parseLedger([{ path: 'main.bean', content: mobileBean }]);
if (!mobileStore) return;
await mobileStore.reload();
const mobileBean = mobileStore.getContent();
const ledger = reparseLedger(mobileBean);
if (get().mobileStore !== mobileStore) return;
set({ ledger, mobileBean });
});
},
@ -159,26 +167,27 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
const { mobileStore } = get();
if (!mobileStore) throw new Error('账本未加载');
await mobileStore.replace(content);
const ledger = parseLedger([{ path: 'main.bean', content }]);
const ledger = reparseLedger(content);
if (get().mobileStore !== mobileStore) return;
set({ mobileBean: content, ledger });
});
},
autoOpenAccounts: async (accounts) => {
return withLock(async () => {
const { mobileStore, ledger } = get();
const { mobileStore } = get();
if (!mobileStore) throw new Error('账本未加载');
const ledger = get().ledger;
// 去重:跳过已 open 的账户
const newAccounts = accounts.filter(acc => !ledger?.accounts.has(acc));
if (newAccounts.length === 0) return;
const mobileBean = get().mobileBean; // 锁内重新读取最新值
const today = new Date().toISOString().slice(0, 10);
const currency = getDefaultCurrency(ledger);
const openStatements = newAccounts.map(acc => `${today} open ${acc} ${currency}`).join('\n');
const newContent = `${mobileBean.trimEnd()}\n\n${openStatements}\n`.trim();
await mobileStore.replace(newContent);
const newLedger = parseLedger([{ path: 'main.bean', content: newContent }]);
set({ mobileBean: newContent, ledger: newLedger });
await mobileStore.modifyContent(current => `${current.trimEnd()}\n\n${openStatements}\n`.trim());
const newLedger = reparseLedger(mobileStore.getContent());
if (get().mobileStore !== mobileStore) return;
set({ mobileBean: mobileStore.getContent(), ledger: newLedger });
});
},
@ -186,30 +195,28 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
return withLock(async () => {
const { mobileStore } = get();
if (!mobileStore) throw new Error('账本未加载');
const mobileBean = get().mobileBean;
const today = new Date().toISOString().slice(0, 10);
const newContent = `${mobileBean.trimEnd()}\n\n${today} close ${accountName}\n`.trim();
await mobileStore.replace(newContent);
const newLedger = parseLedger([{ path: 'main.bean', content: newContent }]);
set({ mobileBean: newContent, ledger: newLedger });
await mobileStore.modifyContent(current => `${current.trimEnd()}\n\n${today} close ${accountName}\n`.trim());
const newLedger = reparseLedger(mobileStore.getContent());
if (get().mobileStore !== mobileStore) return;
set({ mobileBean: mobileStore.getContent(), ledger: newLedger });
});
},
adjustAccountBalance: async (accountName, balance, date) => {
return withLock(async () => {
const { mobileStore, ledger } = get();
const { mobileStore } = get();
if (!mobileStore) throw new Error('账本未加载');
const mobileBean = get().mobileBean;
const ledger = get().ledger;
const currency = getDefaultCurrency(ledger);
// 计算前一天的日期用于 pad
const d = new Date(date);
d.setDate(d.getDate() - 1);
const padDate = d.toISOString().slice(0, 10);
// 计算前一天的日期用于 pad使用本地时间避免 UTC 偏移)
const [y, m, d] = date.split('-').map(Number);
const padDate = toDateString(new Date(y, m - 1, d - 1));
const statements = `${padDate} pad ${accountName} Equity:Opening-Balances\n${date} balance ${accountName} ${balance} ${currency}`;
const newContent = `${mobileBean.trimEnd()}\n\n${statements}\n`.trim();
await mobileStore.replace(newContent);
const newLedger = parseLedger([{ path: 'main.bean', content: newContent }]);
set({ mobileBean: newContent, ledger: newLedger });
await mobileStore.modifyContent(current => `${current.trimEnd()}\n\n${statements}\n`.trim());
const newLedger = reparseLedger(mobileStore.getContent());
if (get().mobileStore !== mobileStore) return;
set({ mobileBean: mobileStore.getContent(), ledger: newLedger });
});
},
@ -221,7 +228,8 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
const serializedChunk = drafts.map(d => serializeTransaction(d)).join('\n\n');
const newContent = `${mobileBean.trimEnd()}\n\n${serializedChunk}\n`.trim();
await mobileStore.replace(newContent);
const newLedger = parseLedger([{ path: 'main.bean', content: newContent }]);
const newLedger = reparseLedger(newContent);
if (get().mobileStore !== mobileStore) return;
set({ mobileBean: newContent, ledger: newLedger });
});
},
@ -232,9 +240,11 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
if (!mobileStore) throw new Error('账本未加载');
const mobileBean = get().mobileBean;
if (!mobileBean.includes(oldRaw)) throw new Error('交易未找到,可能已被删除或修改');
const newContent = mobileBean.replace(oldRaw, newRaw);
const idx = mobileBean.indexOf(oldRaw);
const newContent = mobileBean.slice(0, idx) + newRaw + mobileBean.slice(idx + oldRaw.length);
await mobileStore.replace(newContent);
const newLedger = parseLedger([{ path: 'main.bean', content: newContent }]);
const newLedger = reparseLedger(newContent);
if (get().mobileStore !== mobileStore) return '';
set({ mobileBean: newContent, ledger: newLedger });
const newTx = newLedger.transactions.find(t => t.raw === newRaw);
return newTx?.id ?? '';
@ -247,16 +257,19 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
if (!mobileStore) throw new Error('账本未加载');
const mobileBean = get().mobileBean;
if (!mobileBean.includes(raw)) throw new Error('交易未找到,可能已被删除或修改');
let newContent = mobileBean.replace(raw, '');
const idx = mobileBean.indexOf(raw);
let newContent = mobileBean.slice(0, idx) + mobileBean.slice(idx + raw.length);
newContent = newContent.replace(/\n{3,}/g, '\n\n').trim();
await mobileStore.replace(newContent);
const newLedger = parseLedger([{ path: 'main.bean', content: newContent }]);
const newLedger = reparseLedger(newContent);
if (get().mobileStore !== mobileStore) return;
set({ mobileBean: newContent, ledger: newLedger });
});
},
reset: () => {
loadedFiles = [];
writeLock = Promise.resolve();
set({ ledger: null, mobileStore: null, mobileBean: '', isLoading: false, error: null });
},
}));

View File

@ -140,113 +140,88 @@ export function generateId(prefix = 'id'): string {
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
export const useMetadataStore = create<MetadataState>((set, get) => ({
...DEFAULTS,
/**
* CRUD key add/update/remove action
* 7
*/
function createCrudActions<T extends { id: string }>(
key: keyof PersistableMetadata,
set: (fn: (s: MetadataState) => Partial<MetadataState>) => void,
get: () => MetadataState,
) {
return {
add: (item: T) => {
set((s) => ({ [key]: [...((s[key] as unknown as T[])), item] }) as Partial<MetadataState>);
get().persistTo?.(toPersistable(get()));
},
update: (id: string, patch: Partial<T>) => {
set((s) => ({
[key]: (s[key] as unknown as T[]).map(item => item.id === id ? { ...item, ...patch } : item),
}) as Partial<MetadataState>);
get().persistTo?.(toPersistable(get()));
},
remove: (id: string) => {
set((s) => ({
[key]: (s[key] as unknown as T[]).filter(item => item.id !== id),
}) as Partial<MetadataState>);
get().persistTo?.(toPersistable(get()));
},
};
}
// ---- Category ----
addCategory: (cat) => {
set((s) => ({ categories: [...s.categories, cat] }));
get().persistTo?.(toPersistable(get()));
},
updateCategory: (id, patch) => {
set((s) => ({ categories: s.categories.map(c => c.id === id ? { ...c, ...patch } : c) }));
get().persistTo?.(toPersistable(get()));
},
removeCategory: (id) => {
set((s) => ({ categories: s.categories.filter(c => c.id !== id) }));
get().persistTo?.(toPersistable(get()));
},
export const useMetadataStore = create<MetadataState>((set, get) => {
const cat = createCrudActions<Category>('categories', set, get);
const tag = createCrudActions<Tag>('tags', set, get);
const budget = createCrudActions<Budget>('budgets', set, get);
const card = createCrudActions<CreditCard>('creditCards', set, get);
const rule = createCrudActions<Rule>('rules', set, get);
const rec = createCrudActions<RecurringTransaction>('recurringTransactions', set, get);
const tpl = createCrudActions<{ id: string; name: string; template: string }>('remarkTemplates', set, get);
// ---- Tag ----
addTag: (tag) => {
set((s) => ({ tags: [...s.tags, tag] }));
get().persistTo?.(toPersistable(get()));
},
updateTag: (id, patch) => {
set((s) => ({ tags: s.tags.map(t => t.id === id ? { ...t, ...patch } : t) }));
get().persistTo?.(toPersistable(get()));
},
removeTag: (id) => {
set((s) => ({ tags: s.tags.filter(t => t.id !== id) }));
get().persistTo?.(toPersistable(get()));
},
return {
...DEFAULTS,
// ---- Budget ----
addBudget: (budget) => {
set((s) => ({ budgets: [...s.budgets, budget] }));
get().persistTo?.(toPersistable(get()));
},
updateBudget: (id, patch) => {
set((s) => ({ budgets: s.budgets.map(b => b.id === id ? { ...b, ...patch } : b) }));
get().persistTo?.(toPersistable(get()));
},
removeBudget: (id) => {
set((s) => ({ budgets: s.budgets.filter(b => b.id !== id) }));
get().persistTo?.(toPersistable(get()));
},
addCategory: cat.add,
updateCategory: cat.update,
removeCategory: cat.remove,
// ---- CreditCard ----
addCreditCard: (card) => {
set((s) => ({ creditCards: [...s.creditCards, card] }));
get().persistTo?.(toPersistable(get()));
},
updateCreditCard: (id, patch) => {
set((s) => ({ creditCards: s.creditCards.map(c => c.id === id ? { ...c, ...patch } : c) }));
get().persistTo?.(toPersistable(get()));
},
removeCreditCard: (id) => {
set((s) => ({ creditCards: s.creditCards.filter(c => c.id !== id) }));
get().persistTo?.(toPersistable(get()));
},
addTag: tag.add,
updateTag: tag.update,
removeTag: tag.remove,
// ---- Rule ----
addRule: (rule) => {
set((s) => ({ rules: [...s.rules, rule] }));
get().persistTo?.(toPersistable(get()));
},
updateRule: (id, patch) => {
set((s) => ({ rules: s.rules.map(r => r.id === id ? { ...r, ...patch } : r) }));
get().persistTo?.(toPersistable(get()));
},
removeRule: (id) => {
set((s) => ({ rules: s.rules.filter(r => r.id !== id) }));
get().persistTo?.(toPersistable(get()));
},
addBudget: budget.add,
updateBudget: budget.update,
removeBudget: budget.remove,
// ---- RecurringTransaction ----
addRecurringTransaction: (rec) => {
set((s) => ({ recurringTransactions: [...s.recurringTransactions, rec] }));
get().persistTo?.(toPersistable(get()));
},
updateRecurringTransaction: (id, patch) => {
set((s) => ({ recurringTransactions: s.recurringTransactions.map(r => r.id === id ? { ...r, ...patch } : r) }));
get().persistTo?.(toPersistable(get()));
},
removeRecurringTransaction: (id) => {
set((s) => ({ recurringTransactions: s.recurringTransactions.filter(r => r.id !== id) }));
get().persistTo?.(toPersistable(get()));
},
addCreditCard: card.add,
updateCreditCard: card.update,
removeCreditCard: card.remove,
// ---- RemarkTemplate ----
addRemarkTemplate: (tpl) => {
set((s) => ({ remarkTemplates: [...s.remarkTemplates, tpl] }));
get().persistTo?.(toPersistable(get()));
},
updateRemarkTemplate: (id, patch) => {
set((s) => ({ remarkTemplates: s.remarkTemplates.map(t => t.id === id ? { ...t, ...patch } : t) }));
get().persistTo?.(toPersistable(get()));
},
removeRemarkTemplate: (id) => {
set((s) => ({ remarkTemplates: s.remarkTemplates.filter(t => t.id !== id) }));
get().persistTo?.(toPersistable(get()));
},
addRule: rule.add,
updateRule: rule.update,
removeRule: rule.remove,
hydrate: (data) => {
addRecurringTransaction: rec.add,
updateRecurringTransaction: rec.update,
removeRecurringTransaction: rec.remove,
addRemarkTemplate: tpl.add,
updateRemarkTemplate: tpl.update,
removeRemarkTemplate: tpl.remove,
hydrate: (data) => {
// 通用按 id 合并:用户数据覆盖同 id 的默认值,新 id 追加到末尾
// deleted 标记的项不从 defaults 恢复
const isDeleted = (item: unknown): boolean =>
typeof item === 'object' && item !== null && 'deleted' in item && !!(item as { deleted?: boolean }).deleted;
const mergeById = <T extends { id: string }>(defaults: T[], user?: T[]): T[] => {
if (!user) return defaults;
const merged = [...defaults];
const deletedIds = new Set(
user.filter(item => isDeleted(item)).map(item => item.id)
);
const merged = defaults.filter(d => !deletedIds.has(d.id));
for (const item of user) {
if (isDeleted(item)) continue;
const index = merged.findIndex(d => d.id === item.id);
if (index !== -1) {
merged[index] = { ...merged[index], ...item };
@ -269,4 +244,4 @@ export const useMetadataStore = create<MetadataState>((set, get) => ({
remarkTemplates: mergeById(DEFAULTS.remarkTemplates, data.remarkTemplates),
});
},
}));
}});

View File

@ -53,6 +53,10 @@ export interface SettingsState {
reminderHour: number;
reminderMinute: number;
// 悬浮球
floatingBallEnabled: boolean;
setFloatingBallEnabled: (enabled: boolean) => void;
// actions
updateDedupConfig: (patch: Partial<DedupConfig>) => void;
updateTransferConfig: (patch: Partial<TransferConfig>) => void;
@ -69,6 +73,8 @@ export interface SettingsState {
persistTo?: (state: PersistableSettings) => void;
/** 从持久化源恢复(启动时调用)。 */
hydrate: (data: Partial<PersistableSettings>) => void;
/** 跳过下次持久化hydrate 时使用)。 */
skipPersist: boolean;
}
/** 可持久化的设置子集(排除函数)。 */
@ -97,6 +103,7 @@ export interface PersistableSettings {
reminderEnabled: boolean;
reminderHour: number;
reminderMinute: number;
floatingBallEnabled: boolean;
}
/**
@ -106,7 +113,7 @@ export interface PersistableSettings {
export const SECURE_KEYS = ['aiApiKey', 'webdavPassword', 'gitPassword'] as const;
export type SecureKey = (typeof SECURE_KEYS)[number];
const DEFAULTS: PersistableSettings = {
const DEFAULTS: PersistableSettings & { skipPersist: boolean; floatingBallEnabled: boolean } = {
dedupConfig: DEFAULT_DEDUP_CONFIG,
transferConfig: DEFAULT_TRANSFER_CONFIG,
dedupEnabled: true,
@ -117,7 +124,7 @@ const DEFAULTS: PersistableSettings = {
webdavUrl: '',
webdavUsername: '',
webdavPassword: '',
webdavRemotePath: 'mobile.bean',
webdavRemotePath: 'main.bean',
gitRemoteUrl: '',
gitBranch: 'main',
gitUsername: '',
@ -131,9 +138,11 @@ const DEFAULTS: PersistableSettings = {
reminderEnabled: false,
reminderHour: 20,
reminderMinute: 0,
floatingBallEnabled: false,
skipPersist: false,
};
function toPersistable(state: SettingsState): PersistableSettings {
export function toPersistable(state: SettingsState): PersistableSettings {
return {
dedupConfig: state.dedupConfig,
transferConfig: state.transferConfig,
@ -159,68 +168,41 @@ function toPersistable(state: SettingsState): PersistableSettings {
reminderEnabled: state.reminderEnabled,
reminderHour: state.reminderHour,
reminderMinute: state.reminderMinute,
floatingBallEnabled: state.floatingBallEnabled,
};
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
export const useSettingsStore = create<SettingsState>((set) => ({
...DEFAULTS,
updateDedupConfig: (patch) => {
set((s) => ({ dedupConfig: { ...s.dedupConfig, ...patch } }));
get().persistTo?.(toPersistable(get()));
},
updateDedupConfig: (patch) => set((s) => ({ dedupConfig: { ...s.dedupConfig, ...patch } })),
updateTransferConfig: (patch) => {
set((s) => ({ transferConfig: { ...s.transferConfig, ...patch } }));
get().persistTo?.(toPersistable(get()));
},
updateTransferConfig: (patch) => set((s) => ({ transferConfig: { ...s.transferConfig, ...patch } })),
setDedupEnabled: (enabled) => {
set({ dedupEnabled: enabled });
get().persistTo?.(toPersistable(get()));
},
setDedupEnabled: (enabled) => set({ dedupEnabled: enabled }),
setTransferRecognitionEnabled: (enabled) => {
set({ transferRecognitionEnabled: enabled });
get().persistTo?.(toPersistable(get()));
},
setTransferRecognitionEnabled: (enabled) => set({ transferRecognitionEnabled: enabled }),
setThemeMode: (mode) => {
set({ themeMode: mode });
get().persistTo?.(toPersistable(get()));
},
setThemeMode: (mode) => set({ themeMode: mode }),
setLocale: (locale) => {
set({ locale });
get().persistTo?.(toPersistable(get()));
},
setLocale: (locale) => set({ locale }),
setAppLockEnabled: (enabled) => {
set({ appLockEnabled: enabled });
get().persistTo?.(toPersistable(get()));
},
setAppLockEnabled: (enabled) => set({ appLockEnabled: enabled }),
updateSyncConfig: (patch) => {
set((s) => ({ ...s, ...patch }));
get().persistTo?.(toPersistable(get()));
},
updateSyncConfig: (patch) => set((s) => ({ ...s, ...patch })),
updateAiConfig: (patch) => {
set((s) => ({ ...s, ...patch }));
get().persistTo?.(toPersistable(get()));
},
updateAiConfig: (patch) => set((s) => ({ ...s, ...patch })),
updateReminderConfig: (patch) => {
set((s) => ({ ...s, ...patch }));
get().persistTo?.(toPersistable(get()));
},
updateReminderConfig: (patch) => set((s) => ({ ...s, ...patch })),
setOnboardingCompleted: (completed) => {
set({ onboardingCompleted: completed });
get().persistTo?.(toPersistable(get()));
},
setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }),
setFloatingBallEnabled: (enabled) => set({ floatingBallEnabled: enabled }),
hydrate: (data) => {
set({ ...DEFAULTS, ...data });
// hydrate 期间跳过自动持久化
// 使用单次 setState 合并所有数据,避免多次触发 subscriber
useSettingsStore.setState({ ...DEFAULTS, ...data, skipPersist: true });
useSettingsStore.setState({ skipPersist: false });
},
}));

Some files were not shown because too many files have changed in this diff Show More