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:
parent
76a5853ab6
commit
7fa345b558
4
.gitignore
vendored
4
.gitignore
vendored
@ -47,4 +47,6 @@ CLAUDE.md
|
||||
/.mimocode/
|
||||
/.agents/
|
||||
/reference_project/
|
||||
/.zcode/
|
||||
/.zcode/
|
||||
# superpowers brainstorm mockups
|
||||
.superpowers/
|
||||
|
||||
16
app.json
16
app.json
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
assets/icon/direction_a_feather.png
Normal file
BIN
assets/icon/direction_a_feather.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 486 KiB |
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "beancount-mobile",
|
||||
"name": "drift-ledger",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "beancount-mobile-plugin-accessibility",
|
||||
"name": "drift-ledger-plugin-accessibility",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "app.plugin.js"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "beancount-mobile-plugin-ppocr",
|
||||
"name": "drift-ledger-plugin-ppocr",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "app.plugin.js"
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "size-optimization",
|
||||
"name": "drift-ledger-plugin-size-optimization",
|
||||
"main": "app.plugin.js"
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* 报表页(plan.md「5.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 },
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* 分类管理页面(plan.md「1.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 },
|
||||
});
|
||||
|
||||
|
||||
@ -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 }), [
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }]}>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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 ?? ''}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* 年度报告图表(plan.md「5.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}>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* 分类占比图(plan.md「5.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 (
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* 月度报告图表(plan.md「5.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 (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 净资产趋势图(plan.md「5.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 (
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* 趋势折线图(plan.md「5.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;
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* 账户树与报表计算(plan.md「5.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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* 年度报告(plan.md「5.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);
|
||||
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
/**
|
||||
/**
|
||||
* 预算管理(plan.md「5.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')}`;
|
||||
}
|
||||
|
||||
@ -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' };
|
||||
|
||||
|
||||
@ -1,63 +1,85 @@
|
||||
/**
|
||||
* 图表统计纯函数(plan.md「5.2 图表与可视化」的数据计算部分)。
|
||||
* 从 components/charts/ 抽出,使可单测(不依赖 react-native)。
|
||||
*
|
||||
* 内部聚合使用 addDecimals(decimal 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;
|
||||
}
|
||||
|
||||
@ -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': '微信',
|
||||
|
||||
@ -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
49
src/domain/csvUtils.ts
Normal 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() ?? '';
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]),
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,9 +4,9 @@ import type { LedgerIndex, TransactionDraft } from './types';
|
||||
import type { MobileCommit } from './mobile';
|
||||
|
||||
/**
|
||||
* mobile.bean 的工程化存储:并发写入锁、原子写入、崩溃恢复。
|
||||
* main.bean 的工程化存储:并发写入锁、原子写入、崩溃恢复。
|
||||
*
|
||||
* 设计要点(见 plan.md「0.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)。 */
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* 净资产计算(plan.md「5.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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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 ?? '' },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* OCR 分层处理协调器(plan.md「3.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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)。 */
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 转为规则执行数据。 */
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: '对手方匹配' };
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '重试',
|
||||
|
||||
@ -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)。 */
|
||||
|
||||
150
src/services/accessibilityParser.ts
Normal file
150
src/services/accessibilityParser.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
/** 图片 URI(ScreenshotModule 可选)。 */
|
||||
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(无障碍服务直接给 base64,ScreenshotModule 也读取了 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();
|
||||
|
||||
@ -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 },
|
||||
];
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* 崩溃上报(plan.md「9.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();
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.md「0.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 不保证跨文件系统原子性,
|
||||
|
||||
71
src/services/floatingBillManager.ts
Normal file
71
src/services/floatingBillManager.ts
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 悬浮窗交易草稿管理(App 后台模式的浮窗入账)。
|
||||
*
|
||||
* 从 automationPipeline.ts 提取,职责:
|
||||
* - 管理待确认的交易草稿 Map(LRU + 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 };
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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('');
|
||||
/** 生成随机 salt(16 字节 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;
|
||||
}
|
||||
|
||||
/** 是否应在恢复时锁屏(基于超时配置)。 */
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 海报生成协调器。
|
||||
*/
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* 短信监听(plan.md「4.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);
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
* 通过注入的 GitBackend 实现拉取/合并/推送。
|
||||
* - 测试用 MockGitBackend
|
||||
* - 生产用 ExpoGitBackend:基于 HTTP(fetch + 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}` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* WebDAV 同步(plan.md「6.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',
|
||||
|
||||
72
src/services/txKeyStore.ts
Normal file
72
src/services/txKeyStore.ts
Normal 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();
|
||||
}
|
||||
@ -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) });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
}));
|
||||
}});
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user