Compare commits

..
6 Commits
Author SHA1 Message Date
fengmengqi 6767dd538a feat: 领域/组件/服务三层目录重构 + 微信无障碍绕过方案 + 新 UI 组件体系 + 账本增删改增强
### 架构重构:三层分层目录化
  - domain 拆分为 8 个子目录(core/pipeline/rules/finance/stats/taxonomy/transaction/platform)
  - components 拆分为 6 个子目录(form/ui/layout/account/category/stats/transaction)
  - services 拆分为 5 个子目录(automation/data/ocr/security),accessibilityParser 从 automationPipeline 提取
  - 新增 ruleConfig.ts — 规则配置唯一数据源(关键词/方向/OCR 模式),与业务逻辑解耦

  ### 微信账单抓取:节点混淆绕过(核心突破)
  - BillingAccessibilityService 重命名为 SelectToSpeakService,完整伪装为系统服务
  - 同时伪装包名+类名为 com.google.android.accessibility.selecttospeak,规避微信 8.0.52+ 白名单校验
  - Config Plugin 重写:8 个 kt 文件整体复制+package 正则替换+Manifest/import 联动
  - 支付宝/微信无障碍文本解析器全面增强(方向推断/账单分类提取/付款方式提取/容错)
  - 补充文档 accessibility-wechat-guide.md(伪装原理、踩坑全记录)

  ### 新 UI 组件体系
  - Toast:全局轻量 toast(Context Provider + 入场动画 + 操作按钮 + 自动消失)
  - ErrorBoundary:React class 错误边界(降级 UI + 重试)
  - EmptyState / ConfirmDialog / SegmentedControl / Skeleton / TimePicker
  - BottomSheet 重写:SafeAreaProvider 修复、手势下滑关闭、键盘响应式避让
  - FormModal 重构:拆出 FormFields 子组件(TextField/SelectField/DropdownField)
  - 新增 PeriodSwitcher、RangeStatsCard 独立组件

  ### 新 Hooks & 工具
  - useBottomInset — 统一底部安全区留白
  - useKeyboardAvoiding — 键盘高度响应式 hook(替代 translateY 方案)
  - sanitize.ts — 日志脱敏工具提取

  ### 账本增删改增强
  - 写锁增加代际计数器(lockGeneration),reset 后旧链 pending 任务自动跳过 set
  - 新增 restoreTransaction — 撤销删除(重新追加 raw 文本到 mobile.bean)
  - 删除交易时清除去重缓存(buildTxKeyFromRaw 重建去重键),支持「删了重记」
  - editTransaction/deleteTransaction 改用 dr-id 精确定位交易块(避免同名交易定位错误)
  - appendTransactionsBatch 改从存储直接读取,避免 zustand state 不一致

  ### OCR 原生模块增强
  - 异步 initEngine 增加 CountDownLatch 等待(最多 15s),解决竞态导致的「引擎未就绪」
  - setModelDir 增加去重判断 + file:// 前缀剥离,避免冗余 reload
  - 推理链路增加分阶段耗时日志(det 推理/det 后处理/rec 识别)
  - 图片缩放策略重命名(scaleDownForOcr → capLongEdge)

  ### OCR 模型按需下载
  - 移除了启动时自动下载 ~30MB OCR 模型的逻辑
  - 改为首次使用 OCR 时才触发下载

  ### 设置页重设计
  - ScrollView → SectionList 分组卡片布局(iOS 风格分组圆角行+右侧箭头)
  - 移除 Card 组件包装,直接使用独立分组头+底部关于卡片

  ### 首页优化
  - ScrollView → FlatList(ListHeaderComponent 承载净资产卡片+待办条)
  - 日期/金额格式化增加 locale 感知(zh/en)

  ### 通知管道增强
  - NotificationChannel MD5 去重改为批量淘汰(80% 阈值),替代逐个删除
  - 增加 debug 日志输出(过滤原因/包名)

  ### ESLint
  - 新增 eslint.config.mjs(typescript-eslint + react-hooks + react-native 规则集)
  - package.json 新增 lint/lint:fix 脚本,引入 5 个 devDependencies

  ### 文档
  - accessibility-wechat-guide.md — 微信无障碍伪装完整方案
  - modal-keyboard-guide.md — 弹窗键盘避让方案
  - ocr-pipeline-guide.md — OCR 三层层级管线
  - OCR及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
2026-07-28 20:57:37 +08:00
fengmengqi bf04400852 feat: OCR 模型按需下载 + 三层独立开关 + 日志持久化 + 原生浮层主题同步 + 文档重构
OCR 模型按需下载(P8 瘦身):
- 移除 plugins/ppocr/assets/ 内置模型(det 9.8MB + rec 21MB + dict),APK 减包 ~31MB
- 新增 modelDownloader.ts:优先从 HuggingFace/CDN 下载,兜底从 APK assets 拷贝
- OcrModule.kt 新增 setModelDir,支持从 filesystem 加载模型,回退 assets 兼容旧用户
- settingsStore 持久化 ocrModelVersion / ocrModelDir
- 自动化页集成模型状态检查与一键下载 UI

OCR 三层独立控制:
- OcrProcessorConfig 从单一 aiVisionEnabled 拆分为 layer1/2/3 三个独立开关
- 设置页可按层启停(L1 正则规则 / L2 本地 OCR / L3 AI Vision)
- OCR 处理增加耗时与字符数日志

日志系统升级:
- logger.ts 新增 LogFileBackend 抽象,支持磁盘持久化(按日期 app-YYYY-MM-DD.log)
- 新增 logBackend.ts(ExpoLogFileBackend)+ 日志中心页 settings/logs.tsx
- 日志中心:实时缓冲 + 历史文件、4 级过滤、Tag/关键词搜索、JSON 展开、分享导出、7 天过期清理
- _layout.tsx 启动时初始化文件后端 + 日志脱敏(验证码/卡号)

原生浮层 UI 主题同步(P6):
- 新增 floatingUiConfig.ts:JS 侧从 theme tokens + i18n 构建 FloatingUiConfig 推送原生
- 新增 FloatingUiConfigStore.kt:SharedPreferences 存储,三浮层组件读取
- FloatingBillView 重设计:颜色/文案走配置、新增币种 chip、金额校验改 BigDecimal
- FloatingHelper / FloatingTip 同步适配
- _layout.tsx 新增 FloatingUiConfigSyncer,主题/语言切换自动推送

UI 与组件增强:
- FormModal 新增 select/dropdown 控件、行内布局(row/flex)、联动回调 onValuesChange
- 新增 Touchable 通用触摸组件、AccountCreateModal 快速建账弹窗
- 信用卡页展示账单周期/到期还款日/本期应还/剩余可用额度,关联账户改下拉选择
- AI 设置页重做:OpenAI/Gemini/DeepSeek 预设 + 默认 URL/模型
- 引导页新增 Android 权限检查步骤(无障碍/通知/短信/存储/悬浮窗)

去重优化:
- 对手方匹配改为模糊包含(includes),双方均无对手方时判定低置信度重复
- DedupResult 新增 matchedItem 返回匹配对比项

文档重构:
- README.md 重写为入口索引(品牌更新 + 模块概览 + 文档导航表)
- 新增 AGENTS.md(AI 助手贡献指南)、docs/architecture.md(Mermaid 数据流/分层/OCR 级联图)
- 新增 docs/development.md(环境/命令/编码规范/测试/提交规范)、plugins/README.md
- UI 重设计文档(design spec + p1-p8)移入 docs/design/

其他:
- i18n 新增权限/信用卡详情/日志中心/AI 设置等翻译键
- ppocr Config Plugin 修复 import 注入去重;size-optimization 增强
- 新增测试:logger.test.ts、floating-ui-config.test.ts
2026-07-23 19:03:38 +08:00
fengmengqi 2e73b5a2c6 feat: UI 全面重设计(Bento 明亮风)+ 记一笔 NumpadSheet + 管理页模板 + OCR v5→v6 升级
UI 重设计(P1-P5):
- 主题从靛蓝 accent 切换为近黑白单色(#111318),品牌感靠排版与财务语义色
- 暗色模式适配 OLED 纯黑,财务色整体提亮一档保证对比度
- 圆角体系新增 xl(24),卡片/弹窗统一大圆角
- 移除 Caveat/Quicksand 自定义字体,切换为系统字体
- 新增 display(34/800) 字阶用于净资产等大数字展示
- 分类/标签/渠道颜色统一到 palette.ts 色板(12 色循环 + FNV 哈希)
记一笔 NumpadSheet(P3 录入闭环):
- 全新计算器风格录入面板:方向 chip → 大金额 → 账户 chip → 分类快捷行 → 数字键盘
- 支持表达式输入(20+15-5.5)与字符串十进制求值,避免浮点精度问题
- 高级模式保留多行分录编辑器,简单模式自动推断双腿
- postingLegs.ts 实现 buildPostings 的逆操作(编辑/草稿预填)
- 未保存守卫:整页 beforeRemove + modal 脏状态上报
- 全局弹层(NumpadSheetHost)由+按钮唤起,可通过设置开关
Tab 栏重构:
- AppTabBar 替换默认 tab bar,中央+按钮唤起全局记账面板
- Tab 从 5 个精简为 4 个(首页/交易/报表/设置),+按钮独立
管理页模板 ManagementScreen:
- 统一「列表 + 新增 + 点按编辑 + 长按删除 + FormModal」模式
- 预算/分类/标签/信用卡等管理页消除手写样板
其他:
- OCR 模型从 PP-OCRv5 升级到 v6(det/rec/dict 全部替换)
- GBK 解码器 import 修复(text-encoding-gbk ESM 兼容)
- 批量确认失败数计算修正(用 failedPayees.length 替代 store 快照差值)
- 新增 diagnostics 诊断页、TodoStrip 首页待办条、SwipeableTransactionCard 左滑操作
- periodNav.ts 报表周期导航(anchor+period 统一状态管理,本地时区运算)
- 测试覆盖 numpadExpression/postingLegs/periodNav/palette/search/transactionGroups/categoryIcons/dateGrid
2026-07-22 13:33:29 +08:00
fengmengqi 7fa345b558 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 新增不同格式金额配对、指纹唯一性测试
2026-07-21 15:16:44 +08:00
fengmengqi 76a5853ab6 feat: 重构渠道模型与管道架构,全面升级 UI 主题和报表功能
核心重构 — 去除 channel 字段,引入 sourceAccount 模型:
- 从 ImportedEvent、Rule、EnhancedRule、OcrRule 等接口中彻底移除 channel 字段
- rules.ts 中 resolveChannelAccount → resolveSourceAccount,规则匹配与账户解析不再依赖渠道概念
- dedup.ts 重写去重逻辑:从基于渠道匹配改为基于交易对手(counterparty)匹配,支持相同金额/交易对手/时间窗口的多级置信度判断
- transferRecognizer.ts 增加资产负债表账户校验,确保转账双方均为 Assets/Liabilities 类账户
- 全局替换影响:types、rules、ocr、adapters、adapters-migrations、所有服务层和测试
新增基础设施:
- domain/constants.ts — 统一常量定义(支付包名、截图关键词、去重参数、方向检测函数 detectDirection()),消除 OCR/SMS/截图等模块的重复定义
- domain/channelConfig.ts — 渠道配置系统(支付宝/微信/银行),支持按包名和名称查找
- domain/pipelineSingleton.ts — 共享 BillPipeline 单例,解决 importStore/automationStore 的互斥锁共享问题
- domain/transactionBuilder.ts — 统一交易构建入口 buildAndSaveTransaction(),同时服务手动录入和无障碍监听
OCR 增强:
- 新增账单详情页解析(parseDetailPageBill),支持支付宝/微信详情页结构化提取
- checkIsDetailPage() 识别详情页特征词,防止误提取(如"消费1次"被误读为金额)
- 金额正则支持千分位逗号分隔,商户名正则改用 lookahead 边界匹配
- 时间解析支持中文格式(年月日)和跨年推断
- OcrProcessor 新增详情页路由,跳过 Layer 1 规则匹配
UI 全面升级:
- 主题重设计:accent 色从绿色改为靛蓝(#4F46E5),深色模式适配 OLED 纯黑,引入 Quicksand/Caveat 字体
- 新增 commonStyles.ts 统一 chip/input/modal 等通用样式
- 首页 Bento 网格布局:净资产英雄卡片 + 定期账单/月度统计并排展示
- 报表新增周报标签页,月报整合日历视图(支持点击查看当日交易明细)
- TrendLine 图表从 View 条形图重写为 SVG 贝塞尔曲线
- CategoryPicker 从水平滚动改为 4 列网格 + emoji 图标
- Button/Card 增加 press 缩放动画
管道与自动化改进:
- automationPipeline.ts 新增 handleIncomingBillEvent() 实时账单处理(悬浮账单卡片 + 前台 Alert 确认)
- 新增无障碍文本直解析 parseAndProcessAccessibilityTexts(),微信/支付宝详情页绕过 OCR
- rules.ts 新增智能还款检测(花呗/信用卡还款自动路由)和退款视为收入处理
- metadataStore 默认规则精简为 6 条通用规则,移除约 20 条个人化硬编码规则
存储与同步:
- storePersistence.ts 原子写入 + 崩溃恢复 + 重试机制
- 备份升级到 v2 格式,包含 settings 和 metadata
- 同步路径统一从 mobile.bean 改为 main.bean
- _layout.tsx 启动时自动迁移旧 mobile.bean 到 main.bean
其他:
- 删除独立日历页面,功能合并到报表月报标签
- i18n 清理:移除渠道相关翻译,新增 50+ 翻译键
- docs/android-build-guide.md 重写为 APK 体积优化指南
- 新增 design-system/beancount-mobile/MASTER.md 设计系统文档
- 测试全面更新覆盖以上所有变更
2026-07-18 18:02:45 +08:00
fengmengqi f6437b83fe feat: 初始化完整应用框架
- 切换到 expo-router 文件路由,删除 App.tsx
- 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取),
  notification-listener, screenshot-monitor, sms-receiver
- 实现核心领域逻辑:billPipeline (账单流水), dedup (去重),
  transferRecognizer (转账识别), ruleEngine + categories (双轨制分类),
  budgets, creditCards, recurring, sync, ocrProcessor
- 增强 ledger.ts:支持 balance assertion, option, pad/note 指令,
  posting 级 metadata, cost/price 解析
- 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图,
  分类管理, 信用卡, 定期交易, 规则管理
- 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore,
  metadataStore, automationStore + 持久化
- 新增 AI 功能:chatAssistant, monthlySummary, voiceInput
- 实现多端同步:gitSync, webdavSync, icloudSync
- 新增主题系统 (tokens/presets) 和 i18n (zh/en)
- 添加 30+ 单元测试覆盖核心逻辑
2026-07-15 10:01:31 +08:00
281 changed files with 53961 additions and 1409 deletions
+15 -5
View File
@@ -8,8 +8,8 @@ web-build/
expo-env.d.ts
# Native
android/
ios/
/android/
/ios/
*.hprof
# Metro
@@ -35,8 +35,18 @@ yarn-error.*
.env.*
# Outputs
outputs/
/outputs/
# Example financial data (may contain real PII)
/example/
# AI assistant config
CLAUDE.md
# MiMoCode
.mimocode/
.agent/
/.mimocode/
/.agents/
/reference_project/
/.zcode/
# superpowers brainstorm mockups
.superpowers/
+67
View File
@@ -0,0 +1,67 @@
# Repository Guidelines
## Project Overview
DriftLedger (浮记) is an offline-first mobile client for [Beancount](https://beancount.github.io/) double-entry accounting, built with React Native + Expo (CNG), TypeScript, Zustand, and expo-sqlite. The app treats desktop-maintained `.bean` files as the read-only source of truth and appends new entries to `main.bean`.
## Project Structure & Module Organization
| Directory | Purpose |
|-----------|---------|
| `src/domain/` | Pure business logic (ledger parser, bill pipeline, dedup, rules, OCR processor). No React/RN imports. |
| `src/app/` | Expo Router file-based routes; `(tabs)/` is bottom navigation. |
| `src/components/` | Reusable React Native UI components and charts. |
| `src/store/` | Zustand stores (ledger, import, settings, metadata, automation). |
| `src/services/` | Platform-facing concerns (sync, backup, security, OCR bridge, automation pipeline, accessibility text parser). `src/services/automation/accessibilityParser.ts` parses WeChat/Alipay/bank accessibility node texts into `ImportedEvent`. |
| `src/storage/` | SQLite read-cache with versioned migrations. |
| `src/theme/` | Token-based theming system. |
| `src/i18n/` | zh/en localization dictionaries. |
| `plugins/` | Expo Config Plugins for native modules (ppocr, accessibility, sms-receiver, etc.). |
| `tests/` | Vitest unit tests (mock-backed, domain-focused). |
## Build, Test, and Development Commands
```bash
npm install --legacy-peer-deps # Install dependencies (legacy-peer-deps required)
npm run start # Start Metro dev server
npm run android # Build & run on Android device/emulator
npm test # Run full Vitest suite
npm run typecheck # TypeScript strict-mode check (tsc --noEmit)
# Run a single test file:
npx vitest run tests/ledger.test.ts
```
## Coding Style & Naming Conventions
- **TypeScript strict mode** is enabled; path alias `@/*` maps to `src/*`.
- **Money math** uses string decimals (`src/domain/decimal.ts`) — never raw JS floats.
- **Domain layer** (`src/domain/`) must remain pure: no React, RN, or Expo imports. Inject external dependencies via interfaces.
- Code comments and `plan.md` are written in **Chinese**; match that convention.
- New domain modules must be re-exported from `src/domain/index.ts`.
- Native code lives exclusively in `plugins/<name>/` as Expo Config Plugins — never edit generated `android/` files directly.
## Testing Guidelines
- Framework: **Vitest** (no config file; picks up `tests/**/*.test.ts` by default).
- Tests target the domain layer using mock backends (`MemoryBackend`, `MockOcrEngine`).
- Name test files descriptively: `<module-or-feature>.test.ts` (e.g., `decimal.test.ts`, `billPipeline.test.ts`).
- Run `npm run typecheck` after non-trivial changes to catch type regressions.
## Commit & Pull Request Guidelines
- Commit messages follow the pattern: `feat: <concise summary in Chinese>` with a detailed multi-line body listing changes by area.
- Example: `feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构`
- PRs should describe what changed and why; link related issues. Include screenshots for UI changes.
## Key Architectural Invariants
1. `.bean` files are the source of truth; SQLite is a disposable read-cache.
2. All bill ingestion funnels through `BillPipeline` (`src/domain/billPipeline.ts`) under a serialized mutex.
3. Every Config Plugin function **must `return config`** at the end.
4. IDs/checksums use FNV-1a hashing (`hash()` in `ledger.ts`), not crypto hashes.
5. **Modal 弹窗**脱离主窗口 contextModal 内必须重新包 `<SafeAreaProvider>`,且键盘避让用**响应式 paddingBottom**`Math.max(insets.bottom, 24) + kbHeight`),不要用 `KeyboardAvoidingView``translateY` 位移。详见 `docs/modal-keyboard-guide.md`
6. **改了 TS 后 release 包若不更新**gradle 的 bundle task 会因缓存跳过重打包,Metro 也有 transformer cache。验证 bundle 必须用 `grep -a`(Hermes 字节码是二进制)。详见 `docs/android-build-guide.md §7`
7. **改了原生 `.kt` 后必须让 `android/` 副本同步**Config Plugin 的 `withDangerousMod` 只在 `prebuild` 时把 `plugins/*/android/*.kt` 复制到 `android/` 并替换包名,直接 gradle 编译会用旧副本(「改了没生效」的另一类根因,与第 6 条的 TS bundle 缓存并列)。全量 `prebuild` 或「只改单个 .kt 时手动同步副本」二选一。详见 `docs/android-build-guide.md §0 / §5.4`
8. **OCR 的 det 后处理必须用连通域法**`OcrModule.kt``dbPostprocess`4-连通 BFS + 官方 unclip 外扩 + box_score_fast 过滤),**禁止回退到水平/垂直投影切行**——投影法会把基线孤立小数点切到文本框外,导致 `¥143.97` 识别成 `¥14397`。怀疑「模型识别能力不足」前先用官方 PaddleOCR 跑同一对模型对照。详见 `docs/ocr-pipeline-guide.md`
9. **无障碍服务伪装必须包名+类名同时匹配**:微信 8.0.52+ 按 `ComponentName``包名/类名`)识别系统服务白名单,只伪装类名无效。8 个 kt 文件整体在 Config Plugin`plugins/accessibility/app.plugin.js`)里 rewrite 到 `com.google.android.accessibility.selecttospeak` 包,Manifest `android:name` 写成完整全限定名 `com.google.android.accessibility.selecttospeak.SelectToSpeakService`。**kt 的 `package` 声明、物理路径、Manifest 服务名三者必须逐字一致**,否则 `ClassNotFoundException`。改 package 重写正则时必须精确匹配源码的 `com.beancount.mobile.accessibility`(含 `.accessibility` 后缀),漏匹配会多出一段导致编译失败。伪装后 `triggerManualExtraction` 必须用 `dumpAllTexts()`(覆盖 WebView 子窗口),不能用只读 `rootInActiveWindow` 的旧路径。详见 `docs/accessibility-wechat-guide.md`
-46
View File
@@ -1,46 +0,0 @@
import { useMemo, useState } from 'react';
import { Pressable, SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { classify, commitMobileTransaction, importStatement, parseLedger, type ImportedEvent, type Rule, validateTransaction } from './src/domain';
const sampleLedger = `option "operating_currency" "CNY"
2026-01-01 open Assets:Alipay CNY
2026-01-01 open Assets:WeChat CNY
2026-01-01 open Expenses:Food CNY
2026-01-01 open Expenses:Uncategorized CNY
2026-01-01 open Income:Uncategorized CNY
include "mobile.bean"
`;
const sampleStatement = '交易时间,金额(元),收/支,交易对方,商品说明,交易订单号\n2026-07-01,24.50,支出,咖啡店,冰美式,ORDER-1\n2026-07-02,5000,收入,公司,工资,ORDER-2';
const rules: Rule[] = [{ id: 'coffee', priority: 100, channel: 'Alipay', counterpartyContains: '咖啡', channelAccount: 'Assets:Alipay', categoryAccount: 'Expenses:Food', narration: '咖啡', tags: ['food'], hits: 0 }];
type Tab = '账本' | '记账' | '导入' | '规则' | '诊断';
export default function App() {
const ledger = useMemo(() => parseLedger([{ path: 'main.bean', content: sampleLedger }]), []);
const [tab, setTab] = useState<Tab>('账本'); const [mobileBean, setMobileBean] = useState(''); const [events, setEvents] = useState<ImportedEvent[]>([]);
const [message, setMessage] = useState('账本已解析。手机交易只会写入 mobile.bean。');
const [amount, setAmount] = useState(''); const [account, setAccount] = useState('Expenses:Food');
const addManual = () => {
const draft = { date: '2026-07-10', narration: '手工记账', postings: [{ account: 'Assets:Alipay', amount: `-${amount}`, currency: 'CNY' }, { account, amount, currency: 'CNY' }] };
const result = validateTransaction(draft, ledger); if (!result.valid) return setMessage(result.errors.join(''));
try { const commit = commitMobileTransaction(draft, ledger, mobileBean); setMobileBean(commit.content); setAmount(''); setMessage('已追加到 mobile.bean。'); } catch (error) { setMessage(String(error)); }
};
const loadDemo = () => { setEvents(importStatement(sampleStatement, 'alipay-csv-v1')); setMessage('已导入 2 条账单;请逐条确认。'); };
const confirm = (event: ImportedEvent) => {
const candidate = classify(event, rules, ledger); try { const commit = commitMobileTransaction(candidate.draft, ledger, mobileBean); setMobileBean(commit.content); setEvents(current => current.filter(item => item.id !== event.id)); setMessage(candidate.ruleId ? '已按规则确认并写入 mobile.bean。' : '未匹配规则,已确认未分类分录。'); } catch (error) { setMessage(String(error)); }
};
return <SafeAreaView style={styles.page}><StatusBar style="dark" />
<View style={styles.header}><Text style={styles.title}>Bean Mobile</Text><Text style={styles.subtitle}>{message}</Text></View>
<View style={styles.tabs}>{(['账本', '记账', '导入', '规则', '诊断'] as Tab[]).map(item => <Pressable key={item} onPress={() => setTab(item)} style={[styles.tab, tab === item && styles.activeTab]}><Text style={tab === item ? styles.activeText : styles.tabText}>{item}</Text></Pressable>)}</View>
<ScrollView contentContainerStyle={styles.content}>
{tab === '账本' && <><Card title="账本状态"><Text>{ledger.accounts.size} · {ledger.transactions.length} </Text><Text style={styles.muted}> include "mobile.bean"</Text></Card><Card title="mobile.bean"><Text>{mobileBean || '尚无手机端交易'}</Text></Card></>}
{tab === '记账' && <Card title="手工复式记账"><TextInput value={amount} onChangeText={setAmount} keyboardType="decimal-pad" placeholder="金额,例如 24.50" style={styles.input}/><TextInput value={account} onChangeText={setAccount} placeholder="费用账户" style={styles.input}/><Button label="校验并追加到 mobile.bean" onPress={addManual}/></Card>}
{tab === '导入' && <><Card title="账单自动记账"><Text style={styles.muted}> CSV</Text><Button label="导入演示支付宝 CSV" onPress={loadDemo}/></Card>{events.map(event => { const candidate = classify(event, rules, ledger); return <Card key={event.id} title={`${event.occurredAt} · ${event.amount} ${event.currency}`}><Text>{event.counterparty} · {event.memo}</Text><Text style={styles.muted}>{candidate.ruleId ? `规则:${candidate.ruleId}` : '未匹配:将使用 Uncategorized'}</Text><Text>{candidate.draft.postings.map(posting => `${posting.account} ${posting.amount} ${posting.currency}`).join('\n')}</Text><Button label="确认入账" onPress={() => confirm(event)}/></Card>; })}</>}
{tab === '规则' && <Card title="自动分类规则">{rules.map(rule => <View key={rule.id} style={styles.row}><Text>{rule.id}</Text><Text style={styles.muted}>{rule.counterpartyContains} {rule.categoryAccount}</Text></View>)}</Card>}
{tab === '诊断' && <Card title="只读兼容与诊断"><Text>{ledger.diagnostics.length}</Text><Text>{ledger.unsupported.length}</Text><Text style={styles.muted}></Text></Card>}
</ScrollView>
</SafeAreaView>;
}
function Card({ title, children }: { title: string; children: React.ReactNode }) { return <View style={styles.card}><Text style={styles.cardTitle}>{title}</Text>{children}</View>; }
function Button({ label, onPress }: { label: string; onPress: () => void }) { return <Pressable accessibilityRole="button" onPress={onPress} style={styles.button}><Text style={styles.buttonText}>{label}</Text></Pressable>; }
const styles = StyleSheet.create({ page: { flex: 1, backgroundColor: '#f6f7f9' }, header: { padding: 20, paddingBottom: 12 }, title: { fontSize: 28, fontWeight: '700' }, subtitle: { color: '#57606a', marginTop: 6 }, tabs: { flexDirection: 'row', backgroundColor: '#fff', borderBottomWidth: 1, borderColor: '#e5e7eb' }, tab: { flex: 1, paddingVertical: 12, alignItems: 'center' }, activeTab: { borderBottomWidth: 2, borderColor: '#146c43' }, tabText: { color: '#57606a' }, activeText: { color: '#146c43', fontWeight: '700' }, content: { padding: 16, gap: 12 }, card: { backgroundColor: '#fff', borderRadius: 12, padding: 16, gap: 8, shadowColor: '#000', shadowOpacity: .04, shadowRadius: 6 }, cardTitle: { fontSize: 18, fontWeight: '700' }, muted: { color: '#687078', lineHeight: 20 }, input: { borderWidth: 1, borderColor: '#d0d7de', borderRadius: 8, padding: 11, backgroundColor: '#fff' }, button: { marginTop: 6, backgroundColor: '#146c43', padding: 12, borderRadius: 8, alignItems: 'center' }, buttonText: { color: '#fff', fontWeight: '700' }, row: { borderTopWidth: 1, borderColor: '#eef0f2', paddingTop: 10, gap: 3 } });
+43 -13
View File
@@ -1,26 +1,56 @@
# Bean Mobile
# DriftLedger (浮记)
面向 Beancount 用户的离线移动端原型:桌面账本只读,移动端仅写入 `mobile.bean`CSV 账单先生成复式分录草稿,再由用户确认
面向 [Beancount](https://beancount.github.io/) 用户的离线移动记账客户端。桌面账本只读,移动端仅追加写入 `main.bean`支持 OCR、无障碍服务、通知监听、短信等多渠道自动记账
## 启动
**技术栈**React Native + Expo (CNG) · TypeScript (strict) · Zustand · expo-sqlite · PP-OCRv6 (ONNX Runtime)
## 快速开始
```bash
npm install
npm run start
npm install --legacy-peer-deps # 安装依赖(必须带 --legacy-peer-deps
npm run start # 启动 Metro 开发服务器
npm run android # 构建并运行 Android
npm test # 运行 Vitest 全量测试
npm run typecheck # TypeScript 类型检查
```
使用 Expo CNG 开发构建运行:`npm run android``npm run ios`。应用配置启用了 `expo-sqlite` 的 SQLCipher 构建选项,实际密钥管理应接入 SecureStore/Keychain 后再发布
> 本应用为侧载开源项目,不上架应用商店。Android 构建详见 [docs/android-build-guide.md](docs/android-build-guide.md)
## 模块概览
| 目录 | 职责 |
|------|------|
| `src/domain/` | 纯业务逻辑:`.bean` 解析器、账单管道、去重、规则引擎、OCR 处理器 |
| `src/app/` | Expo Router 文件路由(`(tabs)/` 底部导航) |
| `src/components/` | React Native UI 组件与图表 |
| `src/store/` | Zustand 状态管理(ledger / import / settings / metadata / automation |
| `src/services/` | 平台服务(同步、备份、安全、OCR 桥接) |
| `src/storage/` | SQLite 读缓存 + 版本化迁移 |
| `src/theme/` | Token 主题系统(浅色 / OLED 暗色) |
| `src/i18n/` | 中英文国际化 |
| `plugins/` | Expo Config Plugins 原生模块(OCR、无障碍、通知、短信、截图) |
| `tests/` | Vitest 单元测试(mock 后端,覆盖 domain 层) |
## 文档导航
| 文档 | 内容 |
|------|------|
| [docs/architecture.md](docs/architecture.md) | 系统架构与数据流 |
| [docs/development.md](docs/development.md) | 开发指南(环境、命令、规范、测试) |
| [docs/android-build-guide.md](docs/android-build-guide.md) | Android 打包与体积优化 |
| [docs/accessibility-wechat-guide.md](docs/accessibility-wechat-guide.md) | 无障碍服务与微信文本抓取(绕过 8.0.52+ 节点混淆) |
| [docs/ocr-pipeline-guide.md](docs/ocr-pipeline-guide.md) | OCR 推理管线与诊断方法论 |
| [docs/design/](docs/design/) | UI 重设计规格与分阶段实施计划 |
| [design-system/](design-system/beancount-mobile/MASTER.md) | 设计系统 Token 定义 |
| [AGENTS.md](AGENTS.md) | AI 编程助手贡献指南 |
| [plan.md](plan.md) | 设计决策与实施路线图(权威文档) |
## 账本约定
在主账本添加一次:
桌面主账本添加一次:
```beancount
include "mobile.bean"
include "main.bean"
```
应用不改写主账本或其他桌面维护文件;确认的交易只追加到 `mobile.bean`导出的账本包由用户再同步到 Git、WebDAV 或 iCloud。
## 当前边界
已实现账务校验、基础 `.bean` 解析、CSV 账单规范化、去重、规则分类、转账候选和 `mobile.bean` 序列化。Tree-sitter 原生模块、真实文件选择/ZIP 导出、正式支付宝/微信/各银行字段映射、SecureStore 密钥和多设备冲突处理仍需在真机集成阶段接入。
应用不改写主账本或其他桌面维护文件;确认的交易只追加到 `main.bean`
+36 -6
View File
@@ -1,10 +1,40 @@
{
"expo": {
"name": "Bean Mobile",
"slug": "bean-mobile",
"scheme": "beanmobile",
"plugins": [["expo-sqlite", { "enableFTS": true }]],
"ios": { "bundleIdentifier": "com.example.beanmobile" },
"android": { "package": "com.example.beanmobile" }
"name": "浮记",
"slug": "drift-ledger",
"scheme": "driftledger",
"icon": "./assets/icon/direction_a_feather.png",
"plugins": [
[
"expo-sqlite",
{
"enableFTS": true
}
],
"expo-router",
"expo-localization",
"./plugins/ppocr",
"./plugins/accessibility",
"./plugins/notification-listener",
"./plugins/sms-receiver",
"./plugins/screenshot-monitor",
"./plugins/size-optimization",
"expo-secure-store"
],
"experiments": {
"tsConfigPath": "tsconfig.json",
"typedRoutes": true
},
"ios": {
"bundleIdentifier": "com.example.driftledger",
"icon": "./assets/icon/direction_a_feather.png"
},
"android": {
"package": "com.example.driftledger",
"adaptiveIcon": {
"foregroundImage": "./assets/icon/direction_a_feather.png",
"backgroundColor": "#1B1F20"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

+72
View File
@@ -0,0 +1,72 @@
# Beancount Mobile 设计系统(MASTER
> 本文件与 `src/theme/presets.ts` 一一对应,是实现的事实描述而非平行标准。
> 修改配色/字阶/圆角时必须先改 presets.ts,再同步本文件。
## 设计方向:明亮 Bento 现代风
浅色为主、大圆角卡片(Bento)、近黑白单色 + 财务语义色点缀。
品牌感靠排版与财务色表达,不依赖彩色 accent。暗色为 OLED 纯黑完整对等主题。
## 色板(= presets.ts
### 浅色(默认)
| Token | 值 | 用途 |
|---|---|---|
| bgPrimary | `#F6F7F9` | 页面背景 |
| bgSecondary | `#FFFFFF` | 卡片 |
| bgTertiary | `#EFF1F4` | 输入框 / chip |
| fgPrimary | `#111318` | 主文字 |
| fgSecondary | `#6B7280` | 次要文字 |
| fgInverse | `#FFFFFF` | 反色文字 |
| accent | `#111318` | 按钮 / 选中态(近黑) |
| accentLight | `rgba(17,19,24,0.08)` | 选中高亮底色 |
| accentDark | `#000000` | 按压态 |
| financial.income | `#10B981` | 收入 |
| financial.expense | `#EF4444` | 支出 |
| financial.transfer | `#3B82F6` | 转账 |
| border | `#E5E7EB` | 边框 |
| overlay | `rgba(17,19,24,0.4)` | 遮罩 |
### 暗色(OLED
| Token | 值 |
|---|---|
| bgPrimary | `#040508` |
| bgSecondary | `#101218` |
| bgTertiary | `#1A1D24` |
| fgPrimary / accent | `#F3F4F6` |
| fgSecondary | `#9CA3AF` |
| fgInverse | `#111318` |
| financial.income | `#34D399`(提亮) |
| financial.expense | `#F87171`(提亮) |
| financial.transfer | `#60A5FA`(提亮) |
| border | `rgba(255,255,255,0.08)` |
分类/标签/渠道颜色见 `src/theme/palette.ts`(分类 12 色循环 + 具名映射、TAG_COLORS、渠道品牌色)。
其余 tokendivider / skeleton / progressBg / success / warning / error / info)见 `presets.ts`
全工程颜色字面量只允许存在于 `presets.ts``palette.ts`
## 字体
系统字体(iOS SF / Android Roboto),不加载自定义字体。
金额数字一律 `fontVariant: ['tabular-nums']` 等宽对齐。
字阶:display 34/800 · h1 28/700 · h2 22/700 · h3 17/600 · body 16/400 · bodySmall 14/400 · caption 12/400。
## 圆角 / 间距 / 阴影
- 圆角:sm 8 · md 12 · lg 16 · **xl 24(弹窗默认;卡片组件 P2 统一升级)** · full
- 间距:xs 4 · sm 8 · md 16 · lg 24 · xl 32
- 阴影:浅色 4–12px 弥散轻阴影;暗色阴影减重,辅以半透边框
## 图标
统一 Ionicons`@expo/vector-icons`)。禁止用 emoji 充当图标;
分类图标 = Ionicon + 圆形彩色底(CategoryIcon 组件,P2 落地)。
## 反模式
- 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token
- 禁止低对比文字(< 4.5:1
- 禁止瞬间状态变化,交互反馈 150–300ms
- 禁止绕过 commonStyles 重复造 input/chip/modal 样式
+211
View File
@@ -0,0 +1,211 @@
# 无障碍服务与微信文本抓取指南 (Accessibility & WeChat Text Extraction)
本文记录 DriftLedger 无障碍服务(`plugins/accessibility/`)抓取微信账单页面文本的完整方案,重点沉淀**绕过微信 8.0.52+ 节点混淆**的伪装机制、Config Plugin 的落地细节、调试方法论与踩过的坑。
> 配套:OCR 管线见 `ocr-pipeline-guide.md`;构建/编译陷阱见 `android-build-guide.md`。
---
## 0. TL;DR
- 微信 8.0.52+ 对第三方无障碍服务做**节点混淆**:把页面节点的 `text` / `contentDescription` 用其他节点信息随机替换,导致基于节点文本的解析全部失效。微信内部有**白名单**:系统服务(TalkBack、SelectToSpeak)不受影响。
- 绕过方案:**伪装成系统 SelectToSpeak 服务**。微信按 `ComponentName``包名/类名`)识别白名单,所以必须**包名 + 类名同时伪装**为 `com.google.android.accessibility.selecttospeak.SelectToSpeakService`,只伪装类名无效。
- 落地由 Config Plugin`plugins/accessibility/app.plugin.js`)在 prebuild 时完成:把 8 个 kt 文件整体复制到 `com/google/android/accessibility/selecttospeak/` 目录,`package` 声明 rewrite 为该包,Manifest `android:name` 写成完整全限定名。三者必须严格一致,否则 `ClassNotFoundException`
- 伪装生效后,`triggerManualExtraction` 成为微信账单页的主识别路径,必须用 `dumpAllTexts()`(覆盖所有窗口含 WebView 子窗口),不能用只读 `rootInActiveWindow` 的简化路径。
- OCR 截图兜底链路保留,作为伪装失败或极少数空白帧场景的保底。
---
## 1. 背景:微信 8.0.52+ 节点混淆
### 现象
微信账单详情页(我-服务-钱包-账单-某笔交易)上,点悬浮球「识别账单」后,`billingDebugNodes` 事件里的 `texts` 数组拿到的是乱码、错位文本或空数组。`parseWechatTexts``src/services/automation/accessibilityParser.ts`)依赖「标签 + 下一节点为值」的顺序假设,一旦文本被打乱就完全失效。
### 混淆原理
微信在生成无障碍节点时,用 Map 缓存了页面节点信息,混淆时**从 Map 中随机抽取其他节点的信息填入当前节点**。每次页面刷新混淆结果都不同,所以:
- 基于固定 `resource-id``findAccessibilityNodeInfosByViewId` 失效
- 基于文本匹配的 `findAccessibilityNodeInfosByText` 失效
- 基于顺序假设的扁平数组解析失效
- `className` 也被随机化(EditText/TextView 被换成动态生成类名)
### 白名单机制
微信维护了一份系统服务白名单,对白名单内的服务**不下发混淆**。TalkBack、SelectToSpeak 等系统级无障碍服务在白名单中。判断依据是服务的 `ComponentName`(即 `包名/类名`)。
> 这是整个绕过方案的支点:只要让微信认为我们的服务是系统 SelectToSpeak,就能拿到未混淆的真实节点。
---
## 2. 绕过方案:伪装 ComponentName
### 关键认知:只伪装类名不够
早期实现(本仓库 commit `bf04400` 之前)只把类名伪装成 `SelectToSpeakService`,但 Manifest 服务名写的是 `${appId}.accessibility.SelectToSpeakService`(应用包名 + SelectToSpeak 类名)。**包名没伪装**,所以微信仍将其识别为第三方服务,混淆照常下发。这是「伪装做了但没生效」的典型坑。
### 正确做法:包名 + 类名全限定伪装
微信按 `ComponentName` 全字符串匹配白名单,因此:
| 字段 | 伪装值 |
|---|---|
| Manifest `android:name` | `com.google.android.accessibility.selecttospeak.SelectToSpeakService` |
| Kotlin `package` | `com.google.android.accessibility.selecttospeak` |
| Kotlin `class` | `SelectToSpeakService` |
| 文件物理路径 | `app/src/main/java/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt` |
四者必须严格一致。Android 编译期要求 Manifest 全限定名必须有匹配 `package` + `class` 的真实 Kotlin 源码,否则 `ClassNotFoundException`
### 为什么选 SelectToSpeak 而不是 TalkBack
早期业界方案伪装的是 `com.google.android.marvin.talkback.TalkBackService`,能成功绕过混淆,但**小米机型会误判 TalkBack 已开启**,屏幕持续显示「TalkBack 已激活」文字,严重干扰用户。SelectToSpeak(随选朗读)没有这个副作用,是更安全的选择。
---
## 3. Config Plugin 落地(`plugins/accessibility/app.plugin.js`
伪装不是手动改生成的 `android/` 文件(那会被 `expo prebuild --clean` 冲掉),而是通过 Config Plugin 在 prebuild 时自动完成。
### 三个核心常量
```js
const FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
const FAKE_SERVICE_NAME = `${FAKE_PACKAGE}.SelectToSpeakService`;
const FAKE_TILE_SERVICE_NAME = `${FAKE_PACKAGE}.OcrTileService`;
```
### 三个配合改动的环节
**① 文件复制路径**`withDangerousMod`
8 个 kt 文件统一复制到 `app/src/main/java/com/google/android/accessibility/selecttospeak/`,而不是应用包名目录。
**② package 重写**(正则替换)
kt 源码里仍写 `package com.beancount.mobile.accessibility`(源码锚点),Config Plugin 用正则替换为 FAKE_PACKAGE
```js
content = content.replace(/package\s+com\.beancount\.mobile\.accessibility/g, `package ${FAKE_PACKAGE}`);
```
⚠️ **关键坑**:正则必须精确匹配到 `.accessibility` 后缀。如果只匹配 `com.beancount.mobile`,替换结果会变成 `com.google.android.accessibility.selecttospeak.accessibility`(多出一段),与 Manifest 不一致 → 编译失败。详见 §5 坑 1。
**③ Manifest 服务名 + MainApplication import**`withAndroidManifest` / `withMainApplication`
- Manifest 服务 `android:name``FAKE_SERVICE_NAME` / `FAKE_TILE_SERVICE_NAME`
- MainApplication 里 `import com.google.android.accessibility.selecttospeak.AccessibilityBridgePackage`(注意 import 路径也要用 FAKE_PACKAGE,因为 BridgePackage 跟随其他文件一起伪装了)
### 「全部跟随伪装」 vs 「仅 Service 伪装」
本仓库 8 个 kt 文件(`SelectToSpeakService` / `AccessibilityBridgeModule` / `AccessibilityBridgePackage` / `FloatingHelper` / `FloatingBillView` / `FloatingTip` / `FloatingUiConfigStore` / `OcrTileService`)原本共享同一个 package,彼此通过同 package 直接引用(无显式 import)。
决策:**全部跟随伪装**到 FAKE_PACKAGE。优点是维持同 package 直接引用的简洁结构,零跨包 import 改动;代价是 `AccessibilityBridgeModule` / `OcrTileService` 这些不暴露给微信的组件也披上了系统服务包名(功能上无影响,它们靠 RN Bridge 和 QS Tile 识别,与微信无关)。
> 替代方案是只伪装 `SelectToSpeakService`,其余保持在应用包名,但要给 `FloatingHelper` / `OcrTileService` / `AccessibilityBridgeModule` 加跨包 import,改动点和潜在编译错误更多。
---
## 4. 数据流与触发路径
完整链路(手动识别路径,伪装生效后是主路径):
```
[微信账单详情页前台]
→ onAccessibilityEvent (SelectToSpeakService.kt)
→ 记录 topPackage/topActivity + 显示悬浮球 (FloatingHelper)
[用户点悬浮球「识别账单」]
→ triggerManualExtraction() (SelectToSpeakService.kt)
→ dumpAllTexts() ← 关键:覆盖所有窗口含 WebView 子窗口
→ rootInActiveWindow + dumpNodeTexts (递归 text + contentDescription)
→ 若为空,遍历 windows 兜底
→ emit "billingDebugNodes" {package, activity, signature, isManual:true, texts[]}
[JS 侧 _layout.tsx 监听 billingDebugNodes]
→ 仅处理 isManual=true 的消息
→ parseAndProcessAccessibilityTexts(texts, pkg) (automationPipeline.ts)
→ parseAccessibilityTexts → parseWechatTexts (accessibilityParser.ts)
→ 特征词「交易单号」/「退款单号」/「本服务由财付通提供」定位
→ 金额正则 ^([+-])?(\d+(\.\d{1,2})?)$ + 商户取金额前一节点
→ 命中 → handleIncomingBillEvent → 分类 + 规则匹配 → 生成 draft
→ App 前台:Alert 确认 / App 后台:showFloatingBill 原生浮窗
```
降级路径(兜底,伪装失败时):
```
[文本为空或解析未命中] → bridge.triggerManualOcr()
→ takeScreenshot → bitmapToBase64 (JPEG q60) → emit "billingScreenshot"
[JS 侧] → OcrProcessor (Layer1 规则 → Layer2 ONNX OCR → Layer3 AI 视觉)
```
### 关键改造:`triggerManualExtraction` 必须用 `dumpAllTexts`
改造前 `triggerManualExtraction` 只读 `rootInActiveWindow`,漏掉 WebView 子窗口。伪装生效后这条路径成为主识别路径,必须保证完整性,所以改用现成的 `dumpAllTexts()`(先试 `rootInActiveWindow`,为空则遍历 `windows`)。
---
## 5. 踩过的坑
### 坑 1:package 重写正则不精确,多出 `.accessibility`
**现象**:首次 prebuild 后,kt 文件 package 声明变成 `com.google.android.accessibility.selecttospeak.accessibility`(多了 `.accessibility`),与 Manifest 的 `com.google.android.accessibility.selecttospeak.SelectToSpeakService` 不一致 → 编译期 `ClassNotFoundException`
**根因**:源码 package 是 `com.beancount.mobile.accessibility`(有 `.accessibility` 后缀),但正则只写了 `com\.beancount\.mobile`,替换后保留了原有的 `.accessibility`
**修复**:正则精确匹配到 `.accessibility`
```js
// ❌ 错误:会留下 .accessibility 后缀
content.replace(/package\s+com\.beancount\.mobile/g, `package ${FAKE_PACKAGE}`)
// ✅ 正确:整体替换
content.replace(/package\s+com\.beancount\.mobile\.accessibility/g, `package ${FAKE_PACKAGE}`)
```
**验证方法**prebuild 后用 `head -1` 检查生成的 kt 文件 package 声明,与 Manifest `android:name` 的包名部分逐字对比。
### 坑 2:只伪装类名不伪装包名
早期实现 Manifest 服务名是 `${appId}.accessibility.SelectToSpeakService`,类名伪装了但包名是应用包名。微信按完整 `ComponentName` 判断,包名不在白名单 → 混淆照常下发。详见 §2。
### 坑 3:升级后服务标识变化,用户需重新启用
伪装改变服务的 `ComponentName`,系统无障碍设置里的服务标识随之变化。升级 APK 后,用户原本启用的服务会「失效」(实际是新服务没启用),需要重新在系统设置里启用「浮记-账单识别」。服务 `label` 保持 `浮记-账单识别` 不变(在 Manifest 硬编码),用户可识别。
> 这是预期行为,发布说明里需告知。
---
## 6. 验证方法论
代码改动后,按以下顺序验证(沙箱内能做的 vs 真机才能做的分开):
### 沙箱内(prebuild 后静态检查)
1. **kt 文件路径**`find android/app/src/main/java/com/google/android/accessibility/selecttospeak -type f` 应列出 8 个 kt 文件,且**不应有**应用包名路径下的遗留 kt。
2. **package 声明一致性**`head -1` 每个 kt 文件,都应是 `package com.google.android.accessibility.selecttospeak`
3. **Manifest 服务名**`grep "android:name=\"com.google.android.accessibility" android/app/src/main/AndroidManifest.xml` 应同时命中 `SelectToSpeakService``OcrTileService`,且包名部分与 kt 的 package 声明逐字一致。
4. **MainApplication import**`grep "AccessibilityBridgePackage" android/app/src/main/java/*/MainApplication.kt` 应有 `import com.google.android.accessibility.selecttospeak.AccessibilityBridgePackage``add(AccessibilityBridgePackage())`
5. **typecheck + 测试**`npm run typecheck` 通过;`npm test` 无新增回归(无障碍改动不涉及 ts,测试应零影响)。
### 真机(功能验证)
1. `npm run android` 构建 APK 安装。
2. 系统设置 → 无障碍 → 启用「浮记-账单识别」。
3. 打开微信(≥ 8.0.52)→ 账单详情页。
4. 点悬浮球「识别账单」。
5. 看 Metro 终端的 `billingDebugNodes` 事件:
- **成功**`texts` 数组含真实金额/商户/时间 → `parseWechatTexts` 自动解析入账。
- **失败**`texts` 仍为空或乱码 → 自动降级 OCR 截图(兜底链路未动)。
6. 若伪装完全无效(texts 始终空),说明微信检测点不止 ComponentName(可能还查签名/其他特征),需进一步研究;此时 OCR 兜底仍在工作,不影响基础功能。
---
## 7. 合规与风险
- **侧载路线**:本仓库按 `plan.md` 决策 3 走纯开源侧载,伪装机制仅用于非应用商店分发。伪装系统服务包名、绕过微信反自动化检测,可能违反微信用户协议,存在账号风险。
- **TalkBack 副作用**:不要回退到伪装 TalkBack 的方案,小米机型会有持续文字干扰(§2)。
- **版本适配**:微信持续更新混淆策略。若某次微信升级后伪装失效,先查 AutoJs6 Issue、CSDN/掘金的最新讨论,确认白名单判断逻辑是否变化。
- **回退**:伪装失败时,OCR 截图兜底链路(`_layout.tsx``billingScreenshot` 监听 + `OcrProcessor`)完整保留,最小代价回退。
---
## 8. 关键文件清单
| 文件 | 作用 |
|---|---|
| `plugins/accessibility/app.plugin.js` | Config Plugin:文件复制、package rewrite、Manifest 注册、MainApplication import |
| `plugins/accessibility/android/SelectToSpeakService.kt` | 主服务:事件监听、`dumpAllTexts`/`dumpNodeTexts` 节点遍历、截图、悬浮球管理 |
| `plugins/accessibility/android/AccessibilityBridgeModule.kt` | RN Bridge14 个 `@ReactMethod`,委托 `SelectToSpeakService.instance` |
| `plugins/accessibility/android/FloatingHelper.kt` | 悬浮球 UI,点「识别账单」调 `triggerManualExtraction` |
| `plugins/accessibility/android/FloatingBillView.kt` | 账单确认浮窗 |
| `plugins/accessibility/android/OcrTileService.kt` | 快速设置磁贴 |
| `plugins/accessibility/android/res/xml/accessibility_service_config.xml` | 服务能力声明(`canRetrieveWindowContent` / `canTakeScreenshot` / `canRequestEnhancedWebAccessibility` |
| `src/services/automation/accessibilityParser.ts` | 微信/支付宝/银行文本解析(`parseWechatTexts` 等) |
| `src/services/automation/automationPipeline.ts` | 管道协调器(`parseAndProcessAccessibilityTexts` |
| `src/app/_layout.tsx` | `DeviceEventEmitter` 监听 `billingDebugNodes` / `billingScreenshot` |
---
## 9. 后续优化方向(未实施)
- **方案 C`parseWechatTexts` 鲁棒性增强**。即使伪装生效,微信仍可能对部分节点做轻度扰动。可放弃「标签 + 下一节点」的顺序假设,改用 `getBoundsInScreen()` 的坐标和相对位置识别元素,或用 index 在层级中的位置而非属性。工作量大,建议伪装方案稳定后再立项。
- **自动监听路径恢复**。当前因节点混淆,自动监听(`processContentChange`)实质失效,仅手动悬浮球路径有效。伪装生效后可评估是否恢复自动监听,但需注意 `isManual` 标志和页面签名白名单的配合。
+370
View File
@@ -0,0 +1,370 @@
# Android 安装包打包与体积优化指南 (Android Build Guide)
在引入机器学习引擎(ONNX Runtime)及 React Native 新架构后,本项目的原生 C/C++ SO 库体积大幅增加。为避免将无意义的冗余架构包打包给最终手机用户,本项目对 Android 构建配置进行了专项体积优化。
本篇指南将为您介绍项目中的打包机制、优化细节,以及如何通过命令行进行差异化编译。
---
## 0. TL;DR — 改了原生代码后的完整重建流程
> 当你修改了 `plugins/*/android/` 下的任何 Kotlin/Java 原生源码、或 `app.plugin.js` 注入逻辑后,`android/` 目录里那份由 prebuild 生成的原生工程**已经过时**,必须重新生成才能让改动生效。这是最常见的「我改了代码但安装后没变化」的根因。
完整的三步重建命令(Git Bash,工作目录为项目根):
```bash
# 环境变量(每次新开 shell 都要设;可写入 ~/.bashrc 持久化)
export JAVA_HOME="/c/Program Files/Java/jdk-21"
export ANDROID_HOME="/c/Users/fmq/AppData/Local/Android/Sdk"
export PATH="$ANDROID_HOME/platform-tools:$PATH" # 让 adb 可用
```
> [!TIP]
> **不想每次 export `ANDROID_HOME`** 把 SDK 路径写进 `android/local.properties``sdk.dir=C\:\\Users\\fmq\\AppData\\Local\\Android\\Sdk`),gradle 会优先读它,前台/后台 shell 都不再依赖环境变量。该文件在 `.gitignore` 内、不进仓库。**后台/自动化编译尤其需要它**——后台新 shell 不继承交互会话里的 `ANDROID_HOME`,缺 `local.properties` 会直接 `SDK location not found` 失败(详见 §7.6)。
```bash
# ① 删除旧的原生工程,强制重新生成(确保原生改动被注入)
rm -rf android
npx expo prebuild --platform android
# ② 清理 Gradle 缓存后编译 Release(默认 arm64-v8a,真机最快)
cd android
./gradlew.bat clean --offline
./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --offline
# ③ 通过 adb 覆盖安装到已连接的真机
adb install -r -d app/build/outputs/apk/release/app-arm64-v8a-release.apk
```
> **何时需要重新 prebuild**
> - 改了 `plugins/*/android/*.kt`(任何 Config Plugin 的原生源码)
> - 改了 `plugins/*/app.plugin.js`(注入逻辑)
> - 改了 `app.json`appId、权限、插件配置)
> - 在新机器上首次拉取代码
>
> **何时只需直接编译(无需 prebuild)?**
> - 只改了 `src/` 下的 TS/TSXJS Bundle 由 Metro/assemble 自动打入)
> - 只改了 `android/app/build.gradle`、`gradle.properties` 等已生成的 Gradle 配置
>
> [!WARNING]
> **改了 TS 后,release 包偶尔不会更新**(见 §7):gradle 的 `createBundleReleaseJsAndAssets` 可能因为缓存跳过重新打包,或 Metro 用了 transformer cache。若发现"改了代码但设备上行为没变"**先按 §7.2 验证 bundle 是否真的含新代码**,而不是反复改代码。
---
## 1. 体积优化核心原理
### 1.1 ABI 分包 (ABI Splits)
在 Android 系统的 `build.gradle` 中,我们启用了分包编译:
```groovy
splits {
abi {
enable true
reset()
include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
universalApk true
}
}
```
* **效果**:系统会为每种 CPU 架构单独输出一个体积小巧的 APK,并额外保留一个兼容所有架构的通用包(Universal APK)。
* **独立包大小**~35MB - ~40MB
* **通用包大小**~140MB - ~170MB
### 1.2 默认编译架构限制
我们在 `android/gradle.properties` 中指定了默认编译目标为:
```properties
reactNativeArchitectures=arm64-v8a
```
* **为什么只包含 arm64-v8a**:目前 99% 的主流现代 Android 实体机都是 64 位 ARM 架构(`arm64-v8a`)。在进行日常分发与本地 Release 测试时,默认只编译 arm64-v8a,能够省去编译另外 3 个架构的机器指令时间,**编译速度提升 3~4 倍**,且输出的包体最小。
> [!NOTE]
> **`-PreactNativeArchitectures` 与 ABI Splits 的关系**:当启用了 §1.1 的 `splits.abi` 后,`assembleRelease` 会**无条件生成所有 `include` 列出的架构分包 + universal 包**`-PreactNativeArchitectures` 参数只能限制"编译哪几个架构的原生库",并不能减少最终输出的 APK 数量。若想只产出一个 arm64 包、跳过其它架构的编译耗时,最干净的做法是**注释掉 `app/build.gradle` 里的 `splits { abi { ... } }` 块**,让 `reactNativeArchitectures=arm64-v8a` 单独生效。
### 1.3 Expo 持续原生生成 (Config Plugin) 的自动应用
> [!IMPORTANT]
> 由于 `android/` 目录被 Git 忽略(见 `.gitignore`),**不要直接手动修改 `android/` 下的文件并提交**——它们是 prebuild 生成的产物,会在下次重建时丢失。
> 本项目已编写了专门的本地 Config Plugin[size-optimization](file:///c:/Users/fmq/Documents/work/DriftLedger/plugins/size-optimization/app.plugin.js)。
> 当在新设备上重新 `git clone` 项目后,运行以下指令即可自动拉起所有 Config Plugin 并在重新生成的 `android/` 目录中完美注入上述所有的体积优化配置(ABI 分包、默认单架构编译、NDK 版本统一):
> ```bash
> npx expo prebuild --platform android
> ```
---
## 2. 编译命令与打包指令
请在项目的根目录(若已在 `android/` 目录中则不需要前缀 `cd android`)执行以下指令:
### 2.1 本地测试/实体分发(仅编译 arm64-v8a,最快最推荐)
直接运行默认编译,会使用 `gradle.properties` 中配置的 `arm64-v8a`
**Git Bash(推荐,与本文档 §0 的 TL;DR 一致):**
```bash
export JAVA_HOME="/c/Program Files/Java/jdk-21"
export ANDROID_HOME="/c/Users/fmq/AppData/Local/Android/Sdk"
cd android
./gradlew.bat :app:assembleRelease --offline
```
**PowerShell**
```powershell
$env:JAVA_HOME="C:\Program Files\Java\jdk-21"
$env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk"
cd android
.\gradlew.bat :app:assembleRelease --offline --no-daemon
```
编译完成后,可在以下路径找到适合真机安装的轻量版 APK(开启混淆后约 **24MB**):
* `android\app\build\outputs\apk\release\app-arm64-v8a-release.apk`
---
### 2.2 全量分包发布(适合多设备兼容测试/全网发布)
若需要同时生成适配所有手机架构的独立 APK 以及一个通用包,可以在命令行中通过参数**覆盖默认架构**:
```powershell
$env:JAVA_HOME="C:\Program Files\Java\jdk-21"
$env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk"
cd android
.\gradlew.bat :app:assembleRelease -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 --offline --no-daemon
```
编译完成后,在输出目录会产生 5 个文件:
1. `app-arm64-v8a-release.apk` (推荐绝大多数真机,约 37MB)
2. `app-armeabi-v7a-release.apk` (适合极少数老旧真机)
3. `app-x86-release.apk` (适合 32 位模拟器)
4. `app-x86_64-release.apk` (适合 64 位模拟器)
5. `app-universal-release.apk` (包含上述全部架构的通用胖包,约 140MB)
---
### 2.3 模拟器专用打包 (x86_64)
若需要直接打包在 Windows/macOS 的 x86_64 原生安卓模拟器上测试:
```powershell
.\gradlew.bat :app:assembleRelease -PreactNativeArchitectures=x86_64 --offline --no-daemon
```
即可秒级编译出专门针对模拟器的 `app-x86_64-release.apk`
---
## 3. Proguard/R8 混淆(已默认开启)
本项目的 `gradle.properties` 已**默认启用**代码混淆与资源压缩:
```properties
android.enableMinifyInReleaseBuilds=true
android.enableShrinkResourcesInReleaseBuilds=true
```
这使得 arm64-v8a 独立包从 ~37MB 压缩到约 **24MB**。无需手动开启。
> [!WARNING]
> 若新增了依赖反射/动态加载的库(如 `ONNX Runtime` 的 JNI 调用),混淆后可能出现运行时 `ClassNotFoundException`。此时需在 `android/app/proguard-rules.pro`(由 ppocr 等 Config Plugin 注入)中补充保留规则,例如:
> ```proguard
> -keep class com.microsoft.onnxruntime.** { *; }
> -dontwarn com.microsoft.onnxruntime.**
> ```
> 调试混淆问题时,可临时把上述两个属性改为 `false` 排查是否为混淆所致。
---
## 4. 通过 adb 安装到真机
编译产物就绪后,用 adb 覆盖安装到已连接的设备(保留应用数据):
```bash
# 确认设备已连接(USB 调试已开启)
adb devices
# 覆盖安装:-r 保留数据,-d 允许版本号不升(覆盖安装相同/更低 versionCode 时需要)
adb install -r -d android/app/build/outputs/apk/release/app-arm64-v8a-release.apk
```
常见问题:
| 现象 | 原因与解决 |
|---|---|
| `adb: command not found` | adb 不在 PATH。Git Bash 下执行 `export PATH="/c/Users/fmq/AppData/Local/Android/Sdk/platform-tools:$PATH"` |
| `device offline` / 列表为空 | 手机未授权 USB 调试,或驱动未装;重新插拔并在手机弹窗点「允许」 |
| `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | 签名不一致(如之前装的是 debug 版)。先 `adb uninstall com.example.driftledger` 再装 |
| 装完打开白屏/闪退 | 多为混淆误删(见 §3)或原生库架构不匹配(模拟器需 x86_64 包) |
---
## 5. 原生插件(Config Plugin)开发避坑指南
本项目通过 7 个 Expo Config Plugin`plugins/*/app.plugin.js`)在 prebuild 时注入原生代码与配置。以下是踩过的坑:
### 5.1 跨插件包名一致性陷阱(无障碍伪装包)
**背景**:为绕过微信 8.0.52+ 的节点混淆,`accessibility` 插件把 7 个 Kotlin 文件整体迁移到伪装包 `com.google.android.accessibility.selecttospeak`(伪装成系统「随说随读」服务),并在注入时用正则把源码里的 `com.beancount.mobile.accessibility` 改写成这个伪装包名。
**陷阱**`notification-listener` / `screenshot-monitor` / `sms-receiver` 这三个插件的 Kotlin 源码**也 import 了** `com.beancount.mobile.accessibility.{SelectToSpeakService, ReactContextHolder}`。它们各自的 `app.plugin.js` 有一条「通用包名替换」规则:
```js
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
```
这条规则会**无差别**地把 `com.beancount.mobile.accessibility` 也替换成 `appId.accessibility`,导致引用指向一个不存在的包,编译时报:
```
e: ... BillingNotificationListenerService.kt: Unresolved reference 'accessibility'
e: ... BillingNotificationListenerService.kt: Unresolved reference 'SelectToSpeakService'
```
**修复**(已在三个插件中落地):在通用替换**之前**,先把 accessibility 子包引用单独改写到伪装包:
```js
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
// 必须先改写 accessibility 子包引用,再做通用 com.beancount.mobile → appId 替换
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
```
> **教训**:任何插件如果要用正则做包名改写,必须**先处理跨插件共享的子包引用**(尤其是被「伪装/重命名」过的包),再做通配替换,否则通用规则会破坏跨插件依赖。
### 5.2 验证 prebuild 注入是否成功
prebuild 不会因「源码与注入结果不一致」而报错(它只是文件复制 + 字符串替换),所以注入错误只能在 gradle 编译时暴露。快速自查注入结果:
```bash
# 检查目标包名/类是否被注入到预期路径
find android/app/src/main/java -iname "*SelectToSpeak*" -o -iname "*ReactContext*"
# 对比源文件与注入文件的差异(应仅 package 声明行不同)
diff plugins/accessibility/android/SelectToSpeakService.kt \
android/app/src/main/java/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt
```
若编译报 `Unresolved reference`,先用上述命令确认类是否被注入、package 是否正确改写。
### 5.3 编译失败排查清单
| 错误特征 | 可能原因 | 排查 |
|---|---|---|
| `Unresolved reference 'XXX'` | 跨插件 import 包名改写不一致(见 §5.1) | 检查注入后文件的 `import` 行 |
| `Cannot resolve symbol` / 找不到 R 资源 | res/xml 未随 kt 一起注入 | 检查 `app/src/main/res/xml/` 是否有配置文件 |
| 改了 kt 但安装后行为没变 | 忘记重新 prebuildandroid/ 是旧的) | `rm -rf android && npx expo prebuild` |
| `Execution failed ... mergeReleaseResources` | 资源 ID 冲突 / strings.xml 重复注入 | 检查插件是否做了幂等判断(`if (!content.includes(...))` |
| `Argument type mismatch: 'Float', but 'Double' was expected`(多在 `Math.ceil`/`Math.floor` 处) | `java.lang.Math.ceil`/`floor` **只有 double 重载**Kotlin 传 Float 不会自动提升 | 改 `x.toDouble()`。注意 `Math.round` 同时有 float/double 两个重载,所以 `Math.round(Float)` 不报错——别误以为 `ceil` 也能直接传 Float |
### 5.4 只改单个原生源文件时的快速同步(免全量 prebuild)
§0 的全量重建要 `rm -rf android && prebuild`,慢且扰动整个原生工程。当**只改了某个 `plugins/<x>/android/*.kt` 的内容**(没改 `app.plugin.js` 注入逻辑、没新增/删除文件、没改 assets、没改 app.json)时,可手动把改后的源同步到 android 副本,省去全量 prebuild。Config Plugin 在 prebuild 时对 .kt 做的事 = 复制 + 把 `package`/`import` 里的 `com.beancount.mobile` 替换成 appId,手动等价如下(appId 以 `app.json``android.package` 为准,本项目为 `com.example.driftledger`):
```bash
# 例:只改了 plugins/ppocr/android/OcrModule.kt
python -c "
s=open('plugins/ppocr/android/OcrModule.kt',encoding='utf-8').read()
s=s.replace('com.beancount.mobile','com.example.driftledger')
open('android/app/src/main/java/com/example/driftledger/ppocr/OcrModule.kt','w',encoding='utf-8',newline='\n').write(s)
"
# 然后直接编译(无需 prebuild;改的是 .ktMetro bundle 不受影响)
cd android && ./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --offline --no-daemon
```
> [!WARNING]
> 这条捷径**只对「改 .kt 内容」成立**。以下情况**必须**走 §0 全量 prebuild,否则 android/ 副本与注入结果不一致:改了 `app.plugin.js` 注入/复制逻辑;新增或删除 `.kt`/资源文件(手动同步不会更新 `MainApplication` 的 `add(...)` 注入或 res 复制);改了 `assets/`(模型/字典)或 `app.json`。同步后务必 `grep` 副本确认 `package` 行已是 appId、且无残留 `com.beancount.mobile`。
---
## 6. 常用速查
| 目标 | 命令 |
|---|---|
| 改了 TS 后热更新(无需重编) | `npm run start` → 手机摇一摇 Reload |
| 改了原生后重建并安装 | 见 §0 TL;DR 三步 |
| 只看编译是否通过(不出 APK) | `./gradlew.bat :app:compileReleaseKotlin --offline` |
| 查看连接的设备 | `adb devices -l` |
| 查看应用日志 | `adb logcat *:S ReactNativeJS:V ReactNative:V` |
| 卸载应用 | `adb uninstall com.example.driftledger` |
---
## 7. Release 构建陷阱(改了 TS 却没生效?看这里)
> 这是项目中最坑、最耗时的问题之一。症状:**改了 `src/` 下的 TS/TSX,编译成功,安装到手机,但行为毫无变化**——仿佛代码没改。这几乎总是 JS Bundle 缓存或 Windows 文件锁导致的。
### 7.1 根因分类
| 根因 | 机制 | 表现 |
|---|---|---|
| **gradle bundle task 缓存** | `:app:createBundleReleaseJsAndAssets` 基于输入快照判定 UP-TO-DATE,即使删了 bundle 输出文件,gradle 的 task snapshot 仍认为"最新",跳过打包 | `./gradlew :app:createBundleReleaseJsAndAssets` 显示 `UP-TO-DATE` |
| **Metro transformer cache** | Metro 对每个源文件缓存编译结果,命中就用旧版。Windows 下缓存在 `%LOCALAPPDATA%/Temp/metro-cache``metro-file-map-*` | bundle 时间戳更新了,但内容不含新代码 |
| **Windows 文件锁** | apk/打包中间产物被 adb、杀毒软件、Explorer 预览占用,gradle 无法写入/删除 | `packageRelease FAILED` / `externalNativeBuildCleanRelease FAILED` / "另一个程序正在使用此文件" |
| **设备跑旧 APK** | `adb install -r` 时 USB 断开/授权失效,实际没装上 | `adb: no devices``Success` 但应用没更新 |
### 7.2 验证 bundle 是否含新代码(关键!)
**遇到"改了没生效",第一步永远是验证 bundle,而不是反复改代码。** 项目用 Hermes 字节码,bundle 是二进制。
```bash
BUNDLE="android/app/build/generated/assets/createBundleReleaseJsAndAssets/index.android.bundle"
# ⚠️ 必须用 grep -a(强制文本模式),因为 Hermes bundle 是二进制,
# 默认 grep 会跳过二进制文件,导致永远匹配 0 → 误判"代码没进去"
grep -a -c "你新加的字符串常量" "$BUNDLE"
# 例:grep -a -c "keyboardDidShow" "$BUNDLE" → 应 ≥1
```
> [!IMPORTANT]
> **绝对不要用 `grep -c "..."`(不带 -a)验证 Hermes bundle。** 字符串常量(如事件名 `'keyboardDidShow'`、组件名)在字节码里是明文存的,minify 不会改变,用 `grep -a` 能可靠检测。本项目曾因误用 `grep`(不加 -a)反复重编 5 次,浪费大量时间,根因竟是验证方法错了。
如果 `grep -a` 确认 bundle 含新代码但设备行为没变 → 是「设备跑旧 APK」问题(重装/清数据)。
如果 bundle **不含**新代码 → 是「gradle/Metro 缓存」问题,按 §7.3 处理。
### 7.3 强制 bundle 重新生成的可靠步骤
按顺序尝试,通常第 1 步即可:
```bash
cd "/c/Users/fmq/Documents/work/DriftLedger"
# ① 删除 bundle 输出 + sourcemap,让 gradle 的 bundle task 不再 UP-TO-DATE
rm -f android/app/build/generated/assets/createBundleReleaseJsAndAssets/index.android.bundle
rm -f android/app/build/generated/sourcemaps/react/release/index.android.bundle.map
# ② 若 ① 无效(task 仍 UP-TO-DATE):手动删除整个 app/build(绕过 gradle clean
# 因为 clean 常因 CMake/文件锁失败而中断,反而没清掉 bundle)
rm -rf android/app/build
# ③ 若仍无效(bundle 生成但内容旧):清 Metro 缓存(Windows 多处)
rm -rf node_modules/.cache .expo
rm -rf "$LOCALAPPDATA/Temp/metro-cache" "$LOCALAPPDATA/Temp/metro-file-map-"*
rm -rf "$LOCALAPPDATA/Temp/1/metro-cache" "$LOCALAPPDATA/Temp/1/metro-file-map-"*
# ④ 重新编译(带 --no-daemon 可规避 daemon 缓存与锁)
cd android
export JAVA_HOME="/c/Program Files/Java/jdk-21"
./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --offline --no-daemon
```
验证打包成功后 bundle 是否更新(§7.2),再安装。
### 7.4 Windows 文件锁处理
`packageRelease FAILED``clean FAILED`("另一个程序正在使用此文件")时:
```bash
# 杀掉占用进程(adb/Explorer 预览/杀毒扫描最常见)
taskkill //F //IM adb.exe
taskkill //F //IM java.exe
sleep 2
# 重试对应的失败 task(不必全量重编)
./gradlew.bat :app:packageRelease -PreactNativeArchitectures=arm64-v8a --offline
```
> [!TIP]
> - **不要用 `gradlew clean`**:它依赖 `externalNativeBuildCleanRelease`,该 task 在本项目(含原生 CMake 库)极易因文件锁失败,导致 clean 中断、bundle 也没清掉。改用 `rm -rf app/build` 更可靠。
> - **不要并发跑多个 gradle 进程**:会互相锁文件。编译前 `taskkill //F //IM java.exe` 清理。
> - **`adb install` 前确认设备在线**`adb devices -l`,列表为空说明 USB 断开或授权失效,`install` 会失败或装到错误的设备。
### 7.5 排查决策树
```
改了 TS,编译安装后行为没变
├─ grep -a 验证 bundle 含新代码?(§7.2)
│ ├─ 不含 → gradle/Metro 缓存 → §7.3 强制重生成
│ └─ 含 → 设备跑的是旧 APK
│ ├─ adb devices 确认在线 → adb install -r -d 重装
│ └─ 仍不行 → 卸载重装:adb uninstall com.example.driftledger && adb install <apk>
```
### 7.6 后台 / 自动化编译陷阱
在 CI、IDE 后台任务、或 agent 的后台 shell 里跑 gradle 时,有几个交互会话遇不到的坑:
| 陷阱 | 现象 | 解决 |
|---|---|---|
| **后台 shell 不继承 `ANDROID_HOME`** | `Failed to apply plugin 'com.facebook.react.rootproject'``SDK location not found ... local.properties` | 写 `android/local.properties``sdk.dir`(见 §0 TIP),一劳永逸,不依赖 env |
| **命令接 `\| tail`/`\| head` 管道掩盖退出码** | gradle 实际 `BUILD FAILED`,但管道让 shell 退出码 = `tail` 的 0,误判成功;APK 时间戳其实是旧的 | 后台编译**不要接管道**,让退出码真实反映 gradle;判断成败靠读日志的 `BUILD SUCCESSFUL/FAILED` + 核对 APK 时间戳,而非 `$?` |
| **Git Bash 下 `grep -E` 报 `conflicting matchers specified`** | 用 `-E` 组合多模式直接报错退出 | 该环境 grep 别名冲突;改用 `grep -e A -e B``sed -n` 或多次 `grep` 串联 |
| **`adb: command not found`** | 后台/新 shell 的 PATH 没有 platform-tools | 用全路径 `$ANDROID_HOME/platform-tools/adb.exe`,或 `export PATH=...` |
> **验证后台编译是否真成功的三重核对**:① stdout 含 `BUILD SUCCESSFUL`;② stderr 无 `^e: ` 开头的 Kotlin 编译错误(编译错误走 stderrstdout 往往只有 `> Task :app:compileReleaseKotlin FAILED`);③ APK 文件时间戳晚于本次编译开始时间。三者缺一即视为失败——尤其别被 `| tail` 的假成功骗到。
+116
View File
@@ -0,0 +1,116 @@
# 系统架构
## 核心不变量
`.bean` 文件是唯一事实来源;SQLite 仅为可丢弃的读缓存。
## 数据流
```mermaid
flowchart LR
subgraph DataSource[数据源]
A[".bean 文件"]
B["OCR / 无障碍 / 通知 / 短信"]
C[“手动录入 / CSV 导入"]
end
subgraph Pipeline[处理层]
D["BillPipeline 串行互斥锁"]
E[“转账识别"]
F[“去重"]
G[“规则分类"]
end
subgraph Storage[存储层]
H["main.bean 追加写入"]
I["SQLite 缓存"]
J["Zustand Store 内存索引"]
end
subgraph UI[展示层]
K["Expo Router 页面"]
L[“报表 / 图表"]
end
A -->|parseLedger| J
B -->|原始事件| D
C -->|TransactionDraft| D
D --> E --> F --> G
G -->|确认写入| H
H -->|reparse| J
J --> K
J --> L
I -.->|搜索查询| J
```
## 模块分层
```mermaid
graph TB
subgraph UILayer["UI 层: src/app + src/components"]
R[“Expo Router 页面"]
CO[“可复用组件"]
end
subgraph StateLayer["状态层: src/store"]
S[“Zustand Stores"]
end
subgraph DomainLayer["领域层: src/domain — 纯 TS 零 RN 依赖"]
LE["ledger.ts 解析器"]
P["billPipeline.ts"]
D2["dedup / transfer / rules"]
O["ocrProcessor.ts"]
end
subgraph Infra[“基础设施"]
FS["expo-file-system"]
DB["expo-sqlite"]
NAT["plugins/ 原生模块"]
end
R --> S
CO --> S
S --> LE
S --> P
P --> D2
P --> O
LE --> FS
D2 --> DB
O --> NAT
```
## BillPipeline 处理顺序
所有导入渠道(手动、CSV、OCR、无障碍、通知、短信、截图)统一经过 `BillPipeline`,严格按序执行:
1. **转账识别** — 配对收入+支出 → 单笔转账(必须先于去重)
2. **批内去重** — 时间窗口 + 金额 + 交易对手
3. **历史去重** — 按日期索引比对已提交交易
4. **规则分类** — 规则匹配 → 关键词 → `Uncategorized` 兆底
## 原生模块
原生功能以 Expo Config Plugin 形式封装在 `plugins/<name>/`,详见 [plugins/README.md](../plugins/README.md)。
原生服务**不直接写入**数据库或文件,而是通过 `NativeEventEmitter` 将原始事件推送到 JS 层管道。
## OCR 三层级联
```mermaid
flowchart LR
A[“图像输入"] --> B["Layer 1: 正则规则"]
B -->|未命中| C["Layer 2: 本地 OCR PP-OCRv6"]
C -->|未命中| D["Layer 3: AI Vision 付费"]
```
## 双轨映射
Beancount 无原生“分类/预算”概念,应用在本地 SQLite 维护 UI 元数据,写入时映射回 Beancount 语义:
| 概念 | 本地表 | 映射到 `.bean` |
|------|--------|----------------|
| 分类 | `categories` | `linkedAccount` → posting 账户 |
| 标签 | `tags` | narration 中的 `#tag` |
| 预算 | `budgets` | 仅本地,不影响余额 |
| 信用卡 | `credit_cards` | 账户在 `.bean`UI 字段本地 |
+183
View File
@@ -0,0 +1,183 @@
# Beancount Mobile 前端 UI 全面重设计 Spec
- 日期:2026-07-21
- 状态:已确认(经逐节评审)
- 范围:`src/app``src/components``src/theme``design-system/`**不动** `src/domain``src/storage``src/services``plugins/`
## 1. 背景与问题
对现有 UI 的摸底发现以下问题(按严重度):
1. **品牌色混乱**:代码主题 accent = 靛蓝 `#4F46E5``design-system/beancount-mobile/MASTER.md` 规定 CTA = 绿 `#059669`、背景深蓝 `#0F172A`(代码暗色实为 OLED 纯黑 `#040508`)。设计文档与实现脱节。
2. **图标体系三套并存**Ionicons(主力)+ emoji 分类图标(CategoryPicker+ MASTER.md 要求的 Lucide/Heroicons(未落地)。
3. **硬编码颜色绕过 token**:首页 Hero 卡片写死白字;`CATEGORY_COLORS`、标签默认色 `#2196F3`、渠道色 `#1677FF/#07C160/#E53935`
4. **样式重复爆炸**40+ 处 `StyleSheet.create``header` 样式在 ~15 个页面重复;8 个管理页(账户/分类/标签/预算/周期/规则/信用卡/备注模板)是同一"列表+FormModal"模式却各自实现。
5. **录入流程偏长**:方向 chip → 金额 → 账户 → 分类网格 → 折叠详情,不是金额优先;无 KeyboardAvoidingView;日期靠手输 `YYYY-MM-DD`
6. **信息架构问题**:设置页 13 入口平铺;报表页周/月/年三套独立状态;年报内嵌月报与月 Tab 重复;AI/导出图标无文字标签。
7. **Header 策略不统一**:二级页手写返回栏,唯独 transaction/new 用原生 header。
## 2. 已确认的关键决策
| 决策点 | 结论 |
|---|---|
| 重设计深度 | **全面重做**(所有页面) |
| 视觉方向 | **明亮 Bento 现代风**:浅色为主 + 大圆角黑白对比,财务语义色点缀 |
| 暗色模式 | **完整保留**OLED 纯黑,与浅色对等) |
| 记一笔交互 | **金额优先数字键盘面板**NumpadSheet |
| 底部导航 | **中央凸起 **,4 个内容 Tab(首页/交易/报表/我的) |
| 实施策略 | **设计系统先行,逐页替换**5 个阶段) |
## 3. 视觉语言(Design Tokens
### 3.1 色板
**浅色(默认)**
| Token | 值 | 用途 |
|---|---|---|
| bgPrimary | `#F6F7F9` | 页面背景 |
| bgSecondary | `#FFFFFF` | 卡片 |
| bgTertiary | `#EFF1F4` | 输入框/chip |
| fgPrimary | `#111318` | 主文字 |
| fgSecondary | `#6B7280` | 次要文字 |
| fgInverse | `#FFFFFF` | 反色文字 |
| accent | `#111318` | **近黑**,按钮/选中态 |
| accentLight / accentDark | 重定义为中性色阶:accentLight = accent 8% 透明度的底色(选中高亮),accentDark = accent 的按压加深态 | 高亮背景/按压态 |
| financial.income | `#10B981` | 收入 |
| financial.expense | `#EF4444` | 支出 |
| financial.transfer | `#3B82F6` | 转账 |
**暗色(OLED,完整对等)**
| Token | 值 |
|---|---|
| bgPrimary | `#040508` |
| bgSecondary | `#101218` |
| bgTertiary | `#1A1D24` |
| fgPrimary / accent | `#F3F4F6`accent 反转为白) |
| fgSecondary | `#9CA3AF` |
| financial.income | `#34D399`(提亮) |
| financial.expense | `#F87171`(提亮) |
| financial.transfer | `#60A5FA`(提亮) |
原则:**accent 从靛蓝改为近黑白单色**,品牌感靠排版与财务语义色表达;对比度 ≥ 4.5:1;暗色下用 1px 半透边框代替阴影。
### 3.2 字体 / 圆角 / 阴影 / 图标
- **移除 Caveat/Quicksand 自定义字体**,改系统字体(iOS SF / Android Roboto),消除字体加载失败风险;删除 `_layout.tsx` 字体加载代码。
- 金额数字统一 `fontVariant: ['tabular-nums']`
- 字阶:display 34 / h1 28 / h2 22 / h3 17 / body 16 / bodySmall 14 / caption 12。
- 圆角:sm 8 / md 12 / lg 16 / **xl 24(卡片默认)** / full。
- 阴影减重:浅色 2–12px 弥散阴影;暗色以边框代替。
- **图标统一 Ionicons**;全部 emoji 图标替换为 `CategoryIcon`Ionicon + 圆形彩色底)。
- 硬编码分类色收归 `theme.categoryPalette`(12 色循环);渠道色、标签默认色等一并 token 化。
- `design-system/beancount-mobile/MASTER.md` 重写以匹配实现,结束"两份宪法"。
## 4. 核心交互:NumpadSheet 记一笔面板
### 4.1 结构(一屏完成 90% 记账)
自上而下:方向 chip(支出/收入/转账) → 大金额显示(等宽数字) → **账户 chip 行** → 分类快捷网格(4 列,常用分类 + "全部") → 内嵌数字键盘(0-9、小数点、⌫、**+/- 连续计算**(如 20+15 直接出 35)、"今天"日期键、完成键)。下滑展开"更多"抽屉:备注、标签、日期、高级模式(PostingEditor)入口。原 SpeedDial 的 OCR/导入入口移入面板顶部工具行。
### 4.2 两个账户(复式记账的"两条腿")
| 方向 | 腿 1 | 腿 2 |
|---|---|---|
| 支出 | 资金来源账户(Assets/Liabilities,账户 chip 行,"从" | 分类账户(Expenses:*,分类网格) |
| 收入 | 分类账户(Income:*,分类网格) | 到账账户(Assets,账户 chip 行,"到" |
| 转账 | 转出账户(chip 行) | 转入账户(第二 chip 行 + ⇅ 互换按钮),分类网格隐藏 |
配套规则:
- 账户 chip 行显示最常用的 3~4 个 Assets/Liabilities 账户(按使用频率排序),**默认选中上次使用的账户**(settingsStore 持久化),分类同理;"⋯"弹出全部账户树。
- 转账双方限定 Assets/Liabilities 账户(与 transferRecognizer 校验一致);还信用卡 = 转账到 Liabilities 账户,天然支持。
- 落账走 `buildAndSaveTransaction()` → BillPipeline,与手动/自动渠道完全一致,不产生第二套写入逻辑。
- 无 Assets 账户时 chip 行显示"去创建账户"引导,不死锁。
- 完成键上方可选显示分录预览 `Assets:招行 → Expenses:餐饮 ¥35`(可在设置关闭)。
- 编辑模式按 posting 方向回填两条腿。
- P3 阶段先在设置加"新版录入"开关灰度,稳定后默认开启。
## 5. 导航
自定义 **AppTabBar**(替换 expo-router 默认 tabBar):4 个内容 Tab(首页/交易/报表/我的)+ 中央凸起 +。+ **不是路由**,在任何页面唤起全局 Modal(NumpadSheet),不丢失上下文。**SpeedDial 退役**。
## 6. 核心组件库(P2 交付物)
新增:
- **NumpadSheet** — 见第 4 节
- **AppTabBar** — 见第 5 节
- **ScreenHeader** — 统一二级页头(返回 + 标题 + 右操作位),替换十几份手写返回栏;transaction/new 的原生 header 一并撤掉
- **StatCard** — "标题 + 大数字 + caption"统计卡(首页/报表/年报共用)
- **ManagementScreen** — 管理页模板(ScreenHeader+ / 可选分组 Tab / FlatList / 删除确认 / FormModal),8 个管理页共用,各页只声明字段配置 + 数据读写 hooks
- **DatePickerField** — 日历选择器,终结手输 `YYYY-MM-DD`
- **FilterSheet** — 底部弹层高级筛选(账户/日期/金额区间)
- **CategoryIcon** — Ionicon + 彩色圆底,替换 emoji
改造/退役:Button/Card/Chip/SearchBar 按新 token 重刷;CategoryPicker 改底部弹层网格并 Ionicon 化;FormModal 的 "✕" 字符换 Ioniconstransaction/new 整页重写为 NumpadSheet 宿主(编辑模式复用同面板)。
## 7. 逐页重设计要点
### 7.1 首页:从"数据墙"到"今日视角"
- 顶部:日期 + 问候语(替代应用名标题)。
- 净资产卡:去 accent 底色改白卡 + tabular 大数字,下方一行小字:本月支出 / 收入 / 预算剩余。
- 新增**待办条**:周期记账到期、信用卡还款提醒、未确认自动账单——首页回答"今天我要做什么"。
- 最近 5 条交易 + "查看全部 →"。
- 月度趋势卡(TrendLine 重刷新色板)。
- 账户余额树下沉到「我的」页。
### 7.2 交易页:搜索优先 + 分组时间线
- 搜索框常驻;方向筛选 chip 保留;高级筛选收进 FilterSheet。
- 列表**按日期分组**(今天/昨天/具体日期),组头显示当日收支小计。
- TransactionCard 重刷:CategoryIcon 圆底图标 + 商户/备注 + 账户小字 + 右侧等宽金额(支出黑色、收入绿色带 + 号,降低色彩噪音)。
- 左滑卡片:快捷"再记一笔(复制)/ 删除"。
- 解析诊断从页面底部移入设置 → 数据组。
### 7.3 报表页:统一时间导航 + 去重
- 周/月/年 Tab 保留;三套独立状态(viewYear/viewMonth/viewWeekDate)合并为**单一 anchor 日期 + 周期类型**,左右箭头统一切换。
- 删除年报内嵌的 MonthlyReport(与月 Tab 重复);年报只留:年度收支汇总、月度节奏迷你图、Top 分类。
- AI 总结/导出入口加文字标签,收进 "⋯" 菜单。
- CalendarView 保留在月 Tab,配色收归 token;热力图用 expense 色 5 级透明度。
- CategoryPie / StatCard / NetWorthChart 统一新 token。
### 7.4 设置 →「我的」:4 分组重构
- **账户与分类**:账户树(含余额,从首页移来)、分类、标签、信用卡、备注模板。
- **记账自动化**:规则、周期记账、自动记账通道(无障碍/通知/短信)、导入。
- **数据**:同步(WebDAV/Git/iCloud)、备份恢复、导出、解析诊断。
- **偏好**:主题、语言、应用锁、AI 设置、每日提醒、关于。
- 每组一张 Bento 卡,条目 = 图标 + 名称 + 右箭头;所有二级页用统一 ScreenHeader。
### 7.5 管理页模板化
账户/分类/标签/预算/周期/规则/信用卡/备注模板 8 页统一套 ManagementScreen,预计删除上千行重复代码。
## 8. 阶段计划
| 阶段 | 内容 | 验收 |
|---|---|---|
| P1 设计系统 | 新 tokens → 双主题 presets → 移除自定义字体 → categoryPalette → 重写 MASTER.md | typecheck 通过;token 名不变只改值,旧组件接口兼容 |
| P2 组件库 | ScreenHeader / StatCard / AppTabBar / CategoryIcon / DatePickerField / FilterSheet / ManagementScreen | 组件调试页可逐个查看;纯逻辑单测 |
| P3 录入闭环 | NumpadSheet + 双腿账户选择 + 全局+ + transaction/new 重写 + SpeedDial 退役;"新版录入"开关灰度 | 手动记账/编辑/转账/还款全走面板;pipeline 集成测试不回归 |
| P4 四个 Tab | 首页(待办条)/ 交易(时间线+左滑)/ 报表(anchor 统一)/ 我的(4 分组) | 逐页替换,每页替换后全量测试 |
| P5 管理页+收尾 | 8 页套模板;图表重刷;emoji 清零;硬编码色清零(grep 审计);无障碍标签补全 | `#[0-9A-Fa-f]{6}` 在 src/ 下只剩 presets.ts 与 categoryPalette |
## 9. 测试策略
- **不动 domain 层**billPipeline/dedup/rules 等纯逻辑零改动,现有 30+ 单测是安全网,必须保持全绿。
- 新增纯逻辑单测:numpad 表达式求值(+/- 连续计算)、双腿账户解析(方向 → posting 映射)、anchor 日期导航(周/月/年加减)。
- 每阶段结束跑 `npm test` + `npm run typecheck`UI 层靠深浅双主题手工走查清单。
- 硬编码色审计:grep 全量扫描。
## 10. 风险与 YAGNI
风险对策:
- 主题切换过渡期"半新半旧" → P1 保持 token 名不变只改值,组件接口向后兼容。
- NumpadSheet 全新交互 → 设置开关灰度后再默认开启。
- 移除字体无数据迁移,删加载代码即可。
明确不做:自定义主题编辑器(保留 light/dark/system 三档);迁移图标库到 Lucide;改 domain/存储/同步层;平板/桌面布局适配。
+678
View File
@@ -0,0 +1,678 @@
# UI 重设计 P1:设计系统 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将主题系统切换到「明亮 Bento 现代风」新设计令牌(双主题),移除自定义字体,硬编码颜色收归 palette,并重写 MASTER.md 使设计文档与实现一致。
**Architecture:** 只改 `src/theme/``src/app/_layout.tsx`(字体加载)、`src/domain/channelConfig.ts`(品牌色引用)、`src/components/CategoryPicker.tsx`(色板引用)、`design-system/`。Token 名不变只改值,组件接口向后兼容,现有页面自动"粗换皮"。
**Tech Stack:** React Native + Expo + TypeScript + Vitest。
**Spec:** `docs/ui-redesign-design.md` §3(视觉语言)、§10(P1 验收)。
**验收标准:** `npm test` 全绿;`npm run typecheck` 通过;`src/` 下不再引用 `@expo-google-fonts/*``presets.ts` 的 typography 无 `fontFamily`
---
### Task 1: 新设计令牌的失败测试
**Files:**
- Modify: `tests/theme.test.ts`
- Create: `tests/palette.test.ts`
- [ ] **Step 1: 重写 tests/theme.test.ts 的断言以匹配新令牌**
完整替换文件内容为:
```typescript
import { describe, expect, it } from 'vitest';
import { lightTheme, darkTheme, presetThemes } from '../src/theme/presets';
import { createTheme } from '../src/theme/createTheme';
import type { ThemeTokens } from '../src/theme/tokens';
describe('预置主题(明亮 Bento 设计系统)', () => {
it('lightTheme 与 darkTheme 关键色彩不同', () => {
expect(lightTheme.colors.bgPrimary).not.toBe(darkTheme.colors.bgPrimary);
expect(lightTheme.colors.fgPrimary).not.toBe(darkTheme.colors.fgPrimary);
});
it('浅色背景为 #F6F7F9,暗色为 OLED 纯黑 #040508', () => {
expect(lightTheme.colors.bgPrimary).toBe('#F6F7F9');
expect(darkTheme.colors.bgPrimary).toBe('#040508');
});
it('accent 为近黑白单色:浅色 #111318,暗色反转为 #F3F4F6', () => {
expect(lightTheme.colors.accent).toBe('#111318');
expect(darkTheme.colors.accent).toBe('#F3F4F6');
});
it('accentLight 为 accent 的半透明底色(不再是靛蓝族)', () => {
expect(lightTheme.colors.accentLight).toBe('rgba(17,19,24,0.08)');
expect(darkTheme.colors.accentLight).toBe('rgba(243,244,246,0.10)');
});
it('财务语义色:暗色整体提亮一档', () => {
expect(lightTheme.colors.financial.income).toBe('#10B981');
expect(lightTheme.colors.financial.expense).toBe('#EF4444');
expect(lightTheme.colors.financial.transfer).toBe('#3B82F6');
expect(darkTheme.colors.financial.income).toBe('#34D399');
expect(darkTheme.colors.financial.expense).toBe('#F87171');
expect(darkTheme.colors.financial.transfer).toBe('#60A5FA');
});
it('圆角含 xl(24),卡片默认大圆角', () => {
for (const theme of [lightTheme, darkTheme]) {
expect(theme.radii.sm).toBe(8);
expect(theme.radii.md).toBe(12);
expect(theme.radii.lg).toBe(16);
expect(theme.radii.xl).toBe(24);
expect(theme.radii.full).toBe(9999);
}
});
it('字阶含 display(34),且不指定 fontFamily(系统字体)', () => {
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
expect(theme.typography.display.fontSize).toBe(34);
expect(theme.typography.h1.fontSize).toBe(28);
expect(theme.typography.h2.fontSize).toBe(22);
expect(theme.typography.h3.fontSize).toBe(17);
for (const key of ['display', 'h1', 'h2', 'h3', 'body', 'bodySmall', 'caption'] as const) {
expect(theme.typography[key].fontFamily).toBeUndefined();
}
}
});
it('presetThemes 注册表含 light 与 dark', () => {
expect(presetThemes.light).toBe(lightTheme);
expect(presetThemes.dark).toBe(darkTheme);
});
it('spacing/shadows 结构完整', () => {
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
expect(theme.spacing).toHaveProperty('xs');
expect(theme.spacing).toHaveProperty('xl');
expect(theme.shadows).toHaveProperty('sm');
expect(theme.shadows).toHaveProperty('lg');
}
});
});
describe('createTheme 自定义主题工厂', () => {
it('未覆盖时等价于 lightTheme', () => {
const custom = createTheme({});
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
expect(custom.spacing).toEqual(lightTheme.spacing);
});
it('覆盖 accent 颜色时其他颜色保留', () => {
const custom = createTheme({ colors: { ...lightTheme.colors, accent: '#FF5722' } });
expect(custom.colors.accent).toBe('#FF5722');
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
expect(custom.colors.success).toBe(lightTheme.colors.success);
});
it('覆盖 financial 子对象时深度合并', () => {
const custom = createTheme({ colors: { ...lightTheme.colors, financial: { ...lightTheme.colors.financial, income: '#000' } } });
expect(custom.colors.financial.income).toBe('#000');
expect(custom.colors.financial.expense).toBe(lightTheme.colors.financial.expense);
});
it('覆盖 spacing 部分键时其他键保留', () => {
const custom = createTheme({ spacing: { ...lightTheme.spacing, lg: 32 } });
expect(custom.spacing.lg).toBe(32);
expect(custom.spacing.md).toBe(lightTheme.spacing.md);
});
});
```
- [ ] **Step 2: 新建 tests/palette.test.ts(分类色板 + 渠道品牌色)**
```typescript
import { describe, expect, it } from 'vitest';
import { CATEGORY_PALETTE, getCategoryColor, CHANNEL_BRAND_COLORS } from '../src/theme/palette';
describe('categoryPalette', () => {
it('色板为 12 色循环', () => {
expect(CATEGORY_PALETTE).toHaveLength(12);
for (const c of CATEGORY_PALETTE) expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
it('内置分类有具名颜色(与原 CategoryPicker 一致)', () => {
expect(getCategoryColor('food')).toBe('#F59E0B');
expect(getCategoryColor('transport')).toBe('#3B82F6');
expect(getCategoryColor('salary')).toBe('#22C55E');
});
it('未知分类 id 走哈希循环,结果确定且在色板内', () => {
const a = getCategoryColor('user_custom_abc');
expect(CATEGORY_PALETTE).toContain(a);
expect(getCategoryColor('user_custom_abc')).toBe(a); // 幂等
});
it('空字符串 id 不崩溃', () => {
expect(CATEGORY_PALETTE).toContain(getCategoryColor(''));
});
it('渠道品牌色常量', () => {
expect(CHANNEL_BRAND_COLORS.alipay).toBe('#1677FF');
expect(CHANNEL_BRAND_COLORS.wechat).toBe('#07C160');
expect(CHANNEL_BRAND_COLORS.bank).toBe('#E53935');
});
});
```
- [ ] **Step 3: 运行测试确认失败**
Run: `npx vitest run tests/theme.test.ts tests/palette.test.ts`
Expected: FAIL —— `Cannot find module '../src/theme/palette'`,且 theme 断言多项不通过(旧色板)。
- [ ] **Step 4: Commit**
```bash
git add tests/theme.test.ts tests/palette.test.ts
git commit -m "test: P1 设计系统新令牌与色板的失败测试"
```
---
### Task 2: tokens.ts — display 字阶 + radii.xl
**Files:**
- Modify: `src/theme/tokens.ts`
- [ ] **Step 1: 修改 ThemeRadii 与 ThemeTypography**
`ThemeRadii` 改为(加 `xl`):
```typescript
export interface ThemeRadii {
sm: number;
md: number;
lg: number;
xl: number; // 卡片默认大圆角(24
full: number;
}
```
`ThemeTypographyEntry``fontFamily` 标记废弃(保留字段以兼容现存 `theme.typography.X.fontFamily` 引用,后续阶段逐页清理):
```typescript
export interface ThemeTypographyEntry {
fontSize: number;
fontWeight: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
lineHeight: number;
/** @deprecated 设计系统已切换为系统字体,预置主题不再设置该字段;引用处将在 P4/P5 逐页移除。 */
fontFamily?: string;
}
```
`ThemeTypography``display`
```typescript
export interface ThemeTypography {
display: ThemeTypographyEntry; // 34/800,净资产等大数字
h1: ThemeTypographyEntry;
h2: ThemeTypographyEntry;
h3: ThemeTypographyEntry;
body: ThemeTypographyEntry;
bodySmall: ThemeTypographyEntry;
caption: ThemeTypographyEntry;
}
```
- [ ] **Step 2: 运行 typecheck 确认报错点**
Run: `npm run typecheck`
Expected: FAIL —— `presets.ts` 缺少 `display``radii.xl`Task 3 修复)。
- [ ] **Step 3: 暂不 commit(与 Task 3 一起)**
---
### Task 3: presets.ts — 新双主题色板
**Files:**
- Modify: `src/theme/presets.ts`
- [ ] **Step 1: 完整替换 presets.ts**
```typescript
import type { ThemeTokens } from './tokens';
/**
* 预置主题 —— 「明亮 Bento 现代风」(docs/ui-redesign-design.md §3)。
* 浅色为默认;暗色为 OLED 纯黑完整对等主题。
* accent 为近黑白单色:品牌感靠排版与财务语义色表达。
*/
export const lightTheme: ThemeTokens = {
colors: {
bgPrimary: '#F6F7F9',
bgSecondary: '#FFFFFF',
bgTertiary: '#EFF1F4',
fgPrimary: '#111318',
fgSecondary: '#6B7280',
fgInverse: '#FFFFFF',
accent: '#111318', // 近黑:按钮/选中态
accentLight: 'rgba(17,19,24,0.08)', // accent 8% 底色(选中高亮)
accentDark: '#000000', // 按压加深态
success: '#10B981',
warning: '#F59E0B',
error: '#EF4444',
info: '#3B82F6',
financial: {
income: '#10B981',
expense: '#EF4444',
transfer: '#3B82F6',
},
border: '#E5E7EB',
divider: '#F0F1F3',
overlay: 'rgba(17,19,24,0.4)',
skeleton: '#E5E7EB',
progressBg: 'rgba(17,19,24,0.06)',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radii: { sm: 8, md: 12, lg: 16, xl: 24, full: 9999 },
typography: {
display: { fontSize: 34, fontWeight: '800', lineHeight: 42 },
h1: { fontSize: 28, fontWeight: '700', lineHeight: 36 },
h2: { fontSize: 22, fontWeight: '700', lineHeight: 30 },
h3: { fontSize: 17, fontWeight: '600', lineHeight: 24 },
body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
bodySmall: { fontSize: 14, fontWeight: '400', lineHeight: 20 },
caption: { fontSize: 12, fontWeight: '400', lineHeight: 16 },
},
shadows: {
sm: { shadowColor: '#111318', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4, elevation: 1 },
md: { shadowColor: '#111318', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 8, elevation: 2 },
lg: { shadowColor: '#111318', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.10, shadowRadius: 12, elevation: 4 },
},
};
export const darkTheme: ThemeTokens = {
...lightTheme,
colors: {
bgPrimary: '#040508', // OLED 纯黑
bgSecondary: '#101218',
bgTertiary: '#1A1D24',
fgPrimary: '#F3F4F6',
fgSecondary: '#9CA3AF',
fgInverse: '#111318',
accent: '#F3F4F6', // 反转为白
accentLight: 'rgba(243,244,246,0.10)',
accentDark: '#FFFFFF',
success: '#34D399',
warning: '#FBBF24',
error: '#F87171',
info: '#60A5FA',
financial: {
income: '#34D399', // 提亮一档保证对比度
expense: '#F87171',
transfer: '#60A5FA',
},
border: 'rgba(255,255,255,0.08)',
divider: 'rgba(255,255,255,0.04)',
overlay: 'rgba(0,0,0,0.7)',
skeleton: '#1A1D24',
progressBg: 'rgba(243,244,246,0.08)',
},
shadows: {
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.4, shadowRadius: 2, elevation: 1 },
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.5, shadowRadius: 4, elevation: 3 },
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.6, shadowRadius: 8, elevation: 5 },
},
};
/** 预置主题注册表。 */
export const presetThemes: Record<string, ThemeTokens> = {
light: lightTheme,
dark: darkTheme,
};
```
- [ ] **Step 2: 运行 Task 1 的测试**
Run: `npx vitest run tests/theme.test.ts`
Expected: PASSpalette.test.ts 仍 FAILTask 4 修复)。
- [ ] **Step 3: 运行 typecheck**
Run: `npm run typecheck`
Expected: PASS。
- [ ] **Step 4: Commit**
```bash
git add src/theme/tokens.ts src/theme/presets.ts
git commit -m "feat(theme): P1 明亮 Bento 令牌 —— 近黑 accent、xl 圆角、display 字阶、系统字体"
```
---
### Task 4: theme/palette.ts — 分类色板 + 渠道品牌色
**Files:**
- Create: `src/theme/palette.ts`
- Modify: `src/domain/channelConfig.ts:32,40,48`
- Modify: `src/components/CategoryPicker.tsx:31-48,59`
- [ ] **Step 1: 创建 src/theme/palette.ts(纯 TS,无 RN/domain 依赖,可被双方向引用)**
```typescript
/**
* 分类/渠道颜色统一色板(docs/ui-redesign-design.md §3.2)。
*
* 纯 TS 模块,不依赖 React Native 与 domain 层,因此:
* - components/CategoryPicker 等)用它给分类配色;
* - domain/channelConfig.ts 用它引用渠道品牌色;
* 全工程的颜色字面量只允许存在于 presets.ts 与本文件(P5 grep 审计)。
*/
/** 12 色循环色板:未知/用户自定义分类按 id 哈希取色。 */
export const CATEGORY_PALETTE = [
'#F59E0B', // Amber
'#3B82F6', // Blue
'#EC4899', // Pink
'#06B6D4', // Cyan
'#6366F1', // Indigo
'#8B5CF6', // Purple
'#10B981', // Emerald
'#64748B', // Slate
'#F43F5E', // Rose
'#14B8A6', // Teal
'#EF4444', // Red
'#84CC16', // Lime
] as const;
/** 内置分类的具名颜色(沿用原 CategoryPicker CATEGORY_COLORS 的映射,视觉不变)。 */
const NAMED_CATEGORY_COLORS: Record<string, string> = {
food: '#F59E0B',
transport: '#3B82F6',
shopping: '#EC4899',
housing_utility: '#06B6D4',
housing_rent: '#6366F1',
housing_communication: '#8B5CF6',
entertainment: '#10B981',
services: '#64748B',
personal_care: '#F43F5E',
clothing: '#14B8A6',
health: '#EF4444',
learning: '#84CC16',
salary: '#22C55E',
income_activity: '#EF4444',
income_investment: '#F59E0B',
};
/**
* 本地 FNV-1a 哈希。
* 注:domain/ledger.ts 也有 hash(),但 theme 层不反向依赖 domain 解析器,故保留这份 8 行实现。
*/
function fnv1a(input: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/** 取分类颜色:具名映射优先,否则按 id 哈希在 12 色板内确定性循环。 */
export function getCategoryColor(categoryId: string): string {
const named = NAMED_CATEGORY_COLORS[categoryId];
if (named) return named;
return CATEGORY_PALETTE[fnv1a(categoryId) % CATEGORY_PALETTE.length];
}
/** 渠道品牌色(供 domain/channelConfig.ts 引用)。 */
export const CHANNEL_BRAND_COLORS = {
alipay: '#1677FF',
wechat: '#07C160',
bank: '#E53935',
} as const;
```
- [ ] **Step 2: channelConfig.ts 改用品牌色常量**
文件顶部加 import
```typescript
import { CHANNEL_BRAND_COLORS } from '../theme/palette';
```
三处字面量替换:`color: '#1677FF'``color: CHANNEL_BRAND_COLORS.alipay``color: '#07C160'``color: CHANNEL_BRAND_COLORS.wechat``color: '#E53935'``color: CHANNEL_BRAND_COLORS.bank`
- [ ] **Step 3: CategoryPicker.tsx 改用 getCategoryColor**
删除第 3148 行的 `CATEGORY_COLORS` 常量(含 `fallback`),文件顶部加:
```typescript
import { getCategoryColor } from '../theme/palette';
```
第 59 行 `const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;` 改为:
```typescript
const color = getCategoryColor(cat.id);
```
- [ ] **Step 4: 运行测试**
Run: `npx vitest run tests/palette.test.ts tests/channelConfig.test.ts`
Expected: PASS(渠道色值未变,channelConfig 测试不受影响)。
- [ ] **Step 5: Commit**
```bash
git add src/theme/palette.ts src/domain/channelConfig.ts src/components/CategoryPicker.tsx
git commit -m "feat(theme): 分类/渠道颜色收归 theme/palette,消除组件与 domain 硬编码色"
```
---
### Task 5: 移除自定义字体(Caveat/Quicksand
**Files:**
- Modify: `src/app/_layout.tsx:27-39,70-79,370`
- Modify: `package.json:14-15`
- [ ] **Step 1: 删除 _layout.tsx 的字体导入**
删除第 2739 行两个 `import { useFonts, ... } from '@expo-google-fonts/...'` 块。
- [ ] **Step 2: 删除 useFonts 调用**
删除第 7079 行的 `const [fontsLoaded, fontError] = useFonts({...});`
- [ ] **Step 3: 简化加载门禁**
第 370 行:
```typescript
if (phase === 'loading' || (!fontsLoaded && !fontError)) {
```
改为:
```typescript
if (phase === 'loading') {
```
- [ ] **Step 4: 移除 package.json 依赖并重装**
删除 `package.json``"@expo-google-fonts/caveat"``"@expo-google-fonts/quicksand"` 两行,然后:
Run: `npm install --legacy-peer-deps`
Expected: lockfile 更新,无字体包残留。
- [ ] **Step 5: 验证无残留引用**
Run: `grep -rn "expo-google-fonts\|Caveat_\|Quicksand_" src/ package.json`
Expected: 无输出(typography 中 `theme.typography.X.fontFamily` 的运行时引用返回 undefined,RN 回退系统字体,无需在 P1 清理)。
- [ ] **Step 6: 全量测试 + typecheck**
Run: `npm test && npm run typecheck`
Expected: 全绿。
- [ ] **Step 7: Commit**
```bash
git add src/app/_layout.tsx package.json package-lock.json
git commit -m "feat(theme): 移除 Caveat/Quicksand 自定义字体,切换系统字体"
```
---
### Task 6: commonStyles 适配新圆角
**Files:**
- Modify: `src/theme/commonStyles.ts`
- [ ] **Step 1: modalCard 圆角 lg → xl**
`modalCard``borderRadius: theme.radii.lg, // 弹窗使用大圆角 lg (16px)` 改为:
```typescript
borderRadius: theme.radii.xl, // 弹窗使用卡片级大圆角 xl (24px)
```
`input``theme.radii.md` 现在即 12px,无需改动,仅更新注释 `// 输入框标准 radii 为 md (12px)`。)
- [ ] **Step 2: 验证**
Run: `npm run typecheck`
Expected: PASS。
- [ ] **Step 3: Commit**
```bash
git add src/theme/commonStyles.ts
git commit -m "feat(theme): commonStyles 弹窗圆角升级为 xl(24)"
```
---
### Task 7: 重写 MASTER.md 使其与实现一致
**Files:**
- Modify: `design-system/beancount-mobile/MASTER.md`
- [ ] **Step 1: 完整替换 MASTER.md**
```markdown
# Beancount Mobile 设计系统(MASTER
> 本文件与 `src/theme/presets.ts` 一一对应,是实现的事实描述而非平行标准。
> 修改配色/字阶/圆角时必须先改 presets.ts,再同步本文件。
## 设计方向:明亮 Bento 现代风
浅色为主、大圆角卡片(Bento)、近黑白单色 + 财务语义色点缀。
品牌感靠排版与财务色表达,不依赖彩色 accent。暗色为 OLED 纯黑完整对等主题。
## 色板(= presets.ts
### 浅色(默认)
| Token | 值 | 用途 |
|---|---|---|
| bgPrimary | `#F6F7F9` | 页面背景 |
| bgSecondary | `#FFFFFF` | 卡片 |
| bgTertiary | `#EFF1F4` | 输入框 / chip |
| fgPrimary | `#111318` | 主文字 |
| fgSecondary | `#6B7280` | 次要文字 |
| fgInverse | `#FFFFFF` | 反色文字 |
| accent | `#111318` | 按钮 / 选中态(近黑) |
| accentLight | `rgba(17,19,24,0.08)` | 选中高亮底色 |
| accentDark | `#000000` | 按压态 |
| financial.income | `#10B981` | 收入 |
| financial.expense | `#EF4444` | 支出 |
| financial.transfer | `#3B82F6` | 转账 |
| border | `#E5E7EB` | 边框 |
| overlay | `rgba(17,19,24,0.4)` | 遮罩 |
### 暗色(OLED
| Token | 值 |
|---|---|
| bgPrimary | `#040508` |
| bgSecondary | `#101218` |
| bgTertiary | `#1A1D24` |
| fgPrimary / accent | `#F3F4F6` |
| fgSecondary | `#9CA3AF` |
| fgInverse | `#111318` |
| financial.income | `#34D399`(提亮) |
| financial.expense | `#F87171`(提亮) |
| financial.transfer | `#60A5FA`(提亮) |
| border | `rgba(255,255,255,0.08)` |
分类/渠道颜色见 `src/theme/palette.ts`(12 色循环 + 具名映射 + 渠道品牌色)。
全工程颜色字面量只允许存在于 `presets.ts``palette.ts`
## 字体
系统字体(iOS SF / Android Roboto),不加载自定义字体。
金额数字一律 `fontVariant: ['tabular-nums']` 等宽对齐。
字阶:display 34/800 · h1 28/700 · h2 22/700 · h3 17/600 · body 16/400 · bodySmall 14/400 · caption 12/400。
## 圆角 / 间距 / 阴影
- 圆角:sm 8 · md 12 · lg 16 · **xl 24(卡片与弹窗默认)** · full
- 间距:xs 4 · sm 8 · md 16 · lg 24 · xl 32
- 阴影:浅色 4–12px 弥散轻阴影;暗色以 1px 半透边框代替阴影
## 图标
统一 Ionicons`@expo/vector-icons`)。禁止用 emoji 充当图标;
分类图标 = Ionicon + 圆形彩色底(CategoryIcon 组件,P2 落地)。
## 反模式
- 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token
- 禁止低对比文字(< 4.5:1
- 禁止瞬间状态变化,交互反馈 150–300ms
- 禁止绕过 commonStyles 重复造 input/chip/modal 样式
```
- [ ] **Step 2: Commit**
```bash
git add design-system/beancount-mobile/MASTER.md
git commit -m "docs: MASTER.md 重写为与实现一致的明亮 Bento 设计系统"
```
---
### Task 8: P1 全量验收
- [ ] **Step 1: 全量测试**
Run: `npm test`
Expected: 全部通过(30+ 文件,含新 theme/palette 测试)。
- [ ] **Step 2: typecheck**
Run: `npm run typecheck`
Expected: 无错误。
- [ ] **Step 3: 审计**
Run: `grep -rn "expo-google-fonts" src/ package.json` → 无输出
Run: `grep -rn "4F46E5\|EEF2FF\|3730A3" src/` → 无输出(靛蓝族清零)
已知遗留(不在 P1 处理,已在后续阶段计划内):`src/app/transaction/new.tsx:219``#2196F3`P3 重写 new.tsx 时消除);`src/app/(tabs)/index.tsx` Hero 卡硬编码白字(P4 首页重写时消除);组件内 `fontFamily: theme.typography.X.fontFamily` 引用(P4/P5 逐页清理)。
- [ ] **Step 4: 手工走查(需要设备/模拟器)**
Run: `npm run android`(或 expo start
走查清单:浅色/暗色各过一遍四个 Tab —— 页面背景为米白/纯黑;chip 选中态为黑底白字/白底黑字;卡片圆角变大;无字体加载等待。
---
## 后续计划(不在本文件)
- **P2 组件库**ScreenHeader / StatCard / AppTabBar / CategoryIcon / DatePickerField / FilterSheet / ManagementScreen
- **P3 录入闭环**NumpadSheet + 双腿账户选择 + 全局+ + transaction/new 重写 + SpeedDial 退役
- **P4 四个 Tab 页**:首页待办条 / 交易时间线 / 报表 anchor 统一 / 我的 4 分组
- **P5 管理页模板化 + 清零审计**
每阶段完成后基于实际代码编写下一阶段计划。
+934
View File
@@ -0,0 +1,934 @@
# UI 重设计 P2:核心组件库 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。
**Goal:** 建立新设计系统的核心组件库:CategoryIcon(去 emoji)、BottomSheet、DatePickerField、ScreenHeader、StatCard、AppTabBar(中央凸起+)、ManagementScreen 模板(tag 页试点),并将 Button/Card/SearchBar/FormModal 重刷到新 token。
**Architecture:** 全部为 `src/components/` 下的新组件或既有组件改造;纯逻辑(图标映射、月历网格)抽成无 RN 依赖的独立模块以便 Vitest(node) 测试。AppTabBar 通过 expo-router Tabs 的 `tabBar` 插槽接入;+按钮 P2 暂跳 `/transaction/new`P3 换 NumpadSheet。
**Tech Stack:** React Native + Expo Router + Ionicons + Vitest。
**Spec:** `docs/ui-redesign-design.md` §5(导航)、§6(组件库)。**与 spec 的偏差**FilterSheet 移至 P4(其形态依赖交易页筛选状态,P4 一并设计);CategoryPicker 的"改底部弹层"移至 P3(随 NumpadSheet 一起设计,P2 仅完成 Ionicon 化);FormModal 增加 `children` 支持(ManagementScreen 的颜色选择器需要)。
**前置状态:** P1 已完成(新 tokens/presets/palette 已生效,系统字体,538 测试全绿)。
---
### Task 1: 组件纯逻辑的失败测试
**Files:**
- Create: `tests/components-p2.test.ts`
- [ ] **Step 1: 新建 tests/components-p2.test.ts**
```typescript
import { describe, expect, it } from 'vitest';
import { getCategoryIcon, CATEGORY_ICON_NAMES } from '../src/components/categoryIcons';
import { buildMonthGrid } from '../src/components/dateGrid';
describe('getCategoryIcon', () => {
it('15 个内置分类都有具名图标', () => {
const builtin = [
'food', 'transport', 'shopping', 'housing_utility', 'housing_rent',
'housing_communication', 'entertainment', 'services', 'personal_care',
'clothing', 'health', 'learning', 'salary', 'income_activity', 'income_investment',
];
for (const id of builtin) {
const icon = getCategoryIcon(id);
expect(typeof icon).toBe('string');
expect(icon.length).toBeGreaterThan(0);
}
});
it('未知分类返回 fallback 图标', () => {
expect(getCategoryIcon('whatever_custom')).toBe('pricetag-outline');
});
it('原型链属性名 id 不穿透(返回 fallback', () => {
expect(getCategoryIcon('constructor')).toBe('pricetag-outline');
});
it('所有图标名以 -outline 结尾(风格统一)', () => {
for (const name of Object.values(CATEGORY_ICON_NAMES)) {
expect(name).toMatch(/-outline$/);
}
});
});
describe('buildMonthGrid', () => {
it('固定 6 行 × 7 列', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid).toHaveLength(6);
for (const week of grid) expect(week).toHaveLength(7);
});
it('2026-07:首日周三,周一开头填充 6 月末两天', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid[0][0]).toEqual({ date: '2026-06-29', inMonth: false });
expect(grid[0][1]).toEqual({ date: '2026-06-30', inMonth: false });
expect(grid[0][2]).toEqual({ date: '2026-07-01', inMonth: true });
expect(grid[5][6]).toEqual({ date: '2026-08-09', inMonth: false });
});
it('2026-02:首日周日,填充 1 月最后 6 天', () => {
const grid = buildMonthGrid(2026, 2);
expect(grid[0][0].date).toBe('2026-01-26');
expect(grid[0][6]).toEqual({ date: '2026-02-01', inMonth: true });
});
it('首日恰为周一时无跨月填充(2026-06)', () => {
const grid = buildMonthGrid(2026, 6);
expect(grid[0][0]).toEqual({ date: '2026-06-01', inMonth: true });
});
it('inMonth 标记与当月天数一致(2026-07 共 31 天)', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid.flat().filter(c => c.inMonth)).toHaveLength(31);
});
});
```
- [ ] **Step 2: 运行确认失败**
Run: `npx vitest run tests/components-p2.test.ts`
Expected: FAIL —— `Cannot find module '../src/components/categoryIcons'`。**若意外通过,报告 BLOCKED。**
---
### Task 2: categoryIcons + CategoryIcon + CategoryPicker 去 emoji
**Files:**
- Create: `src/components/categoryIcons.ts`
- Create: `src/components/CategoryIcon.tsx`
- Modify: `src/components/CategoryPicker.tsx`
- [ ] **Step 1: 创建 src/components/categoryIcons.ts**
```typescript
/**
* 分类 id → Ionicons 图标名映射(替换原 CategoryPicker 的 emoji 表)。
* 纯数据模块:仅含类型导入(运行时零依赖),可在 Vitest(node) 中直接测试。
*/
import type { ComponentProps } from 'react';
import type { Ionicons } from '@expo/vector-icons';
export type IoniconName = ComponentProps<typeof Ionicons>['name'];
export const CATEGORY_ICON_NAMES: Record<string, IoniconName> = {
food: 'fast-food-outline',
transport: 'bus-outline',
shopping: 'bag-handle-outline',
housing_utility: 'water-outline',
housing_rent: 'home-outline',
housing_communication: 'call-outline',
entertainment: 'game-controller-outline',
services: 'construct-outline',
personal_care: 'sparkles-outline',
clothing: 'shirt-outline',
health: 'medkit-outline',
learning: 'book-outline',
salary: 'wallet-outline',
income_activity: 'gift-outline',
income_investment: 'trending-up-outline',
};
const FALLBACK_ICON: IoniconName = 'pricetag-outline';
/** 取分类图标:具名映射,否则 fallback(hasOwn 防原型链穿透)。 */
export function getCategoryIcon(categoryId: string): IoniconName {
return Object.hasOwn(CATEGORY_ICON_NAMES, categoryId)
? CATEGORY_ICON_NAMES[categoryId]
: FALLBACK_ICON;
}
```
- [ ] **Step 2: 创建 src/components/CategoryIcon.tsx**
```tsx
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { getCategoryColor } from '../theme/palette';
import { getCategoryIcon } from './categoryIcons';
interface CategoryIconProps {
categoryId: string;
/** 圆形底色直径,默认 36。 */
size?: number;
}
/** 分类图标:Ionicon + 分类色圆形浅底(设计系统 §图标:替换 emoji)。 */
export function CategoryIcon({ categoryId, size = 36 }: CategoryIconProps) {
const color = getCategoryColor(categoryId);
return (
<View
style={[styles.circle, {
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color + '1A',
}]}
>
<Ionicons name={getCategoryIcon(categoryId)} size={size * 0.55} color={color} />
</View>
);
}
const styles = StyleSheet.create({
circle: { alignItems: 'center', justifyContent: 'center' },
});
```
- [ ] **Step 3: CategoryPicker.tsx 接入 CategoryIcon**
- 删除整个 `CATEGORY_ICONS` 常量(第 1330 行)。
- 顶部加 `import { CategoryIcon } from './CategoryIcon';``getCategoryColor` 的 import 保留)。
- 删除 `const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback;` 一行。
- JSX 中 `<Text style={[styles.icon, { color }]}>{icon}</Text>` 替换为 `<CategoryIcon categoryId={cat.id} size={32} />`
- 删除 label 样式里的 `fontFamily: theme.typography.caption.fontFamily,` 一行。
- 删除 styles 中的 `icon` 条目。
- 注意:`const color = getCategoryColor(cat.id);` 保留(active 底色仍用)。
- [ ] **Step 4: 验证**
Run: `npx vitest run tests/components-p2.test.ts` → categoryIcons 相关 4 条 PASSdateGrid 仍 FAILTask 3 修复)
Run: `npm run typecheck` → Expected: 无错误
---
### Task 3: dateGrid 月历网格纯逻辑
**Files:**
- Create: `src/components/dateGrid.ts`
- [ ] **Step 1: 创建 src/components/dateGrid.ts**
```typescript
/**
* 月历网格生成(周一开头,固定 6 行×7 列,含前后月填充)。
* 纯 TS,无 RN 依赖,供 DatePickerField / CalendarView 复用。
*/
export interface DayCell {
/** YYYY-MM-DD(本地时区)。 */
date: string;
inMonth: boolean;
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
function fmt(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
/** 生成指定年月的月历网格。month 为 1-12。 */
export function buildMonthGrid(year: number, month: number): DayCell[][] {
const first = new Date(year, month - 1, 1);
// 周一为一周起点:getDay() 周日=0 → 偏移 (day+6)%7
const offset = (first.getDay() + 6) % 7;
const cursor = new Date(year, month - 1, 1 - offset);
const weeks: DayCell[][] = [];
for (let w = 0; w < 6; w++) {
const week: DayCell[] = [];
for (let d = 0; d < 7; d++) {
week.push({ date: fmt(cursor), inMonth: cursor.getMonth() === month - 1 });
cursor.setDate(cursor.getDate() + 1);
}
weeks.push(week);
}
return weeks;
}
```
- [ ] **Step 2: 验证**
Run: `npx vitest run tests/components-p2.test.ts` → Expected: 全部 9 条 PASS
---
### Task 4: BottomSheet 通用底部弹层
**Files:**
- Create: `src/components/BottomSheet.tsx`
- [ ] **Step 1: 创建 src/components/BottomSheet.tsx**
```tsx
import React from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
interface BottomSheetProps {
visible: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
}
/** 通用底部弹层容器:顶部 xl 圆角 + 把手、遮罩点击关闭、Android 返回键关闭。 */
export function BottomSheet({ visible, onClose, title, children }: BottomSheetProps) {
const { theme } = useTheme();
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onClose}>
<Pressable
style={[styles.sheet, {
backgroundColor: theme.colors.bgSecondary,
borderTopLeftRadius: theme.radii.xl,
borderTopRightRadius: theme.radii.xl,
}]}
onPress={(e) => e.stopPropagation()}
>
<View style={[styles.handle, { backgroundColor: theme.colors.border }]} />
{title ? (
<Text style={[theme.typography.h3, styles.title, { color: theme.colors.fgPrimary }]}>{title}</Text>
) : null}
{children}
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'flex-end' },
sheet: { maxHeight: '85%', paddingHorizontal: 20, paddingBottom: 36 },
handle: { width: 40, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 8, marginBottom: 4 },
title: { marginTop: 8, marginBottom: 12 },
});
```
- [ ] **Step 2: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 5: DatePickerField 日期选择字段
**Files:**
- Create: `src/components/DatePickerField.tsx`
- [ ] **Step 1: 创建 src/components/DatePickerField.tsx**
```tsx
import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../theme';
import { BottomSheet } from './BottomSheet';
import { buildMonthGrid } from './dateGrid';
interface DatePickerFieldProps {
/** YYYY-MM-DD,空串表示未选。 */
value: string;
onChange: (date: string) => void;
placeholder?: string;
}
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
/** 日期选择字段:输入框外观 + 底部弹层月历(终结手输 YYYY-MM-DD)。 */
export function DatePickerField({ value, onChange, placeholder }: DatePickerFieldProps) {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const [open, setOpen] = useState(false);
const [viewYear, setViewYear] = useState(new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(new Date().getMonth() + 1);
const grid = useMemo(() => buildMonthGrid(viewYear, viewMonth), [viewYear, viewMonth]);
const openPicker = () => {
const y = Number(value.slice(0, 4));
const m = Number(value.slice(5, 7));
if (y) {
setViewYear(y);
setViewMonth(m || 1);
}
setOpen(true);
};
const shiftMonth = (delta: number) => {
const m = viewMonth - 1 + delta;
setViewYear(viewYear + Math.floor(m / 12));
setViewMonth(((m % 12) + 12) % 12 + 1);
};
const pick = (date: string) => {
onChange(date);
setOpen(false);
};
return (
<>
<Pressable style={commonStyles.input} onPress={openPicker} accessibilityRole="button">
<Text style={{ color: value ? theme.colors.fgPrimary : theme.colors.fgSecondary }}>
{value || placeholder || 'YYYY-MM-DD'}
</Text>
</Pressable>
<BottomSheet visible={open} onClose={() => setOpen(false)}>
{/* 月份导航 */}
<View style={styles.navRow}>
<Pressable onPress={() => shiftMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月">
<Ionicons name="chevron-back" size={22} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'] }]}>
{viewYear}-{String(viewMonth).padStart(2, '0')}
</Text>
<Pressable onPress={() => shiftMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月">
<Ionicons name="chevron-forward" size={22} color={theme.colors.fgPrimary} />
</Pressable>
</View>
{/* 星期头 */}
<View style={styles.weekRow}>
{WEEKDAYS.map(w => (
<Text key={w} style={[styles.weekCell, theme.typography.caption, { color: theme.colors.fgSecondary }]}>{w}</Text>
))}
</View>
{/* 日期网格 */}
{grid.map((week, wi) => (
<View key={wi} style={styles.weekRow}>
{week.map(cell => {
const selected = cell.date === value;
return (
<Pressable
key={cell.date}
onPress={() => pick(cell.date)}
style={[styles.dayCell, {
backgroundColor: selected ? theme.colors.accent : 'transparent',
borderRadius: theme.radii.full,
}]}
>
<Text style={[theme.typography.bodySmall, {
color: selected
? theme.colors.fgInverse
: cell.inMonth ? theme.colors.fgPrimary : theme.colors.fgSecondary,
opacity: cell.inMonth ? 1 : 0.5,
fontVariant: ['tabular-nums'],
}]}>
{Number(cell.date.slice(8, 10))}
</Text>
</Pressable>
);
})}
</View>
))}
</BottomSheet>
</>
);
}
const styles = StyleSheet.create({
navRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
weekRow: { flexDirection: 'row' },
weekCell: { flex: 1, textAlign: 'center', paddingVertical: 6 },
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', margin: 1 },
});
```
- [ ] **Step 2: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 6: ScreenHeader + StatCard
**Files:**
- Create: `src/components/ScreenHeader.tsx`
- Create: `src/components/StatCard.tsx`
- [ ] **Step 1: 创建 src/components/ScreenHeader.tsx**
```tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '../theme';
interface ScreenHeaderProps {
title: string;
/** 右侧操作位(图标按钮/文本按钮)。 */
right?: React.ReactNode;
/** 自定义返回行为;默认 router.back()。 */
onBack?: () => void;
/** 返回按钮的无障碍标签。 */
backLabel?: string;
}
/** 统一二级页头:返回 + 标题 + 右操作位(替换各页手写 header)。 */
export function ScreenHeader({ title, right, onBack, backLabel = '返回' }: ScreenHeaderProps) {
const { theme } = useTheme();
const router = useRouter();
return (
<View style={styles.row}>
<Pressable
onPress={onBack ?? (() => router.back())}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={backLabel}
>
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, styles.title, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
{title}
</Text>
{right ?? <View style={styles.rightPlaceholder} />}
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, gap: 8 },
title: { flex: 1 },
rightPlaceholder: { width: 24 },
});
```
- [ ] **Step 2: 创建 src/components/StatCard.tsx**
```tsx
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
interface StatCardProps {
label: string;
value: string;
caption?: string;
/** 默认 fgPrimary;收支场景传 financial.income/expense。 */
valueColor?: string;
}
/** 统计数字卡:标题 + 大数字(等宽)+ 说明(首页/报表/年报共用)。 */
export function StatCard({ label, value, caption, valueColor }: StatCardProps) {
const { theme } = useTheme();
return (
<View
style={[styles.card, {
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.xl,
borderColor: theme.colors.border,
}, theme.shadows.sm]}
>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{label}</Text>
<Text
style={[theme.typography.h2, styles.value, { color: valueColor ?? theme.colors.fgPrimary }]}
numberOfLines={1}
>
{value}
</Text>
{caption ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{caption}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
card: { padding: 16, gap: 4, borderWidth: StyleSheet.hairlineWidth },
value: { fontWeight: '800', fontVariant: ['tabular-nums'] },
});
```
- [ ] **Step 3: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 7: AppTabBar 中央凸起+导航
**Files:**
- Create: `src/components/AppTabBar.tsx`
- Modify: `src/app/(tabs)/_layout.tsx`
- [ ] **Step 1: 创建 src/components/AppTabBar.tsx**
```tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { useTheme } from '../theme';
import type { IoniconName } from './categoryIcons';
/** 路由名 → 图标(与 (tabs)/_layout.tsx 的 4 个 Screen 对应)。 */
const TAB_ICONS: Record<string, IoniconName> = {
index: 'home-outline',
transactions: 'list-outline',
report: 'pie-chart-outline',
settings: 'settings-outline',
};
/**
* 自定义底部导航:4 个内容 Tab + 中央凸起+。
* +不是路由——P2 暂跳转 /transaction/newP3 改为唤起全局 NumpadSheet。
*/
export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
const { theme } = useTheme();
const router = useRouter();
const insets = useSafeAreaInsets();
const renderTab = (route: (typeof state.routes)[number], index: number) => {
const focused = state.index === index;
const options = descriptors[route.key].options;
const label = (options.title ?? route.name) as string;
const color = focused ? theme.colors.accent : theme.colors.fgSecondary;
const onPress = () => {
const event = navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true });
if (!focused && !event.defaultPrevented) navigation.navigate(route.name);
};
return (
<Pressable
key={route.key}
onPress={onPress}
style={styles.tab}
accessibilityRole="button"
accessibilityState={{ selected: focused }}
accessibilityLabel={label}
>
<Ionicons name={TAB_ICONS[route.name] ?? 'ellipse-outline'} size={22} color={color} />
<Text style={[styles.tabLabel, { color }]}>{label}</Text>
</Pressable>
);
};
return (
<View
style={[styles.bar, {
backgroundColor: theme.colors.bgSecondary,
borderTopColor: theme.colors.border,
paddingBottom: Math.max(insets.bottom, 8),
}]}
>
{state.routes.slice(0, 2).map(renderTab)}
<View style={styles.plusSlot}>
<Pressable
onPress={() => router.push('/transaction/new')}
style={[styles.plus, {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.bgPrimary,
}, theme.shadows.lg]}
accessibilityRole="button"
accessibilityLabel="记一笔"
>
<Ionicons name="add" size={30} color={theme.colors.fgInverse} />
</Pressable>
</View>
{state.routes.slice(2).map(renderTab)}
</View>
);
}
const styles = StyleSheet.create({
bar: { flexDirection: 'row', alignItems: 'center', borderTopWidth: StyleSheet.hairlineWidth },
tab: { flex: 1, alignItems: 'center', paddingTop: 8, gap: 2 },
tabLabel: { fontSize: 10, fontWeight: '600' },
plusSlot: { width: 64, alignItems: 'center' },
plus: { width: 52, height: 52, borderRadius: 26, alignItems: 'center', justifyContent: 'center', marginTop: -24, borderWidth: 4 },
});
```
- [ ] **Step 2: 接入 (tabs)/_layout.tsx**
- 顶部加 `import { AppTabBar } from '../../components/AppTabBar';`,删除 `import { Ionicons } from '@expo/vector-icons';`(不再需要)。
- 第 7 行注释改为 `/** 底部 Tab 导航:首页/交易/报表/设置 + 中央+(AppTabBar)。 */`
- `Tabs` 组件加 prop`tabBar={(props) => <AppTabBar {...props} />}`
- 删除 4 个 `Tabs.Screen` 里的 `tabBarIcon` 属性(图标由 AppTabBar 的 TAB_ICONS 决定),`title` 保留。
- screenOptions 中 `tabBarActiveTintColor` / `tabBarInactiveTintColor` / `tabBarStyle` 可删除(自定义 tabBar 不用),仅保留 `headerShown: false`
- [ ] **Step 3: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 8: FormModal children 支持 + ManagementScreen + tag 页试点
**Files:**
- Modify: `src/components/FormModal.tsx`
- Create: `src/components/ManagementScreen.tsx`
- Modify: `src/app/tag/index.tsx`(完整重写)
- [ ] **Step 1: FormModal 增加 children + ✕ 换图标 + 圆角 xl**
- `FormModalProps` 加字段:
```typescript
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
children?: React.ReactNode;
```
- 函数签名解构加 `children`;在 `</ScrollView>` 之后、`<View style={styles.actions}>` 之前插入 `{children}`
- 顶部加 `import { Ionicons } from '@expo/vector-icons';`;把关闭按钮的 `<Text ...>✕</Text>` 替换为 `<Ionicons name="close" size={20} color={theme.colors.fgSecondary} />`
- sheet 的 `borderRadius: theme.radii.lg` 改为 `theme.radii.xl`
- [ ] **Step 2: 创建 src/components/ManagementScreen.tsx**
```tsx
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { ScreenHeader } from './ScreenHeader';
import { FormModal, type FormField } from './FormModal';
/**
* 管理页模板(P2):统一「列表 + +新增 + 点按编辑 + 长按删除 + FormModal」模式。
* 各管理页只声明字段配置与数据读写,不再复制 header/Alert/Modal 样板。
*/
interface ManagementScreenProps<T> {
title: string;
items: T[];
keyExtractor: (item: T) => string;
/** 渲染单个条目;handlers.openEdit 打开编辑弹窗,handlers.confirmDelete 弹删除确认。 */
renderItem: (item: T, handlers: { openEdit: () => void; confirmDelete: () => void }) => React.ReactNode;
addLabel: string;
emptyText?: string;
formTitle: (editing: T | null) => string;
formFields: (editing: T | null) => FormField[];
/** 返回 true 关闭弹窗(校验失败 Alert 后返回 false 保持打开)。 */
onSubmit: (values: Record<string, string>, editing: T | null) => boolean;
onDelete: (item: T) => void;
deleteConfirmText: (item: T) => string;
/** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */
formExtra?: (editing: T | null) => React.ReactNode;
/** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */
onOpenForm?: (editing: T | null) => void;
/** 列表上方的额外内容(分组 Tab 等)。 */
headerContent?: React.ReactNode;
footer?: React.ReactNode;
}
export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
const { theme } = useTheme();
const t = useT();
const [editing, setEditing] = useState<T | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const openForm = (item: T | null) => {
setEditing(item);
props.onOpenForm?.(item);
setModalOpen(true);
};
const confirmDelete = (item: T) => {
Alert.alert(t('common.delete'), props.deleteConfirmText(item), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => props.onDelete(item) },
]);
};
const handleSubmit = (values: Record<string, string>) => {
if (props.onSubmit(values, editing)) setModalOpen(false);
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader
title={props.title}
right={
<Pressable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}>
<Ionicons name="add" size={26} color={theme.colors.accent} />
</Pressable>
}
/>
<ScrollView contentContainerStyle={styles.content}>
{props.headerContent}
{props.items.length === 0 && props.emptyText ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{props.emptyText}</Text>
) : null}
{props.items.map(item => (
<View key={props.keyExtractor(item)}>
{props.renderItem(item, {
openEdit: () => openForm(item),
confirmDelete: () => confirmDelete(item),
})}
</View>
))}
{props.footer}
</ScrollView>
<FormModal
visible={modalOpen}
title={props.formTitle(editing)}
fields={props.formFields(editing)}
onConfirm={handleSubmit}
onCancel={() => setModalOpen(false)}
>
{props.formExtra?.(editing)}
</FormModal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16, gap: 12 },
});
```
- [ ] **Step 3: 完整重写 src/app/tag/index.tsxManagementScreen 试点)**
```tsx
/**
* 标签管理页面 —— ManagementScreen 模板试点(P2)。
*
* 功能:标签列表(彩色芯片) + 添加/编辑/删除。
* 标签名写入 .bean 的 #tag 语法,需符合 [\w-]。
*/
import React, { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { TAG_COLORS } from '../../theme/palette';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n';
import { ManagementScreen } from '../../components/ManagementScreen';
import { isValidTagName } from '../../domain/tags';
import type { Tag } from '../../domain/tags';
export default function TagScreen() {
const { theme } = useTheme();
const t = useT();
const tags = useMetadataStore(s => s.tags);
const addTag = useMetadataStore(s => s.addTag);
const updateTag = useMetadataStore(s => s.updateTag);
const removeTag = useMetadataStore(s => s.removeTag);
const [selectedColor, setSelectedColor] = useState<string>(TAG_COLORS[0]);
return (
<ManagementScreen<Tag>
title={t('tag.title')}
items={tags}
keyExtractor={tag => tag.id}
addLabel={t('tag.add')}
emptyText={t('tag.empty')}
onOpenForm={editing => setSelectedColor(editing?.color ?? TAG_COLORS[0])}
renderItem={(tag, { openEdit, confirmDelete }) => (
<Pressable
onPress={openEdit}
onLongPress={confirmDelete}
style={[styles.chip, { backgroundColor: tag.color }]}
>
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
</Pressable>
)}
formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))}
formFields={editing => [
{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name },
]}
formExtra={() => (
<View style={styles.colorRow}>
{TAG_COLORS.map(c => (
<Pressable
key={c}
onPress={() => setSelectedColor(c)}
style={[styles.colorDot, {
backgroundColor: c,
borderWidth: selectedColor === c ? 3 : 0,
borderColor: theme.colors.fgPrimary,
}]}
/>
))}
</View>
)}
onSubmit={(values, editing) => {
const name = values.name?.trim() ?? '';
if (!isValidTagName(name)) {
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
return false;
}
if (editing) updateTag(editing.id, { name, color: selectedColor });
else addTag({ id: generateId('tag'), name, color: selectedColor });
return true;
}}
onDelete={tag => removeTag(tag.id)}
deleteConfirmText={tag => t('tag.deleteConfirm', { name: tag.name })}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
}
/>
);
}
const styles = StyleSheet.create({
chip: { alignSelf: 'flex-start', paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
colorDot: { width: 36, height: 36, borderRadius: 18 },
});
```
- [ ] **Step 4: 验证**
Run: `npm test` → Expected: 全部通过(tag 页逻辑变化不影响单测,但全量跑一遍防回归)
Run: `npm run typecheck` → Expected: 无错误
---
### Task 9: Button / Card / SearchBar 重刷
**Files:**
- Modify: `src/components/Button.tsx`
- Modify: `src/components/Card.tsx`
- Modify: `src/components/SearchBar.tsx`
- [ ] **Step 1: Button.tsx**
- `borderRadius: theme.radii.lg, // 使用 radii.lg 实现现代大圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`
- 删除 Text 样式中的 `fontFamily: theme.typography.body.fontFamily`(系统字体)。
- [ ] **Step 2: Card.tsx**
- cardStyle 中 `borderRadius: theme.radii.lg,` 改为 `borderRadius: theme.radii.xl,`
- [ ] **Step 3: SearchBar.tsx**
- `borderRadius: theme.radii.lg, // 升级为 lg 圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`
- [ ] **Step 4: 验证**
Run: `npm run typecheck && npm test` → Expected: 全部通过
---
### Task 10: P2 全量验收
- [ ] **Step 1: 全量测试 + typecheck**
Run: `npm test` → Expected: 全部通过(含 components-p2 的 9 条新测试)
Run: `npm run typecheck` → Expected: 无错误
- [ ] **Step 2: 审计**
Run: `grep -rn "🍔\|🚗\|🛍\|💧\|🏠\|📞\|🎮\|⚙️\|💅\|👕\|🏥\|📚\|💰\|🧧\|📈\|🏷" src/` → Expected: 无输出(分类 emoji 清零)
Run: `grep -n "fontFamily" src/components/Button.tsx src/components/CategoryPicker.tsx` → Expected: 无输出
Run: `grep -n "✕" src/components/` → Expected: 无输出
- [ ] **Step 3: 手工走查(需设备/模拟器)**
Run: `npm run android`
走查清单(浅/暗双主题):
1. 底部导航为自定义 bar:中央黑色凸起+,4 个 tab 可切换
2. + → 跳转记一笔页(P2 占位行为)
3. 记一笔页分类网格:图标为 Ionicons 圆底彩色,无 emoji
4. 标签管理页:新 ScreenHeader +号新增、点按编辑、长按删除、颜色选择器在弹窗内正常工作
5. 任意 FormModal:关闭按钮为 Ionicons 图标而非 ✕ 字符
---
## 后续计划(不在本文件)
- **P3 录入闭环**NumpadSheet + 双腿账户选择 + AppTabBar 改全局唤起 + transaction/new 重写 + SpeedDial 退役
- **P4 四个 Tab 页**:首页待办条 / 交易时间线(FilterSheet 在此设计)/ 报表 anchor 统一 / 我的 4 分组
- **P5 管理页全面模板化**(tag 页为已验证模板)+ 清零审计
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+233
View File
@@ -0,0 +1,233 @@
# UI 重设计 P5:管理页模板化 + 清零收尾 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。
**Goal:** 7 个管理页套 ManagementScreen 模板、account 页与 3 个设置二级页统一 ScreenHeader、图表/fontFamily/emoji/星期头清理、`numpadGlobalEntry` 翻默认(含 modal 未保存守卫)、清零审计验收。
**Spec:** `docs/ui-redesign-design.md` §7.5/§8。**已确认现状**2026-07-22 审计):hex 硬编码已清零(`#[0-9A-Fa-f]{6}` 在 src/ 仅剩 presets.ts/palette.ts);fontFamily 残留 14 处;emoji 5 处(monthlySummary.ts 4 + ai/chat.tsx 1);tag 页已是模板试点。
**与 spec 的偏差**:①account 页是「账户浏览器」(类型 Tab + 开户/调余额/关户三个交互),不套 ManagementScreen,只做 ScreenHeader 统一;②`numpadGlobalEntry` 翻默认只影响新安装(已持久化的 false 不覆盖,尊重用户选择)。
**模板适配决策**(已通读 7 页源码):
- budget/rules/remark-template → 直套(T1
- category → `headerContent` 放支出/收入 chips,新增按当前类型(T2)
- recurring → 卡片保留显式编辑/删除按钮(用 handlers.openEdit/confirmDelete),不用长按(T2
- credit-card → 富 renderItem(账单盒)直套(T2
---
### Task 1: 管理页模板化批次 1budget / rules / remark-template
**Files:**
- Modify: `src/app/budget/index.tsx``src/app/rules/index.tsx``src/app/remark-template/index.tsx`
- [ ] **Step 1: 三页套 ManagementScreen**(参照试点 `src/app/tag/index.tsx` 与模板 `src/components/ManagementScreen.tsx`
通用映射:页面 state 消失(modal/editing 由模板持有);手写 header/addBtn/Alert/FormModal 全删;`footer``common.clickEditLongDelete` 提示;`deleteConfirmTitle` 传各页 `t('xxx.deleteTitle')`
**budget/index.tsx**
- `items={budgets}``keyExtractor={b => b.id}``addLabel={t('budget.add')}``emptyText={t('budget.empty')}`
- renderItem:保留现有 Card + 进度条结构(Pressable onPress={openEdit} onLongPress={confirmDelete} 包裹)。`today` 改用 `toDateString(new Date())`(修掉 `toISOString().slice(0,10)` 的 UTC 偏移)
- formFields5 字段(name/amount decimal-pad/period/categoryAccount/startDate),defaultValue 从 editing 取
- onSubmit:保留校验(amount parseFloat ≤ 0 → Alert + return false);add/edit 分支调 addBudget/updateBudget
- onDelete`removeBudget(budget.id)`deleteConfirmText`t('budget.deleteConfirm', { name: budget.name })`
**rules/index.tsx**
- `items={[...rules].sort((a, b) => b.priority - a.priority)}`
- renderItem:保留规则行(narration/P 徽标/条件/目标账户+命中次数);**删除 `fontFamily: theme.typography.caption.fontFamily`**(审计项)
- formFields7 字段(priority numeric/counterpartyContains/memoContains/sourceAccount/categoryAccount/narration/tags
- onSubmit:保留现有 Rule 组装逻辑;addRule `{...rule, id: generateId('rule'), hits: 0}` / updateRule
**remark-template/index.tsx**
- 2 字段(name/template);renderItem 的模板文本 `fontFamily: 'monospace'``fontVariant: ['tabular-nums']`(审计项)
- onSubmitname 空回退 `t('common.untitled')`
- [ ] **Step 2: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -ln "FormModal\|arrow-back" src/app/budget/index.tsx src/app/rules/index.tsx src/app/remark-template/index.tsx` → 无输出
---
### Task 2: 管理页模板化批次 2category / recurring / credit-card
**Files:**
- Modify: `src/app/category/index.tsx``src/app/recurring/index.tsx``src/app/credit-card/index.tsx`
- [ ] **Step 1: category/index.tsx — headerContent 类型 chips**
- 保留一个本地 state`const [catType, setCatType] = useState<'expense' | 'income'>('expense')`
- `headerContent`:两个 chip(支出/收入,commonStyles.chip/chipActive),切换 setCatType
- `items={categories.filter(c => c.type === catType)}`
- `formTitle`editing ? `t('category.editTitle')` : `t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') })`(插值语法以现有键为准,先 grep category.addTitle
- formFields3 字段(name/linkedAccount/keywords),编辑时带 defaultValue
- onSubmitadd 时 `type: catType`linkedAccount 空回退按类型(Income:/Expenses:Uncategorized),keywords 拆分逻辑保留;edit 保留原逻辑
- renderItem:保留名称/linkedAccount/关键词行
- [ ] **Step 2: recurring/index.tsx — 显式按钮用 handlers**
- renderItem:保留信息行 Card;卡片底部两个按钮(编辑/删除)分别调 `handlers.openEdit()` / `handlers.confirmDelete()`(**不**用长按,交互更显式);外层 Pressable 去掉
- formFields6 字段;`new Date().toISOString().slice(0, 10)` 两处 → `toDateString(new Date())`
- onSubmit:保留校验/draft 组装/成功 Alert,返回 true;校验失败 Alert 后 return false
- 成功 Alert 在 onSubmit 内(setModal(null) 由模板接管——删除手动 setModal 调用)
- [ ] **Step 3: credit-card/index.tsx — 富 renderItem 直套**
- accountBalances useMemo 保留在页面顶层
- renderItem:保留整张卡(银行/账单日/还款日/额度/账单盒),Pressable onPress={openEdit} onLongPress={confirmDelete}
- formFields8 字段;onSubmit 保留组装逻辑
- [ ] **Step 4: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -ln "FormModal\|arrow-back" src/app/category/index.tsx src/app/recurring/index.tsx src/app/credit-card/index.tsx` → 无输出
---
### Task 3: ScreenHeader 统一(account + settings 三页)
**Files:**
- Modify: `src/app/account/index.tsx``src/app/settings/sync.tsx``src/app/settings/ai.tsx``src/app/settings/preferences.tsx`
- [ ] **Step 1: account/index.tsx**
手写 headerarrow-back + title)替换为 `<ScreenHeader title={t('account.title')} />`(组件保持其余逻辑不变:类型 Tab/开户 FormModal/调余额 FormModal/关户 Alert);删除 styles.header。调余额表单的 date 默认值 `new Date().toISOString().slice(0, 10)``toDateString(new Date())`
- [ ] **Step 2: settings/sync.tsx、ai.tsx、preferences.tsx**
三页手写 header(约在 sync.tsx:460、ai.tsx:51、preferences.tsx:44)替换为 ScreenHeader,标题沿用原文案键;删除对应 styles.header 与不再使用的 importIonicons/router 若仅 header 使用)。sync.tsx 546 行,只动 header 块,其余不动。
- [ ] **Step 3: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -rn "arrow-back" src/app/ | grep -v ScreenHeader` → 无输出
---
### Task 4: 图表与杂项清理(fontFamily / emoji / 星期头 i18n
**Files:**
- Modify: `src/components/charts/CategoryPie.tsx``src/components/charts/TrendLine.tsx``src/components/charts/NetWorthChart.tsx``src/components/CalendarView.tsx``src/components/charts/CalendarHeatmap.tsx``src/components/DatePickerField.tsx``src/components/NumpadKeyboard.tsx``src/components/NumpadSheet.tsx``src/ai/monthlySummary.ts``src/app/ai/chat.tsx``src/app/transaction/[id].tsx``src/app/_error.tsx``src/app/import/index.tsx``src/components/PostingEditor.tsx``src/i18n/zh.ts``src/i18n/en.ts`
- [ ] **Step 1: fontFamily 清零**
- `theme.typography.X.fontFamily` 引用(CategoryPie 2 处、TrendLine 4 处、ai/chat.tsx 1 处):直接删除该属性(P1 后 typography 无 fontFamily 字段,这些是无效引用)
- `fontFamily: 'monospace'`
- 金额/数字场景(CategoryPie:35、transaction/[id].tsx:314、PostingEditor:63、import/index.tsx:391)→ `fontVariant: ['tabular-nums']`
- 纯文本场景(_error.tsx:70 堆栈文本)→ 保留 monospace(堆栈对齐需要)或删除——保留,加注释说明
- NetWorthChart/CalendarHeatmap 顺便检查一遍(grep 未命中但过一眼 token 合规)
- [ ] **Step 2: emoji 清零**
- `src/ai/monthlySummary.ts:81-85`:剥离 `📊/💰/💸/📝` 前缀,保留纯文本行(如 `'总收入:...'`
- `src/app/ai/chat.tsx:123`:方向 emoji(💰/🔄/💸)替换为符号文本:income `''`、transfer `'⇄'`、expense `''`(或直接金额上色——读上下文选与周边一致的处理;金额色用 theme.colors.financial.*
- [ ] **Step 3: 星期头 i18n3 处硬编码中文)**
- i18n 加键(zh/en 对等,单键逗号分隔):`'datepicker.weekdays': '一,二,三,四,五,六,日'` / `'Mo,Tu,We,Th,Fr,Sa,Su'`(周一起,DatePickerField 用);`'calendar.weekdays': '日,一,二,三,四,五,六'` / `'Su,Mo,Tu,We,Th,Fr,Sa'`(周日起,CalendarView/CalendarHeatmap 用)
- `DatePickerField.tsx``CalendarView.tsx``CalendarHeatmap.tsx` 的 WEEKDAYS 常量改为 `t('datepicker.weekdays').split(',')` / `t('calendar.weekdays').split(',')`(组件内 useT;注意保持周一/周日起始顺序与各自网格一致,不要换序)
- [ ] **Step 4: NumpadKeyboard backspace 标签 i18n**
- i18n 加键:`'numpad.backspace': '退格'` / `'Backspace'`
- NumpadKeyboard props 加 `backspaceLabel: string`backspace 键 accessibilityLabel 用它;`NumpadSheet.tsx` 调用处传 `t('numpad.backspace')`
- [ ] **Step 5: 验证**
Run: `npm run typecheck && npm test` → 全绿(i18n 对等校验覆盖新键)
Run: `grep -rn "fontFamily" src/ --include="*.tsx" | grep -v "_error.tsx"` → 无输出
---
### Task 5: 收尾(翻默认 + modal 守卫 + MonthlyReport 删除 + budgets 时区修复 + duplicate 增强)
**Files:**
- Modify: `src/store/settingsStore.ts``src/components/NumpadSheet.tsx``src/components/NumpadSheetHost.tsx``src/domain/budgets.ts``src/app/(tabs)/transactions.tsx``tests/budgets.test.ts`(或现有 budget 测试文件)
- Delete: `src/components/charts/MonthlyReport.tsx`
- [ ] **Step 1: numpadGlobalEntry 默认翻 true**
`src/store/settingsStore.ts` DEFAULTS 中 `numpadGlobalEntry: false``true`。**注意**:已持久化 false 的用户不受影响(hydrate 合并优先级),仅新安装生效——在代码注释中写明。
- [ ] **Step 2: modal 模式未保存守卫(翻默认的前置)**
- `NumpadSheet.tsx`:加可选 prop `onDirtyChange?: (dirty: boolean) => void`;把 beforeRemove 守卫里的 dirty 计算抽成 `computeDirty()`(读 formRef),加 `useEffect(() => { onDirtyChange?.(computeDirty()); })`(每次渲染后上报,轻量)
- `NumpadSheetHost.tsx``const dirtyRef = useRef(false)`NumpadSheet 传 `onDirtyChange={d => { dirtyRef.current = d; }}`overlay onPress 与 Modal onRequestClose 改调 `requestClose`
```typescript
const requestClose = () => {
if (!dirtyRef.current) { close(); return; }
Alert.alert(t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.discard'), style: 'destructive', onPress: close },
]);
};
```
Host 需 useT + Alert importcommon.discard 键先 grep 确认存在。)
- [ ] **Step 3: 删除 MonthlyReport.tsx**P4 确认无引用的死文件)
Run: `rm src/components/charts/MonthlyReport.tsx`,再 `grep -rn "MonthlyReport" src/` → 无输出
- [ ] **Step 4: budgets.ts getPeriodRange 本地时区修复**
`src/domain/budgets.ts``getPeriodRange`(约 93 行):`new Date('YYYY-MM-DD')` 是 UTC 解析,负时区会把边界日算到前一天。改为 split 构造本地 Date(`const [y, m, d] = dateStr.split('-').map(Number); new Date(y, m - 1, d)`),与 periodNav 的 parse 一致。在现有 budget 测试文件(`ls tests/ | grep -i budget`)追加边界用例:`getPeriodRange('monthly', '2026-07-01')` 的 start 必须是 `'2026-07-01'`、end `'2026-07-31'``getPeriodRange('weekly', ...)`/`yearly` 各补一例(先读现有测试风格与函数实际行为写期望值——若修复改变了现有行为导致旧测试失败,停止报告 BLOCKED)。
- [ ] **Step 5: handleDuplicate 带 tags**
`src/app/(tabs)/transactions.tsx` 的 handleDuplicate draftJson 加 `tags: tx.tags`;同时检查 `NumpadSheet.tsx` 的 draftJson 预填 effect——目前不解析 tags,参照 editId 预填的 tags 回填逻辑(tags store 查名 → fallback TAG_COLORS[3])补上 draft.tags 解析(`Array.isArray(draft.tags)` 时)。cost/price 不带(hasExtras 逻辑已保证含 cost 交易从编辑入口进高级模式;复制是新建,丢 cost 属可接受简化,注释说明)。
- [ ] **Step 6: 验证**
Run: `npm run typecheck && npm test` → 全绿
---
### Task 6: P5 清零审计 + 全项目验收
- [ ] **Step 1: 清零审计(全部应无输出或仅剩允许项)**
```bash
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
grep -rnE "#[0-9A-Fa-f]{3}\b" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
grep -rn "fontFamily" src/ --include="*.tsx" # 仅 _error.tsx monospace
grep -rn "📊\|💰\|💸\|📝\|🔄\|🎉\|✨" src/ --include="*.ts" --include="*.tsx" # 无输出
grep -rn "MonthlyReport\|SpeedDial" src/ # 无输出
grep -rn "arrow-back" src/app/ # 无输出(ScreenHeader 内部除外)
grep -rn "WEEKDAYS" src/components/ # 无输出(全部走 i18n
```
- [ ] **Step 2: 全量测试 + typecheck**
Run: `npm test` → 全绿
Run: `npm run typecheck` → 无错误
- [ ] **Step 3: 手工走查(需设备/模拟器)**
Run: `npm run android`
走查清单(浅/暗双主题):
1. 6 个管理页(分类/预算/规则/周期/信用卡/备注模板):+新增、点按编辑、删除(recurring 为显式按钮,其余长按)、空态文案
2. account 页:类型 Tab、开户、调余额、关户
3. settings/sync、ai、preferencesheader 统一有返回箭头
4. **+按钮默认开全局面板**(新装/清数据后):任意页面弹 modal;输入金额后点遮罩/Android 返回 → 弹「放弃修改」确认;无输入直接关
5. 设置→偏好里开关关闭 → +回到跳整页
6. 交易页左滑复制带标签的交易 → 标签预填
7. 报表月 Tab 日历、年报节奏图、TrendLine/CategoryPie 显示正常
8. 英文语言:日期选择器/日历星期头为英文缩写
---
## 完成后的项目状态
P1–P5 全部交付,spec §8 验收标准全达成。剩余已知非阻塞项(不修,记录):TransactionCard memo 被内联 onPress 击穿(性能);todayStr useMemo 跨午夜不刷新;NumpadSheet page 模式无可见返回按钮(spec 允许);_error.tsx monospace 保留(堆栈对齐)。
+317
View File
@@ -0,0 +1,317 @@
# UI 重设计 P6:原生浮层(悬浮球/悬浮窗)重设计 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。
**Goal:** 原生浮层(FloatingHelper 悬浮球 / FloatingBillView 浮窗 / FloatingTip 提示)视觉与文案对齐新设计系统:颜色与文案由 JS 层(theme tokens + i18n)通过 bridge 下发,原生不再硬编码中文与旧靛蓝;悬浮球位置持久化;浮窗补币种字段;前台账单 Alert i18n 化并去 emoji。
**背景决策(已确认):**
- 原生 View 无法直接用 RN theme,采用 **`FloatingUiConfig` 下发模式**JS 侧从当前 theme + i18n 构建 config → bridge `setFloatingUiConfig()` → 原生存 SharedPreferencesJSON)→ 三个浮层组件读取。原生保留现有硬编码值作为**默认值兜底**(JS 未推送时行为不变)。
- 浮窗卡片**跟随 App 主题**(浅色=白卡近黑 accent,深色=OLED 黑卡白 accent),卡片用实色(不再半透明磨砂),保证在其他 App 上的可读性。
- 浮窗金额校验从 `toDoubleOrNull` 改正则 + BigDecimal;自动消失 15s → 30s。
- 改动范围:`plugins/accessibility/`Kotlin+ `src/services/`bridge/config+ `src/app/_layout.tsx` + `src/services/automationPipeline.ts` + `src/i18n/`。**不碰 domain 写路径。**
---
## FloatingUiConfig 契约(T1-T5 共同遵守,先读这里)
### JS → 原生:`setFloatingUiConfig(config: ReadableMap): Promise<boolean>`
```typescript
interface FloatingUiConfig {
colors: {
accent: string; // 按钮/选中态背景(= theme accentlight #111318 / dark #F3F4F6
accentFg: string; // accent 上的文字色(= fgInverse
cardBg: string; // 卡片底色(= bgSecondary
inputBg: string; // 输入框/未选中 chip 底色(= bgTertiary
fgPrimary: string;
fgSecondary: string;
border: string;
income: string; // financial.income
expense: string; // financial.expense
transfer: string; // financial.transfer
};
labels: {
billTitle: string; // 浮窗标题
dirExpense: string; dirIncome: string; dirTransfer: string;
amountLabel: string; payeeLabel: string;
narrationLabel: string; narrationHint: string;
categoryExpense: string; // 「交易分类」
categoryIncome: string; // 「收入分类」
transferTarget: string; // 「转入账户」
accountExpense: string; // 「资金来源」
accountIncome: string; // 「存入账户」
accountTransfer: string; // 「转出账户」
openApp: string; dismiss: string; confirm: string;
ballOcr: string; // 悬浮球「识别账单」
ballRemember: string; // 「记住此页」
rememberSuccess: string; // 记住页面成功提示(不含 emoji)
rememberFail: string; // 失败提示前缀(原生拼接 ': ' + e.message
pageRemembered: string; // BillingAccessibilityService Toast「已记住页面签名」前缀
pageSignatureExists: string; // 「该页面签名已存在」前缀
};
}
```
- 颜色一律 `"#RRGGBB"` 六位数 hextheme tokens 里有 rgba 的字段不要直接下发——accent/cardBg/fg/border 等都是纯色,确认无 rgba;border 深色主题是 `rgba(255,255,255,0.08)`,下发前换算为 hex:深色用 `#14202429`? **不许发明值**——正确做法:原生 `Color.parseColor` 支持 `#AARRGGBB`JS 侧把 rgba(255,255,255,0.08) 换算为 `#14FFFFFF`alpha 0.08≈0x14)。写一个 `rgbaToHex` 小工具处理这种情况,纯色 hex 原样通过)。
- 原生端用 `Color.parseColor` 解析,异常时回退默认值。
### 原生持久化
- SharedPreferences 文件名:`floating_ui_config`,单键 `config_json` 存整个 JSON。
- 新增 `FloatingUiConfigStore.kt`object):`save(context, ReadableMap)` / `load(context): FloatingUiConfig`data class,字段缺省值=现状硬编码值,保证旧 JS 行为不变)。
- `FloatingUiConfig` data class 字段名与上面契约一致(camelCase)。
### 事件契约变更(T3+T5
- `showFloatingBill` 增加参数 `currency: String`(放在 `direction` 之后、`draftId` 之前)。
- `billingConfirmed` / `billingOpenApp` 事件 map 增加 `putString("currency", currentCurrency)`
- 浮窗金额行左侧显示当前币种 chip,点击在 `listOf("CNY","USD","HKD","JPY","EUR","GBP")` 中循环切换;初始值 = 传入 currency(不在列表则插入到首位)。
---
### Task 1: JS 侧 — i18n 键 + floatingUiConfig 服务 + bridge 接口 + 推送接线 + 前台 Alert i18n
**Files:**
- Modify: `src/i18n/zh.ts``src/i18n/en.ts``src/services/accessibilityBridge.ts``src/app/_layout.tsx``src/services/automationPipeline.ts`
- Create: `src/services/floatingUiConfig.ts``tests/floating-ui-config.test.ts`
- [ ] **Step 1: i18n 新增 `floating.*` 键组(zh/en 对等,`%{name}` 插值语法)**
```
floating.billTitle 调整交易草稿 / Adjust Draft
floating.dirExpense 支出 / Expense (复用现有方向键也行,但浮层独立一组避免耦合)
floating.dirIncome 收入 / Income
floating.dirTransfer 转账 / Transfer
floating.amountLabel 金额 / Amount
floating.payeeLabel 交易对手 / Payee
floating.narrationLabel 描述/备注 / Narration
floating.narrationHint 输入交易叙述 / Enter narration
floating.categoryExpense 交易分类 / Category
floating.categoryIncome 收入分类 / Income Category
floating.transferTarget 转入账户 / To Account
floating.accountExpense 资金来源 / From Account
floating.accountIncome 存入账户 / To Account
floating.accountTransfer 转出账户 / From Account
floating.openApp 打开应用 / Open App
floating.dismiss 忽略 / Dismiss
floating.confirm 确认入账 / Confirm
floating.ballOcr 识别账单 / Scan Bill
floating.ballRemember 记住此页 / Remember Page
floating.rememberSuccess 已将当前页面加入识别白名单 / Page added to recognition whitelist
floating.rememberFail 记录失败 / Failed to save
floating.pageRemembered 已记住页面签名 / Page signature saved
floating.pageSignatureExists 该页面签名已存在 / Page signature already exists
```
(若现有键已有同文案可复用,但键组保持独立 `floating.*`;先 grep 避免重复键名。)
同时给前台 Alert 加键(放 `automation.*` 下,先读现有 automation 节):
```
automation.billDetectedTitle 识别到新账单 / New Bill Detected (无 emoji
automation.billDetectedReject 拒绝/丢弃 / Discard
automation.billDetectedEdit 修改并入账 / Edit & Save
automation.billDetectedConfirm 确认入账 / Confirm
```
Alert 的多行消息体(日期/商户/金额/分类/账户/叙述)逐行用既有标签键拼,不为每行加新键(行标签可复用表单/详情现有键,先 grep `transaction.payee` 之类;没有合适的就在 automation 节加 `billDetectedLine*` 键)。
- [ ] **Step 2: `src/services/floatingUiConfig.ts`**
```typescript
export interface FloatingUiConfig { colors: {...}; labels: {...} } // 按契约
/** rgba(r,g,b,a) → #AARRGGBB#RRGGBB 原样返回。 */
export function colorToHex(color: string): string;
/** 从 theme tokens + t() 构建完整 config。 */
export function buildFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): FloatingUiConfig;
/** 构建并推送到原生(bridge 不可用时静默返回 false)。 */
export async function pushFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): Promise<boolean>;
```
- [ ] **Step 3: bridge 接口扩展**
`accessibilityBridge.ts``NativeAccessibilityBridge``setFloatingUiConfig(config: FloatingUiConfig): Promise<boolean>;`,文件头注释方法清单补一行。`showFloatingBill` 签名加 `currency: string`direction 之后、draftId 之前)。
- [ ] **Step 4: _layout.tsx 推送接线**
- 启动时(setFloatingBallEnabled 同一处,`src/app/_layout.tsx:139` 附近)也 `pushFloatingUiConfig(...)`。注意此处拿不到 useTheme 的 theme(在 ThemeProvider 外层?先读 _layout 结构确认)——若拿不到,启动这次推送可移到下一步的组件里统一做,避免重复。
- 在 ThemeProvider **内部**挂一个小组件(如 `FloatingUiConfigSyncer`,可直接写在 _layout.tsx 里):`const { theme } = useTheme(); const t = useT(); useEffect(() => { pushFloatingUiConfig(theme, t); }, [theme, t])` —— 主题切换/语言切换/启动都会重推。t 引用随 locale 变化(确认 useT 返回的 t 在语言切换时引用变化,先读 src/i18n 实现;若 t 引用稳定,则依赖里加 locale)。
- [ ] **Step 5: automationPipeline.ts 前台 Alert i18n + 去 emoji**
`src/services/automationPipeline.ts:298``Alert.alert('🌟 识别到新账单', ...)`:标题/按钮全部改 t();消息体保留原信息结构。该文件是 service 层非组件——确认文件里如何拿 t(若没有,用 `i18n.t(...)` 直接调,读 src/i18n/index.ts 导出了什么)。`showFloatingBill` 调用处(:262)传第 7 个参数 `currency`(用 :225 已提取的 currency 变量)。
- [ ] **Step 6: 测试 + 验证**
`tests/floating-ui-config.test.ts`
- `colorToHex``'#111318'` 原样;`'rgba(255,255,255,0.08)'``#14FFFFFF``'rgba(17,19,24,0.4)'``#66111318`alpha 四舍五入 Math.round(a*255)
- `buildFloatingUiConfig`:用 lightTheme + 真实 zh t() 构建,断言 colors.accent === '#111318'、labels.billTitle === '调整交易草稿'、所有契约字段非空(遍历 keys)
Run: `npm run typecheck && npm test` → 全绿(i18n parity 测试覆盖新键)
---
### Task 2: 原生 config 基础设施(FloatingUiConfigStore + bridge 方法 + FloatingTip/Toast 改读)
**Files:**
- Create: `plugins/accessibility/android/FloatingUiConfigStore.kt`
- Modify: `plugins/accessibility/android/AccessibilityBridgeModule.kt``plugins/accessibility/android/FloatingTip.kt``plugins/accessibility/android/BillingAccessibilityService.kt`
- [ ] **Step 1: FloatingUiConfigStore.kt**
- `data class FloatingUiConfig(...)`colors/labels 全部字段,默认值 = 现状硬编码(accent `#5E6AD2`、cardBg `#FF050506`? —— **不**:默认值应是「现状视觉」,但现状 cardBg 是 0x8C050506 半透明。默认 cardBg 用 `#F2050506`? 简化:默认值就用现状各硬编码颜色换算成 `#AARRGGBB`/`#RRGGBB` 字符串,逐字段列注释对应原 Kotlin 常量)。labels 默认值 = 现状中文硬编码文案(去 emoji)。
- `object FloatingUiConfigStore``private const val PREFS = "floating_ui_config"``fun save(context: Context, map: ReadableMap)`(遍历契约字段,缺失字段跳过不覆盖,JSONObject 组装后写入);`fun load(context: Context): FloatingUiConfig`JSONObject 读取,缺失/解析失败逐字段回退默认;`optString`)。颜色字段提供 `fun parseColorOr(value: String, fallback: Int): Int` 工具(`Color.parseColor` try/catch)。
- org.json 可用(Android 内置),无需新依赖。
- [ ] **Step 2: AccessibilityBridgeModule.setFloatingUiConfig**
```kotlin
@ReactMethod
fun setFloatingUiConfig(config: ReadableMap, promise: Promise) {
try {
FloatingUiConfigStore.save(reactContext, config)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("CONFIG_SAVE_FAIL", e.message)
}
}
```
- [ ] **Step 3: FloatingTip/RepeatToast 改读 config**
- FloatingTip.show():背景色 `0xF0333333` → config accent(不透明化:解析后 `or 0xFF000000.toInt()`),文字色 → accentFgProgressBar `progressTintList` 设 accentFg。构造签名加可选 `config: FloatingUiConfig? = null`null 时内部 `FloatingUiConfigStore.load(context)`
- RepeatToast 的 `"⚠ $message"` 前缀 emoji 去掉,只留 message(⚠ 属 emoji 审计范围)。
- FloatingTip 调用处(FloatingHelper.kt:209/211)改传 config 文案:`labels.rememberSuccess``"${labels.rememberFail}: ${e.message}"`(T4 做也可以,此处先改文案读取,FloatingHelper 的彻底重设计在 T4)。
- [ ] **Step 4: BillingAccessibilityService 两处 Toast 文案**
:387 `"已记住页面签名:\n$sig"` 与 :419 `"该页面签名已存在:\n$sig"` → 改读 `FloatingUiConfigStore.load(this).labels` 的 pageRemembered / pageSignatureExists + `":\n$sig"`
- [ ] **Step 5: 编译验证**
`android/` 已生成。把改动的 .kt 手动拷到 `android/app/src/main/java/com/beancount/mobile/accessibility/`(与 plugins 目录同名文件覆盖),然后:
Run: `cd android && ./gradlew :app:compileDebugKotlin -q` → BUILD SUCCESSFUL
(若编译环境不可用,记录为设备验证项并在报告中说明。)
---
### Task 3: FloatingBillView 重设计 + 币种
**Files:**
- Modify: `plugins/accessibility/android/FloatingBillView.kt``plugins/accessibility/android/AccessibilityBridgeModule.kt`showFloatingBill 加 currency 参数透传)
**视觉规格(对齐 docs/ui-redesign-design.md §3 Bento):**
- 卡片:实色 cardBg、圆角 16dp、1dp border 描边、padding 16dp(原 12/8 加宽)
- 标题:fgPrimary 13sp bold;分段选择器:选中=accent 底 accentFg 字,未选中=透明 fgSecondary 字,圆角 6dp
- 字段标签:fgSecondary 10sp bold;输入框:inputBg 底、圆角 10dp、1dp border、fgPrimary 字、hint fgSecondary
- chips:圆角 999(用 dp(14f) 近似胶囊)、未选中=inputBg+border 描边+fgSecondary 字、选中:分类行支出/收入=accent;转账方向第一行=transfer 色;账户行按方向=income/expense 色(语义色保留,但改从 config 读)
- 按钮:高 40dp、圆角 12dpconfirm=accent 底 accentFg 字 boldopenApp/dismiss=inputBg 底 fgPrimary 字
- 所有颜色经 `FloatingUiConfigStore.load(context)` + parseColorOr 兜底;所有文案经 labels
- [ ] **Step 1: 构造签名 + showFloatingBill 参数**
`FloatingBillView` 构造加 `initialCurrency: String = "CNY"``AccessibilityBridgeModule.showFloatingBill``currency: String` 参数(direction 后 draftId 前)并透传。
- [ ] **Step 2: 币种 chip**
金额行:水平布局,左侧币种 chipinputBg+borderfgPrimary 字,显示 currentCurrency),右侧金额输入框(weight 1)。点击 chip 在 `listOf("CNY","USD","HKD","JPY","EUR","GBP")` 循环(initialCurrency 不在列表则临时插到首位)。sendSaveEvent/sendOpenAppEvent 的 map 加 `putString("currency", currentCurrency)`
- [ ] **Step 3: 金额校验改正则 + BigDecimal**
`afterTextChanged``Regex("^\\d+(\\.\\d{1,2})?$")` 匹配且 `BigDecimal(text) > BigDecimal.ZERO` 才启用保存(BigDecimal 构造 try/catch)。禁用时按钮底色 inputBg + fgSecondary 字。
- [ ] **Step 4: 全面替换颜色与文案 + 自动消失 30s**
逐段按上面视觉规格重写 show() 与 rebuildChips()(结构不变,只换色值来源、文案来源、尺寸);`15000L``30000L`。标签按方向切换的文案(交易分类/收入分类/转入账户、资金来源/存入账户/转出账户)全部走 labels。
- [ ] **Step 5: 编译验证**(同 T2 Step 5 流程)
---
### Task 4: FloatingHelper 重设计 + 位置持久化
**Files:**
- Modify: `plugins/accessibility/android/FloatingHelper.kt`
- [ ] **Step 1: 颜色与文案走 config**
- 指示竖线:accent(保持 70% 透明度:解析后 alpha 设为 0xB0
- 菜单:实色 cardBg、圆角 12dp、1dp border
- 「识别账单」按钮:accent 底 accentFg 字(主操作);「记住此页」:inputBg 底 fgPrimary 字;按压态透明度变化保留(按下时 alpha 0.8)
- ScanIconDrawable/PinIconDrawable 颜色:Scan 用 accentFg(在 accent 底按钮上),Pin 用 fgSecondary
- 文案 labels.ballOcr / ballRemember / rememberSuccess / rememberFail(带 ': ' + message 拼接)
- [ ] **Step 2: 位置持久化**
- SharedPreferences 复用 `billing_accessibility_prefs`:键 `floating_ball_x` / `floating_ball_y`
- `show()` 初始化:`params.x/y = prefs.getInt(...)`,无键时用现状默认(0/400);拖动抬起(吸附后)写 prefs
- companion 的 lastX/lastY 保留为进程内缓存但初始从 prefs 读(或直接用局部变量+prefs,简化则删 companion——注意多实例语义,BillingAccessibilityService 单例,安全)
- [ ] **Step 3: 编译验证**(同 T2 Step 5 流程)
---
### Task 5: 币种链路 JS 消费侧
**Files:**
- Modify: `src/services/automationPipeline.ts``src/app/_layout.tsx`
- [ ] **Step 1: billingConfirmed 处理器用 res.currency**
`src/services/automationPipeline.ts:50-110` 的 handleBillingConfirmed`const currency = pending?.event?.currency || 'CNY'`:78)→ 优先 `res.currency`(浮窗用户改过),`res.currency || pending?.event?.currency || 'CNY'`。确认 res 类型定义处加 currency 字段(NativeOpenAppEvent 之类,grep 类型声明一起改)。
- [ ] **Step 2: billingOpenApp 处理器**
`src/app/_layout.tsx:255` `const currency = 'CNY'``const currency = res.currency || 'CNY'`
- [ ] **Step 3: 验证**
Run: `npm run typecheck && npm test` → 全绿
---
### Task 6: P6 审计 + 验收
- [ ] **Step 1: 审计**
```bash
grep -rn "0xFF5E6AD2\|0x995E6AD2\|0xB05E6AD2\|0x1F5E6AD2\|0x405E6AD2" plugins/accessibility/android/ # 无输出(旧靛蓝清零,默认值除外——FloatingUiConfigStore 默认值允许保留并注释)
grep -rn '"[^"]*[一-鿿]' plugins/accessibility/android/*.kt | grep -v "FloatingUiConfigStore\|//" # 除 Store 默认值与注释外无硬编码中文 UI 字符串
grep -rn "⚠\|📌\|🌟" src/ plugins/ # 无输出
grep -n "currency" src/services/accessibilityBridge.ts # showFloatingBill 含 currency
```
- [ ] **Step 2: 全量测试 + typecheck + Kotlin 编译**
Run: `npm test` → 全绿;`npm run typecheck` → 无错误;`cd android && ./gradlew :app:compileDebugKotlin -q` → 成功
- [ ] **Step 3: 手工走查(需设备)**
Run: `npm run android`
1. 设置里切换 浅/深主题 → 触发一次浮窗(可用 automation 页调试入口或真实账单)→ 浮窗颜色跟随主题
2. 切换英文 → 浮窗/悬浮球/提示文案全英文
3. 拖动悬浮球 → 杀进程重启无障碍服务 → 位置保持
4. 浮窗点币种 chip 切换 USD → 确认入账 → 交易币种为 USD
5. 金额输入 `1e10` / `0` / `0.005` → 保存按钮禁用;`12.50` → 可用
6. 浮窗 30 秒无操作自动消失;触摸后不消失
---
## 终审修订(已实施,以实际代码为准)
**终审日期**2026-07-22,审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
| # | 级别 | 问题 | 修复 |
|---|---|---|---|
| C1 | Critical | FloatingBillView 标题 `setTextColor(colorAccentFg)` — 浅色主题下白卡白字、深色主题下黑底黑字,标题不可见 | 改为 `colorFgPrimary` |
| I1 | Important | 容器边框从 accent 派生 alpha 而非用 config.border | 删除 `colorContainerBorder`,直接使用 `colorBorder` |
| I2 | Important | FloatingHelper btnRemember 背景从 fgPrimary 派生,计划要求 inputBg | 加 `colorInputBg` 解析,btnRemember 改从 inputBg 派生 |
| I3 | Important | 悬浮球菜单背景半透明(65% cardBg),计划要求实色 | 菜单背景直接使用 `colorCardBg`full opacity),仅描边保留半透明 |
| M1 | Minor | FloatingTip 背景未强制不透明化(plan §T2 Step 3 | 加 `or 0xFF000000.toInt()` 保护 |
| M2 | Minor | 金额校验先 BigDecimal 解析再正则,正则先匹配可早期短路 | 调整为先 `pattern.matches(text)``value != null && value > BigDecimal.ZERO` |
**偏差说明(无需修复)**
- FloatingHelper 菜单背景 / 按钮背景 / 指示线的 alpha 分量组合(`(colorX and 0x00FFFFFF) or alpha`)是原生浮层叠加其他 App 所必需的透明度控制,不是颜色硬编码。颜色 RGB 分量完全来自 config。
- FloatingTip 默认 fallback `#FF000000` 是纯黑(非靛蓝),因为 accent 在极浅色主题下是 `#111318`(近黑),fallback 用黑色是安全的极限退化。
- ScanIconDrawable laserColor `0xFFF87171` 保留为语义色(扫描红光),不受主题控制。
+192
View File
@@ -0,0 +1,192 @@
# P7:权限快捷引导 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax. **不执行任何 git 操作**(用户要求,改动留工作区)。不创建额外任务清单。
**Goal:** 引导页权限步骤从纯文字改可操作状态清单;automation 页补齐通知监听/短信/存储三项权限的状态检测与快速跳转按钮。不新增原生权限,只加 UI 入口和一座通知监听状态检测 bridge 方法。
---
## 背景决策
- **不新增权限**。所有权限已在 AndroidManifest 中声明(通过 Config Plugin),P7 只加 UI 入口。
- **通知监听状态检测**需要一个原生 bridge 方法(`isNotificationListenerEnabled`),因为 RN 侧无法直接检查。用 `NotificationManager.getEnabledListenerPackages()`
- **短信 + 存储**用 React Native 内置 `PermissionsAndroid` API。
- **引导页 UI** 沿用现有 Card + Button 组件,与 P1-P5 设计系统一致。
---
### Task 1: 新增桥接方法 `isNotificationListenerEnabled`
**Files:**
- Modify: `plugins/accessibility/android/AccessibilityBridgeModule.kt`
- Modify: `src/services/accessibilityBridge.ts`
- [ ] **Step 1: AccessibilityBridgeModule.kt 加方法**
```kotlin
@ReactMethod
fun isNotificationListenerEnabled(promise: Promise) {
try {
val pm = reactContext.packageManager
val enabledListeners = android.app.NotificationManager::class.java
.getMethod("getEnabledListenerPackages")
.invoke(reactContext.getSystemService(Context.NOTIFICATION_SERVICE)) as? List<String>
val myPkg = reactContext.packageName
promise.resolve(enabledListeners?.any { it.startsWith(myPkg) } == true)
} catch (e: Exception) {
promise.resolve(false)
}
}
```
**注意**`getEnabledListenerPackages()` 在 Android 11+ 需 `isNotificationListenerEnabled(ComponentName)`;优先用反射避免 API level lint 错误,反射失败返回 false。
- [ ] **Step 2: JS 侧 bridge 接口**
`NativeAccessibilityBridge``isNotificationListenerEnabled(): Promise<boolean>;`
- [ ] **Step 3: 编译验证**
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
---
### Task 2: 引导页权限步骤(`_onboarding.tsx` 重写 permissions 步骤)
**Files:**
- Modify: `src/app/_onboarding.tsx`
- Modify: `src/i18n/zh.ts``src/i18n/en.ts`(少量新键)
- [ ] **Step 1: i18n 新键(zh/en 对等)**
`onboarding` section 加:
```
permAccessibility 无障碍服务 / Accessibility Service
permNotification 通知监听 / Notification Listener
permOverlay 悬浮窗 / Overlay
permSms 短信读取 / SMS
permStorage 相册/存储 / Photos & Storage
permGranted 已授权 / Granted
permNotGranted 未授权 / Not Granted
permOpenSettings 前往设置 / Open Settings
permRequest 请求权限 / Request
```
- [ ] **Step 2: 重写 permissions 步骤**
替换 `_onboarding.tsx:48``key: 'permissions'` 步骤内容。在当前 description 下方加权限状态清单(Card 包裹,每行:图标 + 名称 + 状态点 + 操作按钮)。
权限列表:
1. **无障碍服务**`BIND_ACCESSIBILITY_SERVICE`)—— 最核心。检测 `serviceRunning`(从 `getAccessibilityBridge().isServiceRunning()` 异步取)。未授权 → 跳转 `ACCESSIBILITY_SETTINGS`
2. **通知监听**`BIND_NOTIFICATION_LISTENER_SERVICE`)—— 检测 `bridge.isNotificationListenerEnabled()`。未授权 → 跳转 `ACTION_NOTIFICATION_LISTENER_SETTINGS`
3. **短信**`RECEIVE_SMS`)—— `PermissionsAndroid.check()`。未授权 → `PermissionsAndroid.request()`
4. **存储**`READ_MEDIA_IMAGES`Android 13+ 用 `READ_MEDIA_IMAGES`,否则 `READ_EXTERNAL_STORAGE`)—— 同上
**实现细节**
- `const [permStates, setPermStates] = useState<Record<string, boolean | null>>({})` —— null = 加载中
- `useEffect(() => { checkAllPermissions(); }, [])` —— 步骤切到 permissions 时触发
- `checkAllPermissions`:调 `PermissionsAndroid.check()` 检查短信和存储;调 bridge 检查无障碍和通知监听
- 每行渲染:`Ionicons` 图标 + `permLabel` + 右侧状态文字(绿「已授权」/ 灰「未授权」)+ 未授权时显示操作按钮
- 「全部已授权」时显示一个大绿色勾 + 提示文字,按钮不显示
**权限 API 版本适配**
- `PermissionsAndroid.PERMISSIONS` 里 Android 13+ 是 `READ_MEDIA_IMAGES`,低版本用 `READ_EXTERNAL_STORAGE`
- 统一用 `Platform.Version >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE'`
- [ ] **Step 3: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -rn "onboarding.perm" src/i18n/zh.ts src/i18n/en.ts` → 确认键对等
---
### Task 3: automation 页补齐权限入口
**Files:**
- Modify: `src/app/automation/index.tsx`
- Modify: `src/i18n/zh.ts``src/i18n/en.ts`(少量新键)
- [ ] **Step 1: i18n 新键**
`automation` section 加:
```
notificationTitle 通知监听服务 / Notification Listener
notificationEnabled 已启用 / Enabled
notificationDisabled 未启用 / Disabled
notificationOpenSettings 前往系统设置 / Open Settings
smsPermissionTitle 短信读取权限 / SMS Permission
smsPermissionGranted 已授权 / Granted
smsPermissionRequest 请求权限 / Request
storagePermissionTitle 存储权限 / Storage Permission
storagePermissionGranted 已授权 / Granted
storagePermissionRequest 请求权限 / Request
```
- [ ] **Step 2: automation 页加权限卡片**
在现有「无障碍服务状态与控制」Card **之后**加一个新 Card`<Card title={t('automation.otherPermissions')}>`
内容三行:
1. **通知监听**:异步 `bridge.isNotificationListenerEnabled()` → 状态文字 + 未启用时「前往系统设置」按钮(`Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS')`
2. **短信**`PermissionsAndroid.check('android.permission.RECEIVE_SMS')` + `PermissionsAndroid.request()`
3. **存储**`PermissionsAndroid.check(StoragePermission)` + `PermissionsAndroid.request()`
**实现模式**
```tsx
const [notifEnabled, setNotifEnabled] = useState(false);
const [smsGranted, setSmsGranted] = useState(false);
const [storageGranted, setStorageGranted] = useState(false);
useEffect(() => {
// 异步获取各权限状态
checkNotif(); checkSms(); checkStorage();
}, []);
```
每行渲染:`Ionicons` 图标 + 权限名称 + 状态文字 + 未授权时的按钮。
- [ ] **Step 3: 验证**
Run: `npm run typecheck && npm test` → 全绿
---
### Task 4: 编译 + 测试 + 终审
- [ ] **Step 1: 编译验证**
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
- [ ] **Step 2: 全量测试**
`npm test` → 全绿;`npm run typecheck` → 0 错误
- [ ] **Step 3: 审计**
```bash
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
grep -rn "fontFamily" src/app/_onboarding.tsx src/app/automation/index.tsx # 无输出
grep -rnE "📊|💰|💸|📝|🔄|🎉|✨|⚠|📌|🌟" src/i18n/ src/app/_onboarding.tsx src/app/automation/index.tsx # 无输出(预存 i18n fallbackWarn ⚠ 除外)
```
- [ ] **Step 4: 手工走查(需设备)**
1. 清数据 → 启动应用 → 引导页第 5 步(权限)→ 看到 4 个权限状态
2. 点未授权的无障碍 → 跳系统无障碍设置 → 返回看到状态已变
3. 点未授权的通知监听 → 跳系统通知设置
4. 点短信 → 弹出系统权限对话框
5. 引导页全部完成后 → 进入 automation 页 → 看到新增的权限卡片
---
## 终审修订(已实施,以实际代码为准)
**终审日期**2026-07-22,审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
| # | 级别 | 问题 | 处理 |
|---|---|---|---|
| — | — | 无 Critical / Important 发现 | — |
**偏差说明**
1. `onboarding.permOverlay`(悬浮窗)未加入引导页——计划 Task 2 决策列为可选项,实际未实现。不影响核心功能(sideload 自动授予 + 无障碍运行时浮窗走 ACCESSIBILITY_OVERLAY 不依赖此权限)。
2. T3 中 `Platform.Version >= 33` 改为 `Number(Platform.Version) >= 33`——React Native 的 `Platform.Version` 类型为 `string | number`,直接比较会 TS 报错。
3. T1 的 `isNotificationListenerEnabled` 使用反射而非 `enabledListenerPackages` 属性——minSdk=24 < API 27,反射是兼容方案。
+338
View File
@@ -0,0 +1,338 @@
# P8:自动记账可配置化 + 模型按需下载 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git 操作**。不创建额外任务清单。
**Goal:** automation 页统一管控所有自动记账能力(规则/OCR/AI Vision 三层独立开关 + 模型下载管理 + AI 配置整合),`settings/ai.tsx` 删除并重定向;OCR 模型从 APK assets 改为应用内下载(filesystem 路径加载 ONNX)。
**背景决策**
- 三层开关默认值:L1 规则 `true`、L2 OCR `true`、L3 AI Vision `false`(需用户配 API Key 后才可开启)
- 模型下载:首次启用 L2 时检查模型是否存在,无则弹出下载提示
- `settings/ai.tsx` 删除,原入口重定向到 automation 页
- 去重/转账识别两个 setting 字段不动(它们不是「层」而是管道的独立步骤),但在 automation 页加开关
- 模型从 assets 复制到 filesystem:预构建时仍打 APK assets 以兼容旧用户,应用首次启动将模型文件从 assets 拷贝到 documentDirectoryOcrModule 从 filesystem 加载。**这样之后卸载 assets 打包就变成了纯删除**(未来版本切到纯下载只需移除 assets + app.plugin.js 禁拷贝)。
---
### Task 1: settingsStore 新增字段 + 迁移
**Files:** `src/store/settingsStore.ts`
- [ ] **Step 1: 接口 + DEFAULTS + 持久化 + setter**
`SettingsState` 接口加 5 个新字段:
```typescript
/** 自动记账 L1:正则规则匹配 */
layer1RuleEnabled: boolean;
/** 自动记账 L2:本地 OCR 识别 */
layer2OcrEnabled: boolean;
/** 自动记账 L3:云端 AI Vision */
layer3AiEnabled: boolean;
/** OCR 模型版本(已下载),空字符串表示未安装 */
ocrModelVersion: string;
/** OCR 模型下载路径 */
ocrModelDir: string;
```
DEFAULTS
- `layer1RuleEnabled: true`
- `layer2OcrEnabled: true`
- `layer3AiEnabled: false`
- `ocrModelVersion: ''`
- `ocrModelDir: ''`
`PersistableSettings` 加对应 5 字段;`toPersistable` 加 5 行;新增 5 个 setter 函数。现有 `aiEnabled` 保留作为 LLM 的总开关语义(`layer3AiEnabled` 是在 automation 页独立控制,「启用」需同时满足 `aiEnabled && aiApiKey`)。
**注意**`isNotificationListenerEnabled` 这类 bridge 方法在 `setFloatingBallEnabled` 附近——新字段与它们无关,不加 bridge 方法。
- [ ] **Step 2: 验证**
`npm run typecheck` → 0 错误;`npm test` → 全绿
---
### Task 2: OcrProcessor 逐级回退逻辑
**Files:** `src/domain/ocrProcessor.ts``src/services/ocrBridge.ts``src/services/automationPipeline.ts`
- [ ] **Step 1: OcrProcessorConfig 扩展**
`OcrProcessorConfig` 接口加:
```typescript
layer1Enabled: boolean; // 默认 true
layer2Enabled: boolean; // 默认 true
layer3Enabled: boolean; // 默认 false
```
- [ ] **Step 2: doProcess() 逐级跳过**
`OcrProcessor.doProcess()` 中修改流程(约 :103-162):
```
// 1. 横屏 / 图片去重 守卫不变
// 2. L1+L2 需要 OCR 文本
if (config.layer1Enabled || config.layer2Enabled) {
if (!ocrEngine) 返回 none(引擎不可用)
ocrText = await ocrEngine.recognizeText(imageBase64)
if (!ocrText) → 如果 L3 启用则 goto L3,否则返回 none
} else {
ocrText = ''
}
// 3. Layer 1(仅当启用)
if (config.layer1Enabled && ocrText && !isDetailPage) {
result = matchOcrRule(ocrText, packageName)
if (result) return { layer: 'layer1-rule', ... }
}
// 4. Layer 2(仅当启用)
if (config.layer2Enabled && ocrText) {
result = parseOcrBill(ocrText, packageName)
if (result) return { layer: 'layer2-ocr', ... }
}
// 5. Layer 3(仅当启用)
if (config.layer3Enabled) {
return tryLayer3(imageBase64, ocrText)
}
// 6. 全部未命中或全部关闭
return { event: null, layer: 'none', skipped: 'non-bill' }
```
- [ ] **Step 3: automationPipeline.ts 的 getOcrProcessor 改读新字段**
`getOcrProcessor()` 约 :116-147 行:从 `settingsStore` 读取 `layer1RuleEnabled` / `layer2OcrEnabled` / `layer3AiEnabled` 传入 `OcrProcessorConfig`
- [ ] **Step 4: 验证**
`npm run typecheck` → 0 错误;`npm test` → 全绿
---
### Task 3: 模型文件从 assets 拷贝到 filesystem + OcrModule 改路径
**Files:** `plugins/ppocr/android/OcrModule.kt``plugins/ppocr/app.plugin.js``plugins/accessibility/android/AccessibilityBridgeModule.kt`(新 bridge 方法)
- [ ] **Step 1: AccessibilityBridgeModule 加 copyModelFromAssets 方法**
```kotlin
@ReactMethod
fun copyOcrModelsFromAssets(promise: Promise) {
try {
val assetManager = reactContext.assets
val destDir = java.io.File(reactContext.filesDir, "ocr_models_v6")
if (!destDir.exists()) destDir.mkdirs()
val files = listOf("ppocrv6_det.onnx", "ppocrv6_rec.onnx", "ppocrv6_dict.txt")
for (filename in files) {
val destFile = java.io.File(destDir, filename)
if (destFile.exists()) continue // skip existing
val input = assetManager.open(filename)
val output = java.io.FileOutputStream(destFile)
val buffer = ByteArray(8192)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
}
input.close()
output.close()
}
promise.resolve(destDir.absolutePath)
} catch (e: Exception) {
promise.reject("COPY_MODEL_FAIL", e.message)
}
}
```
- [ ] **Step 2: OcrModule.kt 改为 filesystem 路径加载**
`OcrModule.kt``createSession` 调用的位置,改从传入路径加载而非 asset 文件名。当前构造可能接受 asset 路径——改为接收 filesystem 绝对路径。新增一个 `@ReactMethod setModelDir(dir: String)` 存储路径,或直接在 `initialize(assetManager: ...)` 的参数中改为 `initialize(modelDir: String, ...)`
**注意**ONNX Runtime Android 的 `OrtEnvironment.createSession(filePath)` 接受文件系统路径(String),不需要 asset 特殊处理——只需确认 `createSession` 的参数类支持文件路径,否则改用 `OrtEnvironment.createSession(byte[] modelBytes)` 从内存加载。**最终方案**:保持 `OrtEnvironment.createSession(filePath)` ,将 `modelDir + "/ppocrv6_det.onnx"` 作为绝对路径传入。
- [ ] **Step 3: JS 侧 bridge 接口 + modelDownloader 服务**
`NativeAccessibilityBridge``copyOcrModelsFromAssets(): Promise<string>` → 返回模型目录绝对路径。
新建 `src/services/modelDownloader.ts`
```typescript
export async function ensureOcrModels(): Promise<string> {
// 1. 如果 settingsStore.ocrModelDir 有效且文件存在 → 直接返回
// 2. 尝试从 assets 拷贝到 documentDirectory/ocr_models_v6
// 3. 返回 modelsDir 绝对路径,存储到 settingsStore.ocrModelDir + ocrModelVersion
}
```
`ocrModelVersion` 在从 assets 拷贝成功后写 `'v6-assets'`(区分下载版本和打包版本)。
- [ ] **Step 4: automationPipeline.ts 的 OcrProcessor 从 filesystem 初始化**
`getNativeOcrBridge()``getOcrProcessor()` 中,在初始化 OCR 引擎前调用 `ensureOcrModels()` 获取模型路径,传入 `NativeOcrBridge` 构造(或 OcrModule initialize)。
- [ ] **Step 5: 验证**
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
---
### Task 4: automation 页 UI —— 三层开关 + 模型管理 + AI 配置整合
**Files:** `src/app/automation/index.tsx``src/i18n/zh.ts``src/i18n/en.ts`
- [ ] **Step 1: i18n 新键(zh/en 对等)**
`automation` section 加:
```
autoBookkeeping 自动记账层级 / Auto Bookkeeping Layers
layer1Rule 规则匹配 / Rule Matching
layer1RuleDesc 基于正则规则的快速账单识别 / Fast regex-based bill recognition
layer2Ocr OCR 识别 / OCR Recognition
layer2OcrDesc 本地 PP-OCRv6 模型识别 / On-device PP-OCRv6 model
layer3Ai AI 视觉识别 / AI Vision
layer3AiDesc 云端多模态大模型识别 / Cloud multimodal LLM
layer3AiDisabled 请先配置 AI Key / Configure AI Key first
ocrModelTitle OCR 模型 / OCR Model
ocrModelNotInstalled 未安装 / Not Installed
ocrModelInstalled 已安装 ({version}) / Installed ({version})
ocrModelInstall 安装模型 / Install Model
ocrModelInstalling 安装中... / Installing...
ocrModelCopying 正在复制模型文件... / Copying model files...
dedupSwitch 多通道联合去重 / Cross-channel Dedup
transferSwitch 转账智能识别 / Transfer Recognition
```
- [ ] **Step 2: automation 页 UI 重组织**
在现有截图监控按钮 Card **之后**加以下新 Card 区块:
**Card A:「自动记账层级」**
- 三行 SwitchL1 规则匹配(always enabled,不依赖外部条件)
- L2 OCR 识别(Switch;开启时检查模型→无模型则弹下载提示 Dialog)
- L3 AI 视觉(Switch;如果 aiEnabled=false 或 aiApiKey 为空则 disabled+提示文字;开启后展开 Provider/Key/Model 输入)
**Card B:「OCR 模型」**
- 状态行:版本号(未安装显示灰色「未安装」;已安装显示绿色「已安装 (v6-assets)」)
- 操作按钮:安装/重新安装(从 assets 拷贝 → 进度状态 → 完成)
**Card C:「管道设置」**(复用现有 Card B 和 C 部分)
- `dedupEnabled` Switch
- `transferRecognitionEnabled` Switch
- [ ] **Step 3: 操作逻辑**
```typescript
// 模型安装
const handleInstallModel = async () => {
setModelStatus('installing');
try {
const bridge = getAccessibilityBridge();
if (!bridge) throw new Error('Bridge unavailable');
const modelDir = await bridge.copyOcrModelsFromAssets();
setOcrModelVersion('v6-assets');
setOcrModelDir(modelDir);
setModelStatus('done');
} catch (e) {
setModelStatus('error');
Alert.alert('安装失败', String(e));
}
};
// L2 开关变化时检查模型
const handleL2Toggle = (val: boolean) => {
setLayer2OcrEnabled(val);
if (val && !ocrModelVersion) {
Alert.alert(t('automation.ocrModelTitle'), t('automation.ocrModelNotInstalled'), [
{ text: t('common.cancel'), onPress: () => setLayer2OcrEnabled(false) },
{ text: t('automation.ocrModelInstall'), onPress: handleInstallModel },
]);
}
};
// L3 开关变化时检查 AI Key
const handleL3Toggle = (val: boolean) => {
if (val && (!aiEnabled || !aiApiKey)) {
Alert.alert(t('automation.layer3Ai'), t('automation.layer3AiDisabled'));
return;
}
setLayer3AiEnabled(val);
};
```
- [ ] **Step 4: AI 配置内联**
L3 启用时展开的子区域:
- `aiProviderId`:三选一 chipopenai/gemini/deepseek
- `aiApiKey`:输入框(secureTextEntry
- `aiBaseUrl`:输入框(默认值 placeholder
- `aiModel`:输入框(默认值 placeholder
- `aiEnabled`:Switch(总开关,控制 L3 的实际可用性)
- [ ] **Step 5: 验证**
`npm run typecheck` → 0 错误;`npm test` → 全绿
---
### Task 5: AI 设置页清理 + settings 导航更新
**Files:** `src/app/settings/ai.tsx`(重写为跳转)、`src/app/(tabs)/settings.tsx`(更新入口文案)
- [ ] **Step 1: settings/ai.tsx 改为 redirect**
不删除文件(保留路由),但内容改为:`useEffect(() => { router.replace('/automation'); }, [])` 跳转到 automation 页,期间显示 loading。
- [ ] **Step 2: settings.tsx 入口文案**
`src/app/(tabs)/settings.tsx` 中「智能记账 & AI」入口的文案保持不变,但实际点击跳转已由 expo-router 自动处理(ai.tsx → redirect → automation),不需要改路由注册。
- [ ] **Step 3: 验证**
`npm run typecheck` → 0 错误;`npm test` → 全绿
`grep -rn "settings/ai" src/app/(tabs)/settings.tsx` → 确认无需改动即可跳转
---
### Task 6: 编译 + 测试 + 终审
- [ ] **Step 1: Kotlin 编译**
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
- [ ] **Step 2: 全量测试**
`npm test` → 全绿;`npm run typecheck` → 0 错误
- [ ] **Step 3: 审计**
```bash
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
grep -rn "fontFamily" src/app/automation/index.tsx # 无输出
grep -rn "📊|💰|💸|📝" src/app/automation/ # 无输出
```
- [ ] **Step 4: 手工走查清单**
1. settings 页点「智能记账」→ 跳转到 automation 页
2. automation 页看到三个层级开关(L1 默认开 / L2 默认开 / L3 默认关灰)
3. 关闭 L1 → 仍可记账(走 L2 OCR)
4. 关闭 L2 → L1 失败不再调用 OCR
5. 开启 L3 → 提示配 AI Key → 配置后可用
6. OCR 模型未安装 → 点安装 → 从 assets 拷贝 → 显示绿色已安装
7. L2 开关关闭再打开 → 已有模型直接可用,不再提示
8. AI Provider 切换 openai/gemini/deepseek → 对应的 baseUrl 默认值变化
---
## 终审修订(已实施,以实际代码为准)
**日期**2026-07-22,审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
| # | 级别 | 问题 | 处理 |
|---|---|---|---|
| — | — | 无 Critical / Important 发现 | — |
**偏差说明**
1. 管道设置 Card 复用现有 `settings.dedupLabel`/`settings.transferLabel` i18n 键(已在 settings/ai.tsx 中使用),未新键 `dedupSwitch`/`transferSwitch`——避免重复键。
2. OcrModule.kt 从 assets 加载路径改为 `modelDir` 字段 + `setModelDir` 方法——保留原 assets 加载作为兜底(modelDir 为空时走原路径),保证向后兼容。
3. `ensureOcrModels()``_layout.tsx` ready 前调用一次(异步非阻塞),`getOcrProcessor()` 中也传 `ocrModelDir` 作为安全网。
+68
View File
@@ -0,0 +1,68 @@
# 开发指南
## 环境要求
- Node.js 18+
- JDK 21 + Android SDK 35 + NDK 27.1.12297006(仅 Android 构建需要)
- 详见 [android-build-guide.md](android-build-guide.md)
## 常用命令
```bash
npm install --legacy-peer-deps # 安装依赖(必须带 --legacy-peer-deps
npm run start # 启动 Metro 开发服务器
npm run android # expo run:android(设备/模拟器)
npm test # Vitest 全量测试
npm run typecheck # tsc --noEmit 类型检查
# 单文件测试
npx vitest run tests/ledger.test.ts
# 按名称运行测试
npx vitest run -t "拒绝不平衡交易"
# 重新生成原生项目
npx expo prebuild --platform android
```
## 编码规范
### TypeScript
- **strict 模式**已开启,路径别名 `@/*``src/*`
- 非平凡改动后必须运行 `npm run typecheck`
### 金额计算
- 使用字符串十进制运算(`src/domain/decimal.ts`),**禁止**用 JS 浮点数处理金额
- 关键 API`addDecimals``subtractDecimals``multiplyDecimal``divideDecimals``negateDecimal`
### 领域层纯净性
- `src/domain/` 禁止导入 React / React Native / Expo 任何模块
- 外部依赖通过接口注入:`MobileBeanBackend``OcrEngine``AiVisionProvider`
- 新模块必须在 `src/domain/index.ts` 重导出
### 原生代码
- 原生 Kotlin/XML 只能放在 `plugins/<name>/` 下,不可直接编辑 `android/`
- 每个 Config Plugin 函数**必须 `return config`**
- `expo prebuild` 后检查 `onnxruntime-android:1.20.0` 版本
### 注释与文档
- 代码注释和 `plan.md` 使用**中文**
- ID/校验和使用 FNV-1a 哈希(`hash()` in `ledger.ts`),不用加密哈希
## 测试
- 框架:**Vitest**(无配置文件,默认拾取 `tests/**/*.test.ts`
- 测试对象:主要覆盖 `src/domain/` 层,使用 mock 后端(`MemoryBackend``MockOcrEngine`
- 命名:`<模块或功能>.test.ts`,如 `decimal.test.ts``billPipeline.test.ts`
- 新增 domain 逻辑时必须同步添加测试
## 提交规范
- 提交消息格式:`feat: <中文摘要>`,多行 body 按模块分区列举变更
- 示例:`feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构`
- PR 需描述变更内容与原因,UI 变更附截图,关联相关 issue
+152
View File
@@ -0,0 +1,152 @@
# 弹窗与键盘避让设计指南 (Modal & Keyboard Avoidance)
本项目(React Native + Expo)所有底部弹窗(FormModal / BottomSheet / NumpadSheet)都基于 RN 原生 `<Modal>`。Modal 会带来两个棘手问题:**底部安全区丢失** 和 **键盘遮挡**。本篇记录反复踩坑后总结的正确模式,避免重蹈覆辙。
参考实现:`reference_project/BeeCount`Flutter`AnimatedPadding + viewInsets.bottom` 方案)。
---
## 1. 核心认知:`<Modal>` 脱离主窗口 context
> **这是所有问题的总根源,必须牢记。**
RN 的 `<Modal>` 会在原生层创建一个**独立的新窗口**,它**脱离了主窗口的 React 树**。两个直接后果:
1. **`react-native-safe-area-context` 失效**:主窗口里 expo-router 自动注入的 `SafeAreaProvider` 不会延伸到 Modal 内部。在 Modal 内直接调 `useSafeAreaInsets()` 会返回 `{ top: 0, bottom: 0, ... }`(全零),拿不到手势条高度。
2. **`KeyboardAvoidingView` 不可靠**:在 Modal 的独立窗口里,`behavior="height"` 在 Android 上键盘关闭后可能残留 padding(表现为"关闭键盘后弹窗底部有留白");`behavior="padding"` 对 Modal 内的布局响应也不稳定。
---
## 2. 正确模式:Modal 内补 SafeAreaProvider + 响应式 paddingBottom
### 2.1 SafeAreaProvider 补救(解决底部安全区丢失)
每个 `<Modal>` 内部**重新包一层 `<SafeAreaProvider>`**,让 context 在弹窗内恢复有效:
```tsx
// ❌ 错误:Modal 内直接用 useSafeAreaInsets(),返回全零
export function FormModal(props) {
const insets = useSafeAreaInsets(); // bottom === 0 永远
return <Modal>...</Modal>;
}
// ✅ 正确:Modal 内补 SafeAreaProvider,拆成外层 + Content
export function FormModal(props) {
return (
<Modal visible={props.visible} transparent animationType="slide">
<SafeAreaProvider>
<FormModalContent {...props} />
</SafeAreaProvider>
</Modal>
);
}
function FormModalContent(props) {
const insets = useSafeAreaInsets(); // 现在 bottom 是真实手势条高度
// ...
}
```
> **为什么必须拆两个组件?** `useSafeAreaInsets()` 必须在 `<SafeAreaProvider>` 的**子组件**里调用。同一个组件既渲染 Provider 又调 hook 会拿到全零(hook 在 Provider 上方执行)。
### 2.2 响应式 paddingBottom(解决键盘遮挡,绝不残留)
**核心思路(学 BeeCount 的 `AnimatedPadding + viewInsets.bottom`):键盘高度做成 paddingBottom,而不是位移 sheet。**
```tsx
// useKeyboardHeight hook(见 src/hooks/useKeyboardAvoiding.ts
const kbHeight = useKeyboardHeight();
// sheet 的 paddingBottom = 基础留白 + 键盘高度
<Pressable style={{
paddingBottom: Math.max(insets.bottom, 24) + kbHeight,
}}>
{/* 内容(含确认/取消按钮)会被 padding 顶起,始终在键盘上方 */}
</Pressable>
```
**为什么这个模式正确:**
- 键盘弹出 → `paddingBottom` 增大 → 内容被推到键盘上方,**且 sheet 仍贴底,不会露出遮罩缝**
- 键盘关闭 → `paddingBottom` 精确回到 `Math.max(insets.bottom, 24)`**无残留**
- 初始(键盘未弹)→ 只有基础留白 → **不会一打开就有大留白**
---
## 3. ❌ 已验证的失败方案(不要再尝试)
### 3.1 `KeyboardAvoidingView` 包裹 sheet
```tsx
// ❌ Modal 内 KAV 不可靠
<Modal>
<SafeAreaProvider>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<Sheet/>
</KeyboardAvoidingView>
</SafeAreaProvider>
</Modal>
```
**问题**Android `behavior="height"` 键盘关闭后残留 padding("关闭键盘后有留白");`behavior="position"` 在 Modal 内也不稳定。
### 3.2 `Animated` translateY 位移整个 sheet
```tsx
// ❌ 位移会露出遮罩缝 + Modal 内键盘事件易误触发
const translateY = useKeyboardAvoiding(); // 返回 Animated.Value
<Animated.View style={{ transform: [{ translateY }] }}>
<Sheet/>
</Animated.View>
```
**两个致命问题**
1. 位移后 sheet 原位置空出来,露出遮罩色 → **"键盘和弹窗之间有留白"**
2. Modal 首次渲染时若 `keyboardDidShow` 被残留焦点事件误触发,translateY 变负 → **"一打开就有留白"**
> 这正是本项目踩过的坑:translateY 方案让用户看到"所有弹窗底部都有留白"。改用响应式 padding 后彻底解决。
---
## 4. 底部留白值的正确计算
```tsx
// ❌ 错误:安全区 + 固定值叠加(会过大)
paddingBottom: 36 + insets.bottom // 全面屏手势机 bottom≈48 → 总 84px,太多
// ✅ 正确:取较大值,不叠加
paddingBottom: Math.max(insets.bottom, 24)
```
**为什么不能叠加?** 安全区(`insets.bottom`)本身已经覆盖了手势条区域。再叠加一个固定的 36,会在底部堆出 80+px 的空白,视觉上是"一大块留白"。两者应取**较大值**——安全区大时取安全区,安全区为 0(如 MIUI 全面屏手势机)时取最小视觉留白。
---
## 5. 项目中三个公共弹窗的实现
| 组件 | 文件 | 键盘避让 | 备注 |
|---|---|---|---|
| `FormModal` | `src/components/form/FormModal.tsx` | 响应式 paddingBottom(含 TextInput | 所有管理页 CRUD 复用 |
| `BottomSheet` | `src/components/ui/BottomSheet.tsx` | 响应式 paddingBottom + 手势下滑 Animated | 手势 `translateY` 仅用于下滑关闭,与键盘 padding 独立 |
| `NumpadSheet` | `src/components/form/NumpadSheet.tsx` | 内部 ScrollView | 记一笔主面板,主要用自定义 NumpadKeyboard,系统键盘场景少 |
`BottomSheet` 的特殊点:它同时有**手势下滑关闭**(用 `Animated.Value` 做 translateY)和**键盘避让**(用响应式 paddingBottom)。两者必须独立——手势位移走 Animated,键盘避让走 padding,不要合并成一个位移值(`Animated.add` 合并会导致手势和键盘互相干扰)。
---
## 6. 居中弹窗(ConfirmDialog)无需处理
`ConfirmDialog`(删除确认)是居中浮层(`justifyContent: 'center'`),无 TextInput、不贴底、无键盘,**不受安全区/键盘影响,不需要上述任何处理**。
---
## 7. 验证清单(修弹窗后必测)
1. **底部留白**:打开弹窗(不点输入框),底部只有正常小留白,无"一大块空白"。
2. **键盘上移**:点输入框唤起键盘 → 确认/取消按钮在键盘上方可见,且弹窗与键盘之间无缝隙。
3. **关闭键盘无残留**:收起键盘 → 弹窗精确回到初始状态,底部留白和打开时一致。
4. **全面屏手势机**:在 MIUI 等全面屏手势设备上(`insets.bottom = 0`)也正常。
---
## 8. 经验教训
1. **RN `<Modal>` 是独立窗口**——这是 RN 的设计,不是 bug。任何依赖主窗口 context 的 APISafeArea、某些 Keyboard 行为)在 Modal 内都要重新建立。
2. **键盘避让用 padding,不用位移**——这是 Flutter/RN 通用共识(BeeCount 的 `AnimatedPadding + viewInsets`)。位移方案虽然直觉上像"弹窗上移",但会露出原位置,制造视觉缝隙。
3. **Hermes bundle 是二进制**——验证 release 包代码时必须 `grep -a`,否则永远误判"代码没进去"。详见 `android-build-guide.md §7.2`
+124
View File
@@ -0,0 +1,124 @@
# OCR 推理管线指南 (OCR Pipeline Guide)
本文记录原生 OCR 引擎(`plugins/ppocr/android/OcrModule.kt`PP-OCRv6 / ONNX Runtime)的推理管线设计、一次「金额丢小数点」问题的根因与修复,以及**与 PaddleOCR 官方管线的差异和裁剪理由**。同时沉淀一套可复用的「OCR 识别错误诊断方法论」。
> 配套:构建/编译陷阱见 `android-build-guide.md`Modal/键盘见 `modal-keyboard-guide.md`。
---
## 0. TL;DR
- 管线:`整图 cap 长边 → det 检测文本框 → 逐框 crop → rec 识别 → CTC 解码`,全部在 Kotlin 原生侧,JS 层只透传 base64。
- **det 后处理必须用「连通域法」**4-连通 BFS + 官方 unclip 外扩 + box_score_fast 过滤)。**禁止回退到旧的「水平/垂直投影切行」**——它会把基线上的孤立小数点切到文本框外,导致 `¥143.97` 被识别成 `¥14397`
- 怀疑「模型识别能力不足」之前,**先用官方 PaddleOCR 跑同一对模型做对照**:官方能认出来 → 是我们的预处理/后处理代码问题;官方也认不出 → 才是模型边界。这条纪律避免误判。
- 我们用 Python + onnxruntime **逐行镜像** Kotlin 管线做离线验证(同一对 onnx 模型 + 真实截图),算法正确性在移植到 Kotlin 前就锁死,原生改动只是「翻译」。
---
## 1. 管线总览与参数
实现位置:`plugins/ppocr/android/OcrModule.kt`。常量在 `companion object`
| 环节 | 函数 | 关键参数 | 说明 |
|---|---|---|---|
| 整图缩放 | `capLongEdge` | `CAP_LONG_EDGE=3000` | 仅超大图降采样防 OOM;手机截图(≤2400)不预压,使 rec 的 crop 源为高清原图 |
| det resize | `resizeForDet` | `DET_LIMIT_MAX_SIDE=1600`half-up**无条件对齐 32** | 旧值 960 会让小数点仅 1–2px 而糊掉;无条件对齐 32 防止非法尺寸喂入 det 触发广播报错 |
| det 前处理 | `preprocessDet` | mean/std=ImageNet**通道序 BGR** | `(px shr (8*c)) and 0xFF`c=0→B),对齐官方训练约定 |
| det 后处理 | `dbPostprocess` | `DET_THRESH=0.2``BOX_THRESH=0.6``UNCLIP_RATIO=1.5``MIN_SIZE=5` | 连通域法,见 §3 |
| crop | `cropBox` | paddingX=4/paddingY=2**h≥1.5w 时 rot90** | 竖排文本旋转,对齐官方 `get_rotate_crop_image` |
| rec 前处理 | `preprocessRec` | 高 `REC_IMAGE_HEIGHT=48`,宽 **ceil** 上限 `REC_MAX_WIDTH=1280`**不 pad** | 旧宽上限 320 把长商户名压扁 5×+;不 pad 见 §6 |
| 解码 | `ctcGreedyDecode` | blank=0,去重,置信度=非 blank 步均值 | 与官方 `CTCLabelDecode` 逻辑一致 |
---
## 2. 丢小数点根因:投影切行把基线点切到框外
### 现象
招行一笔 `GOOGLE*ChatGPT` 交易,金额 `¥143.97` 被 OCR 识别成 `¥14397`;而同一页的 `交易地金额 21.19` 小数点却正常。
### 根因(已用官方同模型对照坐实)
旧的 `dbPostprocess` 用**水平投影切行**:一行活跃像素 ≥ `w/20` 才算文本行。小数点是基线上孤立的 1–2 像素宽,它所在那一行的水平投影**远低于阈值**,被判成「非文本行」→ `rowRange``y1` 停在数字主体底部,**基线行被排除** → `cropBox` 裁出的图**不含小数点** → rec 看不到点 → `14397`
官方 PaddleOCR 的 det 后处理是**轮廓/连通域法**:连通域把「数字 + 基线点」归为同一个域,外接框天然含基线,所以点保住了。
> 关键证据:把旧管线切出的金额框在原图上**纵向下扩 10px** 再送 rec,小数点立刻以 0.99 置信度回来——证明点一直在二值图里,只是被切行排除在 crop 之外。
### 为什么前两轮误判(教训)
- 第一轮怀疑 **JPEG q60 / scaleDown 降采样**抹掉了点 → 用**原图**裁剪送 rec 仍 `14397`,证伪。
- 第二轮怀疑**模型能力边界**(大字号下点太小,rec 不认)→ 用**官方 PaddleOCR 跑同一对模型**,官方正确输出 `¥143.97`,**反转结论**:模型完全能认,问题 100% 在我们的后处理代码。
**纪律**:在归咎「模型不行」或「图像预处理丢信息」之前,先做官方同模型对照。它能一锤定音区分「模型边界」与「我们的代码 bug」。
---
## 3. 修复:连通域法 + 官方 unclip + box_score
`dbPostprocess` 重写为(与官方轮廓法等价、但纯 Kotlin 零新依赖):
1. **二值化**`sig > DET_THRESH(0.2)`,同时保留 `sigMap`(概率图)供后续 box_score 用。状态栏/导航栏(顶/底 8%)清零 + 垂直干扰线(列活跃 >30%)清零保留。
2. **连通域标记**4-连通、**迭代 BFS**(用 `ArrayDeque`,防递归栈溢出),每域记录 bbox `(x0,y0,x1,y1)`、真实像素面积 `area`、域内 `sig` 累加 `sum`
3. **官方 unclip 外扩**`d = area × UNCLIP_RATIO(1.5) / 周长``d` 下限 1bbox 四向各扩 `d`(half-up 取整)。水平文本下这与官方多边形外扩在结果上几乎等价,把基线标点纳入框。
4. **过滤**:扩后短边 `< MIN_SIZE(5)` 丢弃;**box_score_fast** = `sum / area`(**域内文本像素 prob 均值**,见 §5 口径)`< BOX_THRESH(0.6)` 丢弃。
5. 映射回原图坐标(×ratioX/ratioY)。
> 旧的「水平投影切行 + 垂直投影切列 + 临时纵向膨胀」已**整体删除**,不要复活。
---
## 4. 诊断方法论(可复用范式)
当某张图 OCR 结果错误时,按以下顺序定位,**不要直接改 Kotlin 猜**
1. **Python 复现管线**:用 onnxruntime 加载同一对 `ppocrv6_det.onnx` / `ppocrv6_rec.onnx` + `ppocrv6_dict.txt`**逐行镜像** Kotlin 的缩放/前处理/后处理/crop/解码(脚本见仓库根 `.ocr-test/`,未跟踪,验证完可删)。先确认能复现错误。
2. **逐环节隔离**:对出错文本框做对照实验——分别用「降采样图 / 原图」裁剪、放开 rec 宽上限、纵向放大 crop 等,看哪一步改变结果,缩小嫌疑环。
3. **官方同模型对照**`pip install paddleocr` 后用官方 `PaddleOCR(... engine="onnxruntime")` 跑同一张图。**官方能认 → 我们代码 bug;官方也认不出 → 模型边界。** 这一步是判读的分水岭。
4. **dump 逐时间步 logits**:对 rec 输出看「出错字符位置」那个时间步,目标字符类的概率是多少、argmax 是什么。能区分「crop 没含该字符(概率≈0)」还是「含了但模型判错」。
5. **修复先在 Python 验证台改对、端到端跑通**,再 1:1 翻译到 Kotlin——原生端跑不了 onnx 离线验证,靠这层把算法正确性前置锁死。
---
## 5. 跨语言复现的两个对齐坑
### 5.1 舍入语义必须一致
det 尺寸对齐 32 时,Python 内置 `round` 是**银行家舍入**22.5→22),Kotlin/Java `Math.round`**half-up**(22.5→23)。两者会让 det 输入差 32 像素列。若验证台用银行家、Kotlin 用 half-up,则 Kotlin 实际跑的是**验证台没验证过的尺寸**,成为盲点。
**做法**:验证台显式用 half-up`int(math.floor(x+0.5))`)镜像 `Math.round`,使两边输入逐像素一致;Kotlin 侧 `Math.ceil` 注意只有 `double` 重载(传 Float 编译失败,需 `toDouble()`,详见 `android-build-guide.md §5.3`)。
### 5.2 box_score_fast 的口径
`box_score_fast` 是**文本像素(连通域 mask 内)的 prob 均值****不是整个 bbox 矩形的均值**。后者含大量 0 值背景,会把分数稀释到阈值以下、把所有框误杀(实测曾出现「连通域 24 个、保留 0 个」)。用 `scipy.ndimage.mean(sig, labels)` / Kotlin 的 `sum/area` 取域内均值才对。
---
## 6. 与官方差异的裁剪清单(为什么不做某些官方能力)
两条标尺贯穿全部判断:① 我们是 **Android ONNX CPU**,无 GPU、无 OpenCV、包体/内存敏感;② 输入几乎全是 **App 截图**——水平文本、无旋转、无镜头畸变、无弯曲。官方很多重型能力是为「拍照/扫描/任意文档」的宽分布设计的,对我们的窄分布是 over-engineering。
| 项 | 类别 | 不做/保留现状的主因 | 重新评估的触发条件 |
|---|---|---|---|
| 透视矫正 `warpPerspective` | P2 | 截图无透视;需引 OpenCV(~30MB so) 或自写 warp | 上「拍照记账」 |
| det 通道序 BGR | **已做** | 零成本对齐训练约定,顺手做了 | — |
| 顶/底 8% + 列 30% 硬清零 | 保留 | 截图利>弊;无贴边/JS 预裁需求 | 出现贴边误删 或 JS 预裁状态栏 |
| `textline_orientation` 行方向模型 | 不做 | 截图恒 0°,每框白跑一次 ONNX 推理 | 上「拍照记账」 |
| `doc_orientation` + `UVDoc` 去弯 | 不做 | 截图无旋转/弯曲;移动端最贵的两个模型 | 产品转向拍纸质账本(基本不会) |
| rec 右侧 pad 到 320 | 不做 | 逐行 + ONNX 动态宽,pad 只增算力无收益(**少数「与官方不同但我们更对」的点**) | 改做 rec 批推理(大概率不做) |
| pyclipper 精确多边形膨胀 | 不做 | 水平文本 bbox 近似等价;需引 Clipper2 新依赖 | 支持倾斜/拍照文本 |
> 一句话:官方「完整管线」为宽分布 + 强算力调;我们为窄分布 + 弱算力,正确做法是**按输入分布裁掉用不上的重型环节**,只移植真正提升截图质量的部分(连通域取框、提分辨率、rec 放宽、参数对齐)。
---
## 7. 参数对照表(旧 → 新 → 官方)
| 参数 | 旧 | 新 | 官方(OCR 管线生效值) |
|---|---|---|---|
| 整图缩放 | 短边压 720 | 长边 cap 3000 | 不降采样(max_side_limit=4000 |
| det 长边 | 960 / floor | **1600** / half-up / 无条件对齐 32 | 不降采样 / round 对齐 32 |
| det thresh | 0.3 | **0.2** | 0.3(模型 yml 0.2 |
| det box_thresh | 无 | **0.6** | 0.6(模型 yml 0.45 |
| det unclip_ratio | 无(临时 0.4 行高) | **1.5**area×ratio/周长) | 1.5 |
| det 取框法 | 投影切行 | **连通域** | 轮廓 findContours+unclip |
| rec 宽 cap | 320 / floor | **1280** / ceil | 3200 / ceil |
| det 通道序 | RGB | **BGR** | BGR |
| crop 竖排 | 无 | **rot90** | rot90 + 透视 |
> 移动端折中说明:det 长边取 1600(非官方全分辨率)是为 CPU 性能;box_thresh 取 0.6(非模型 yml 的 0.45)是实测 0.45 仅多收噪声无额外召回。两者都是「在官方语义下向移动端性能倾斜」的有意识折中,非疏漏。
+147
View File
@@ -0,0 +1,147 @@
# 硅基流动与智谱 AI 账单 OCR 及 JSON 结构化提取实测报告
本报告针对测试图片(`20260725-142945.jpg` 信用卡账单截图),对 **硅基流动 (SiliconFlow)****智谱 AI (BigModel.cn)** 平台的视觉大模型、OCR 引擎及多模型组合进行了多轮实测基准对比。
---
## 目录
1. [测试结论与终极选型建议](#一-测试结论与终极选型建议)
2. [实测性能对比总表](#二-实测性能对比总表)
3. [硅基流动 (SiliconFlow) 平台测试详解](#三-硅基流动-siliconflow-平台测试详解)
4. [智谱 AI (BigModel.cn) 平台测试详解](#四-智谱-ai-bigmodelcn-平台测试详解)
5. [免费模型配额与 Rate Limits 规则](#五-免费模型配额与-rate-limits-规则)
6. [DriftLedger (浮记) 架构与账户分配流程](#六-driftledger-浮记-架构与账户分配流程)
---
## 一、 测试结论与终极选型建议
> [!TIP]
> **最佳单 API 直出方案**:智谱 **`glm-4v-flash`**
> - **耗时仅 3.22 秒**,单次 API 请求即可直接输出包含商户、金额、日期、卡号、原币的**完美 JSON**,且 100% 免费。
> [!IMPORTANT]
> **最佳双阶段流水线方案**:硅基流动 **`DeepSeek-OCR` + `THUDM/GLM-4-9B-0414`**
> - **总耗时仅 4.36 秒**(阶段一 OCR 1.17s + 阶段二 文本提 JSON 3.19s),全免费,OCR 文字识别准确率 100%。
> [!NOTE]
> **最佳跨国/外币高精度方案****`DeepSeek-OCR` + `deepseek-ai/DeepSeek-V3`**
> - **总耗时 5.39 秒**,具备强大的语义推理能力,不仅提取出原币数字 `21.19`,还智能推断并补充了单位 `USD`。每次调用费用仅约 0.0008 分钱。
---
## 二、 实测性能对比总表
测试图片:`20260725-142945.jpg`(招商银行信用卡消费通知,含商户 `GOOGLE *ChatGPT`,金额 `¥143.97`,信用卡 `3315`,原币 `21.19`,时间 `2026-07-23 00:00:00`)。
| 平台 | 模型 / 组合名称 | 架构类型 | 阶段1 耗时 | 阶段2 耗时 | **总耗时** | 提取准确度与 JSON 质量 | 资费类型 |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| **智谱 AI** | **`glm-4v-flash`** | 单阶段 VLM | - | - | **3.22s** | **100% 完美** (提取极精准,结构清晰) | **100% 免费** |
| **硅基流动** | **`DeepSeek-OCR` + `GLM-4-9B-0414`** | 双阶段流水线 | 1.17s | 3.19s | **4.36s** | **100% 准确** (结构化规范,无幻觉) | **100% 免费** |
| **硅基流动** | **`DeepSeek-OCR` + `DeepSeek-V3`** | 双阶段流水线 | 1.17s | 4.22s | **5.39s** | **智能推导** (自动补全原币单位 `USD`) | 低成本计费 (~0.0008分/次) |
| **智谱 AI** | **`glm-4.1v-thinking-flash`** | 思维链 VLM | - | - | **7.03s** | 带有 `<think>` 推理链,易超长截断 | **100% 免费** |
| **硅基流动** | **`Qwen/Qwen3-VL-8B-Instruct`** | 单阶段 VLM | - | - | **10.34s** | **100% 准确** (一次生成完成) | **100% 免费** |
| **硅基流动** | **`PaddleOCR-VL-1.5`** | 坐标类 OCR | - | - | **62s ~ 114s** | 包含大量 `<\|LOC_xxx\|>` 点位标签与杂音 | **100% 免费** |
---
## 三、 硅基流动 (SiliconFlow) 平台测试详解
### 1. `deepseek-ai/DeepSeek-OCR`
- **定位**:端到端纯文档/图片至 Markdown 识别模型。
- **优点**:速度极快(**1.17s ~ 1.48s**),完美还原表格与富文本结构。
- **限制**:不支持通用大语言模型的指令遵循(无法直接提示词输出 JSON,强制开启 `json_object` 会陷入空格生成死循环)。
### 2. `PaddlePaddle/PaddleOCR-VL-1.5`
- **定位**:带坐标识别的文档/版面分析大模型。
- **缺点**:缺少张量并行加速,单次生成生成耗时高达 60~110 秒,输出结果混杂大量的点位 Token。
### 3. 双阶段流水线提取结果(实际返回 JSON)
```json
{
"occurredAt": "2026-07-23 00:00:00",
"amount": 143.97,
"currency": "CNY",
"direction": "expense",
"counterparty": "GOOGLE *ChatGPT",
"memo": "信用卡尾号: 3315, 原币金额: 21.19 USD"
}
```
---
## 四、 智谱 AI (BigModel.cn) 平台测试详解
### 1. 智谱免费 Flash 模型分类与特性
- **`glm-4v-flash`**:智谱基础免费视觉模型,**实测表现最稳定、速度最快 (3.22s)**,原生支持 `json_object`
- **`glm-4.6v-flash`**:最新轻量多模态模型,支持原生工具调用(Tool Calling),但免费接口频控较严(并发时易触发 HTTP 429)。
- **`glm-4.1v-thinking-flash`**:具备 Thinking 思维链机制,回答前会在 `<think>` 中展开多步骤思考逻辑。
- **`glm-4-flash-250414`**:纯文本/代码轻量旗舰模型,适合放在双阶段流水线的 Stage 2。
- **`CogView-3-Flash` / `CogVideoX-Flash`**:分别用于文生图与视频生成。
### 2. `glm-4v-flash` 实测输出数据
```json
{
"occurredAt": "2026-07-23 00:00:00",
"amount": 143.97,
"currency": "CNY",
"direction": "expense",
"counterparty": "GOOGLE *ChatGPT",
"memo": {
"card_last_4_digits": "3315",
"original_amount": 21.19,
"country_or_region": "美国"
}
}
```
---
## 五、 免费模型配额与 Rate Limits 规则
### 1. 硅基流动 (SiliconFlow)
- **门槛**:需完成账户实名认证。
- **配额**
- **RPM (Requests Per Minute)**100 ~ 1,000 RPM(中小模型 500~1000 RPM)。
- **TPM (Tokens Per Minute)**50,000 ~ 100,000 TPM。
- **Pro/ 专线**:带 `Pro/` 前缀的模型(如 `Pro/deepseek-ai/DeepSeek-V3`)为付费独占集群,无免费版的固定并发上限。
### 2. 智谱开放平台 (BigModel.cn)
- **免费规则**:所有的 Flash 命名系列(`GLM-4-Flash``GLM-4V-Flash` 等)API 均免费开放。
- **并发控制**:对高频连续调用设置了 RPM 阈值(触发时返回 `HTTP 429 Too Many Requests`),代码中需配置指数退避或 1~2 秒重试间隔。
---
## 六、 DriftLedger (浮记) 架构与账户分配流程
针对识别结果中“为什么只包含时间、金额、商户名,而没有 Beancount 账户”的说明:
### 1. 职责解耦设计
识图/OCR 模块只负责提取客观的**原始交易事件 (`ImportedEvent`)**,定义于 [types.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/core/types.ts#L31-L38)。由于每个用户的 Beancount 账户名(如 `Assets:招商银行:信用卡3315`)是高度个性化的,模型无法预知用户本地账本结构。
### 2. 账本账户决定流程
账户映射是在 **`BillPipeline` 责任链** 中完成的:
```text
[账单截图]
▼ 1. 图像解析 (AiVisionProcessor.ts)
[ImportedEvent] ─── (仅含时间、金额、商户 GOOGLE *ChatGPT、备注 3315)
▼ 2. 进入流水线 BillPipeline.process() ─── [billPipeline.ts]
├──> ① 转账识别 (recognizeTransfers)
├──> ② 批次去重与历史去重 (dedup)
└──> ③ 规则匹配与账户分类 (rules.ts)
├── 匹配商户/卡号 "3315" ──> 资金来源账户 (sourceAccount): Assets:招商银行:信用卡3315
└── 匹配商户 "GOOGLE *ChatGPT" ──> 支出分类账户 (categoryAccount): Expenses:订阅服务:AI
▼ 3. 输出标准交易草稿 (TransactionDraft)
[TransactionDraft] ─── 包含标准的双式记账 Postings 分录
```
### 3. 相关代码位置
- **原始事件类型定义**[src/domain/core/types.ts:L31-L38](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/core/types.ts#L31-L38)
- **账单流水线责任链**[src/domain/pipeline/billPipeline.ts:L94-L180](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/pipeline/billPipeline.ts#L94-L180)
- **规则分类与账户映射引擎**[src/domain/rules/rules.ts:L1-L60](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/rules/rules.ts#L1-L60)
- **AI 识图处理器服务**[src/services/ocr/AiVisionProcessor.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/services/ocr/AiVisionProcessor.ts)
+155
View File
@@ -0,0 +1,155 @@
# 账单识别与 Beancount 账户分类:合并 vs 解耦架构对比文档
在双式记账(Beancount / DriftLedger)离线移动客户端开发中,**“原始账单识别(Bill Recognition)”** 与 **“Beancount 账户分类(Account Classification)”** 是两个核心处理阶段。
本文档深度对比分析将这两个阶段进行 **合并(单步一体求值)****解耦(双阶段责任链)** 的架构差异,并结合 DriftLedger 源码实现,分别涵盖 **传统规则渠道(Rule-based****大模型/AI 渠道(LLM/VLM** 的表现。
---
## 目录
1. [架构定义与处理模式](#一-架构定义与处理模式)
2. [传统规则渠道(Rule-based Channel)对比](#二-传统规则渠道-rule-based-channel-对比)
3. [大模型/AI 渠道(AI/LLM Channel)对比](#三-大模型ai-渠道-aillm-channel-对比)
4. [多维性能与工程指标全景对比表](#四-多维性能与工程指标全景对比表)
5. [DriftLedger (浮记) 混合架构落地推荐](#五-driftledger-浮记-混合架构落地推荐)
---
## 一、 架构定义与处理模式
在多通道系统架构中,无论采用合并还是解耦,**多通道原始内容采集(Ingestion Adapters** 始终是前置的:
- **通道 1:拍照/图库 OCR**(获取账单图像或 OCR Markdown 文本)
- **通道 2:无障碍服务 UI 节点提取**(解析微信/支付宝支付成功页的 node 文本,参见 [accessibilityParser.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/services/automation/accessibilityParser.ts)
- **通道 3:短信与系统通知监控**(解析银行/支付软件推送通知字符串)
合并与解耦的核心区别,在于拿到**账单原始内容 (Raw Bill Text)** 之后,**“事实字段识别”** 与 **“Beancount 账户分配”** 是在单次操作中完成,还是拆分为多个阶段:
```text
========================================================================================
【多通道多源输入】
(通道A: 拍照OCR文本 / 通道B: 无障碍UI节点文本 / 通道C: 短信通知文本)
【合并架构 (Merged Mode / 一体化文本求值)】
单次求值 (AI Prompt 或 规则引擎):
输入: 账单原始内容 + 用户 Beancount 动态账户列表
输出: 直接一步得到【Beancount 交易草稿 TransactionDraft】(含时间、金额、商户、资金账户、支出账户)
========================================================================================
========================================================================================
【多通道多源输入】
(通道A: 拍照OCR文本 / 通道B: 无障碍UI节点文本 / 通道C: 短信通知文本)
【解耦架构 (Decoupled Mode / 责任链流水线)】
阶段 1(事实识别): 仅识别事实,输出无账户关联的【ImportedEvent】(时间、金额、商户、备注)
阶段 2(账户分类): 送入 BillPipeline,由规则引擎 RuleEngine 或轻量 AI 匹配 Beancount 账户
========================================================================================
```
---
## 二、 传统规则渠道(Rule-based Channel)对比
在规则匹配渠道下,账户名均来自用户动态配置的规则库 (`Rule[]`),绝非代码硬编码:
### 1. 动态规则合并模式(Single-Pass Rule Evaluation / 一体化动态匹配)
- **处理逻辑**
拿到多通道的原始内容后,解析器直接调用用户配置的动态规则库 `Rule[]`。一条规则同时包含了**事实判定条件**与**双向账户分配**:
```typescript
// 用户在 APP 中配置的动态规则对象(非代码硬编码)
const rule: Rule = {
id: "rule-101",
counterpartyContains: "星巴克",
sourceAccount: "Assets:Alipay:Balance", // 动态资金来源账户
categoryAccount: "Expenses:Food:Coffee", // 动态分类支出账户
narration: "星巴克咖啡"
};
// 单次求值:一步构造出带有账户的完整 TransactionDraft 草稿
if (matches(rawText, rule)) {
return createDraftDirectly(rawText, rule.sourceAccount, rule.categoryAccount);
}
```
- **特点**
单次求值即产生最终 `TransactionDraft`。如果入口预填了 `sourceAccount` / `categoryAccount`(参考 DriftLedger 代码 [billPipeline.ts:L169-L180](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/pipeline/billPipeline.ts#L169-L180)),流水线直接接受该草稿。
### 2. 规则解耦模式(Two-Stage Evaluation / 责任链流水线匹配)
- **处理逻辑**
1. **阶段 1 (事实化)**:所有通道(通知、无障碍、短信、账单 CSV)统一输出仅包含事实的 `ImportedEvent`(无账户关联)。
2. **阶段 2 (责任链评估)**`BillPipeline` 依次执行:转账识别 ➔ 批次去重 ➔ 历史去重 ➔ 送入 `RuleEngine` 匹配规则库中的账户。
- **特点**
解析器只需关心文本事实提取,账户分配归口给 `BillPipeline` 与 `RuleEngine` 统一调度。在分配账户前可先过滤重复事件,避免无效计算。
---
## 三、 大模型/AI 渠道(AI/LLM Channel)对比
利用大语言模型(LLM)或多模态视觉模型(VLM)处理多通道获取的原始内容:
### 1. AI 文本级合并模式(单次 LLM 提示词一步直出草稿)
- **处理逻辑**
多通道(拍照 OCR / 无障碍节点文本 / 短信通知)提取到**账单原始内容字符串**后,**在单次 LLM 请求中**将“账单原始内容”与“用户本地 Beancount 候选账户列表”一同作为 Prompt 提交给大模型(如 `glm-4-flash-250414` 或 `Qwen2.5-7B-Instruct`)。
- **流程**
```text
[多通道原始内容文本] + [用户动态账户列表] ───(单次 LLM 求值)───> [Beancount TransactionDraft]
```
- **优势**
- **通道统一**:不管是图片 OCR、微信支付成功的节点文本、还是银行扣款短信,都使用同一种“文本级合并 Prompt”,直接返回填充好 `sourceAccount` 和 `categoryAccount` 的 JSON 草稿。
- **响应极快**:纯文本大模型(如 `glm-4-flash-250414`)处理文本合并请求耗时仅 **~2.2 秒**。
- **劣势**
- **Token 开销**:每次请求都需携带用户账户列表。
- **确定性风险**:可能偶发生成不存在的账户名。
### 2. AI 解耦模式(内容识别提取事实 ➔ 独立分类器)
- **处理逻辑**
1. **阶段 1(事实识别)**:模型仅负责解析多通道原始内容,输出不含账户的 `ImportedEvent`(时间、金额、商户、备注)。
2. **阶段 2(账户分类)**:优先走本地 `RuleEngine`;若未命中,再调用轻量 LLM(耗时 ~2.2 秒)或向量 Embedding 模型(如 `bge-m3`,耗时 **0.3 秒**)在单独的 Prompt / 向量空间中挑选账户。
- **优势**
- **绝对确定性**:已知商户 100% 走本地规则引擎(0 延迟、0 错误率)。
- **离线优先 (Offline-first)**:本地识别出事实后,断网状态下本地规则引擎依然能完成账户分类。
---
## 四、 多维性能与工程指标全景对比表
| 评估维度 | **规则合并模式 (单次一体匹配)** | **规则解耦模式 (责任链流水线)** | **AI 文本级合并模式 (单次LLM求值)** | **AI 解耦模式 (双阶段链式)** |
| :--- | :--- | :--- | :--- | :--- |
| **原始内容来源** | 多通道 (OCR/无障碍/短信) | 多通道 (OCR/无障碍/短信) | 多通道 (OCR/无障碍/短信) | 多通道 (OCR/无障碍/短信) |
| **识别与分类点** | 文本解析时同步求值 | 分两阶段:解析事实 ➔ 匹配账户 | **单次 LLM 同时提取事实+账户** | 分两阶段:提取事实 ➔ LLM/向量选账户 |
| **响应延迟 (Latency)** | **< 1ms** | **< 1ms** | **~2.2s** (`glm-4-flash-250414`) | **~4.3s** (OCR + 分类) |
| **API 调用次数** | 0 次 | 0 次 | **1 次** | 1 ~ 2 次 |
| **断网/离线鲁棒性** | 完全支持 | 完全支持 | 不支持 (需在线 LLM) | **半支持** (文本提取后离线规则分类) |
| **准确率与确定性** | **100%** | **100%** | 高 (约 95%,受 Prompt 引导) | **100%** (已知规则优先覆写) |
| **代码可维护性** | 良好 | **极佳** (领域分层极清) | 优秀 (通道无缝复用 Prompt) | **极佳** (管道可随意组装) |
---
## 五、 DriftLedger (浮记) 混合架构落地推荐
结合多通道采集(OCR / 无障碍 / 短信通知)与 DriftLedger 的代码实现 [billPipeline.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/pipeline/billPipeline.ts#L169-L180),推荐采取 **“多通道输入 ➔ AI 文本级合并直出 ➔ 本地流水线预填覆写”** 的最佳落地架构:
```text
【通道 A: 拍照 OCR 文本】 【通道 B: 无障碍 UI 文本】 【通道 C: 短信通知文本】
│ │ │
└────────────────────────────┼────────────────────────────┘
【AI 文本级合并求值 (glm-4-flash-250414)】
单次 LLM 传入多通道文本 + 用户 Beancount 动态账户列表
2.2 秒内一步生成带预填账户的 `ImportedEvent` (含 sourceAccount/categoryAccount)
【领域核心层 / BillPipeline 责任链】
┌────────────────────────────────┴────────────────────────────────┐
▼ ▼
【1: 本地规则覆写 (RuleEngine)】 【2: 多通道去重 (Dedup)】
若匹配到用户定义的 100% 精确 Rule 规则, 自动过滤历史账本已存在的重复交易,
本地规则强行覆写 AI 预测的账户,保障零差错。 避免多次拍照或无障碍重复记账。
```
### 总结:
1. **多通道采集(OCR / 无障碍 / 短信)** 是解耦的前置适配层。
2. **AI 合并模式** 指的是在获取到账单文本后,**用单次 LLM 请求同时完成事实提取与账户选择**(2.2 秒极速返回)。
3. **架构落地**:多通道文本输入 ➔ AI 文本级合并一步预填 ➔ 本地流水线校验覆写。
+195
View File
@@ -0,0 +1,195 @@
# 硅基流动与智谱 AI 账单 OCR、JSON 提取及 AI 账户自动分类实测报告
本报告针对测试图片(`20260725-142945.jpg` 信用卡账单截图),对 **硅基流动 (SiliconFlow)****智谱 AI (BigModel.cn)** 平台的视觉大模型、OCR 引擎、免费文本大模型及向量 Embedding 模型进行了全流程实测基准对比。
---
## 目录
1. [测试结论与终极选型建议](#一-测试结论与终极选型建议)
2. [实测性能对比总表](#二-实测性能对比总表)
3. [硅基流动 (SiliconFlow) 平台测试详解](#三-硅基流动-siliconflow-平台测试详解)
4. [智谱 AI (BigModel.cn) 平台测试详解](#四-智谱-ai-bigmodelcn-平台测试详解)
5. [免费模型配额与 Rate Limits 规则](#五-免费模型配额与-rate-limits-规则)
6. [DriftLedger (浮记) 架构与账户分配流程](#六-driftledger-浮记-架构与账户分配流程)
7. [AI 账户自动分类实测 (免费 LLM vs 路径 B 向量检索)](#七-ai-账户自动分类实测-免费-llm-vs-路径-b-向量检索)
---
## 一、 测试结论与终极选型建议
> [!TIP]
> **最佳单 API 识图直出方案**:智谱 **`glm-4v-flash`**
> - **耗时仅 3.22 秒**,单次 API 请求即可直接输出包含商户、金额、日期、卡号、原币的**完美 JSON**,且 100% 免费。
> [!IMPORTANT]
> **最佳双阶段识图流水线方案**:硅基流动 **`DeepSeek-OCR` + `THUDM/GLM-4-9B-0414`**
> - **总耗时仅 4.36 秒**(阶段一 OCR 1.17s + 阶段二 文本提 JSON 3.19s),全免费,OCR 文字识别准确率 100%。
> [!NOTE]
> **最佳 AI 账户自动分类方案**:智谱 **`glm-4-flash-250414`** (免费 LLM) / 硅基 **`BAAI/bge-m3`** (向量路径 B)
> - **免费 LLM 直选 (`glm-4-flash-250414`)**:耗时仅 **2.22 秒**100% 精确推导出 `Liabilities:CreditCard:CMB:3315` 与 `Expenses:Software:Subscription`。
> - **向量路径 B (`bge-m3`)**:耗时仅 **0.32 秒 (320 毫秒)**,亚秒级定位匹配目标卡片。
---
## 二、 实测性能对比总表
测试图片:`20260725-142945.jpg`(招商银行信用卡消费通知,含商户 `GOOGLE *ChatGPT`,金额 `¥143.97`,信用卡 `3315`,原币 `21.19`,时间 `2026-07-23 00:00:00`)。
| 阶段 / 任务 | 平台 | 模型 / 组合名称 | 架构类型 | 阶段耗时 | **总耗时** | 提取准确度与 JSON 质量 | 资费类型 |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| **识图 JSON 提取** | **智谱 AI** | **`glm-4v-flash`** | 单阶段 VLM | - | **3.22s** | **100% 完美** (提取极精准,结构清晰) | **100% 免费** |
| **识图 JSON 提取** | **硅基流动** | **`DeepSeek-OCR` + `GLM-4-9B-0414`** | 双阶段流水线 | 1.17s + 3.19s | **4.36s** | **100% 准确** (结构化规范,无幻觉) | **100% 免费** |
| **识图 JSON 提取** | **硅基流动** | **`DeepSeek-OCR` + `DeepSeek-V3`** | 双阶段流水线 | 1.17s + 4.22s | **5.39s** | **智能推导** (自动补全原币单位 `USD`) | 低成本 (~0.0008分/次) |
| **识图 JSON 提取** | **智谱 AI** | **`glm-4.1v-thinking-flash`** | 思维链 VLM | - | **7.03s** | 带有 `<think>` 推理链,易超长截断 | **100% 免费** |
| **账户智能分类** | **智谱 AI** | **`glm-4-flash-250414`** | 免费 LLM 分类 | - | **2.22s** | **100% 精确** (同时给出资金与支出账户及理由) | **100% 免费** |
| **账户智能分类** | **硅基流动** | **`THUDM/GLM-4-9B-0414`** | 免费 LLM 分类 | - | **3.45s** | **100% 精确** | **100% 免费** |
| **账户智能分类** | **硅基流动** | **`BAAI/bge-m3`** | 向量路径 B 检索 | - | **0.32s** | **亚秒级最快** (Top 1 相似度 0.5662 精准命中) | **100% 免费** |
---
## 三、 硅基流动 (SiliconFlow) 平台测试详解
### 1. `deepseek-ai/DeepSeek-OCR`
- **定位**:端到端纯文档/图片至 Markdown 识别模型。
- **优点**:速度极快(**1.17s ~ 1.48s**),完美还原表格与富文本结构。
- **限制**:不支持通用大语言模型的指令遵循(无法直接提示词输出 JSON,强制开启 `json_object` 会陷入空格生成死循环)。
### 2. `PaddlePaddle/PaddleOCR-VL-1.5`
- **定位**:带坐标识别的文档/版面分析大模型。
- **缺点**:缺少张量并行加速,单次生成生成耗时高达 60~110 秒,输出结果混杂大量的点位 Token。
### 3. 双阶段流水线提取结果(实际返回 JSON)
```json
{
"occurredAt": "2026-07-23 00:00:00",
"amount": 143.97,
"currency": "CNY",
"direction": "expense",
"counterparty": "GOOGLE *ChatGPT",
"memo": "信用卡尾号: 3315, 原币金额: 21.19 USD"
}
```
---
## 四、 智谱 AI (BigModel.cn) 平台测试详解
### 1. 智谱免费 Flash 模型分类与特性
- **`glm-4v-flash`**:智谱基础免费视觉模型,**实测表现最稳定、速度最快 (3.22s)**,原生支持 `json_object`
- **`glm-4.6v-flash`**:最新轻量多模态模型,支持原生工具调用(Tool Calling),但免费接口频控较严(并发时易触发 HTTP 429)。
- **`glm-4.1v-thinking-flash`**:具备 Thinking 思维链机制,回答前会在 `<think>` 中展开多步骤思考逻辑。
- **`glm-4-flash-250414`**:纯文本/代码轻量旗舰模型,适合放在双阶段流水线的 Stage 2 以及账户分类。
- **`CogView-3-Flash` / `CogVideoX-Flash`**:分别用于文生图与视频生成。
### 2. `glm-4v-flash` 实测输出数据
```json
{
"occurredAt": "2026-07-23 00:00:00",
"amount": 143.97,
"currency": "CNY",
"direction": "expense",
"counterparty": "GOOGLE *ChatGPT",
"memo": {
"card_last_4_digits": "3315",
"original_amount": 21.19,
"country_or_region": "美国"
}
}
```
---
## 五、 免费模型配额与 Rate Limits 规则
### 1. 硅基流动 (SiliconFlow)
- **门槛**:需完成账户实名认证。
- **配额**
- **RPM (Requests Per Minute)**100 ~ 1,000 RPM(中小模型 500~1000 RPM)。
- **TPM (Tokens Per Minute)**50,000 ~ 100,000 TPM。
- **Pro/ 专线**:带 `Pro/` 前缀的模型(如 `Pro/deepseek-ai/DeepSeek-V3`)为付费独占集群,无免费版的固定并发上限。
### 2. 智谱开放平台 (BigModel.cn)
- **免费规则**:所有的 Flash 命名系列(`GLM-4-Flash``GLM-4V-Flash` 等)API 均免费开放。
- **并发控制**:对高频连续调用设置了 RPM 阈值(触发时返回 `HTTP 429 Too Many Requests`),代码中需配置指数退避或 1~2 秒重试间隔。
---
## 六、 DriftLedger (浮记) 架构与账户分配流程
针对识别结果中“为什么只包含时间、金额、商户名,而没有 Beancount 账户”的说明:
### 1. 职责解耦设计
识图/OCR 模块只负责提取客观的**原始交易事件 (`ImportedEvent`)**,定义于 [types.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/core/types.ts#L31-L38)。由于每个用户的 Beancount 账户名(如 `Assets:招商银行:信用卡3315`)是高度个性化的,模型无法预知用户本地账本结构。
### 2. 账本账户决定流程
账户映射是在 **`BillPipeline` 责任链** 中完成的:
```text
[账单截图]
▼ 1. 图像解析 (AiVisionProcessor.ts)
[ImportedEvent] ─── (仅含时间、金额、商户 GOOGLE *ChatGPT、备注 3315)
▼ 2. 进入流水线 BillPipeline.process() ─── [billPipeline.ts]
├──> ① 转账识别 (recognizeTransfers)
├──> ② 批次去重与历史去重 (dedup)
└──> ③ 规则匹配与账户分类 (rules.ts)
├── 匹配商户/卡号 "3315" ──> 资金来源账户 (sourceAccount): Assets:招商银行:信用卡3315
└── 匹配商户 "GOOGLE *ChatGPT" ──> 支出分类账户 (categoryAccount): Expenses:订阅服务:AI
▼ 3. 输出标准交易草稿 (TransactionDraft)
[TransactionDraft] ─── (产生符合 Beancount 规范的两笔双式记账 Postings)
```
---
## 七、 AI 账户自动分类实测 (免费 LLM vs 路径 B 向量检索)
实测验证:能否利用 AI 模型根据用户本地的 Beancount 候选账户列表自动完成账户匹配?
### 1. 实测结果展示
#### 路径 A:免费 LLM 直选(智谱 `glm-4-flash-250414`,耗时 2.22 秒)
传入 9 个 Beancount 候选账户 + 交易事实,模型 100% 精确返回:
```json
{
"sourceAccount": "Liabilities:CreditCard:CMB:3315",
"categoryAccount": "Expenses:Software:Subscription",
"confidence": "high",
"reason": "交易备注说明信用卡尾号3315,且根据金额和商户判断为软件订阅支出"
}
```
#### 路径 B:向量 Embedding 余弦相似度检索(硅基流动 `BAAI/bge-m3`,耗时 0.32 秒)
将交易文本与账户生成 1024 维向量进行余弦相似度计算,点积耗时 `< 1ms`
- **Top 1 命中**`Liabilities:CreditCard:CMB:3315`(相似度 **0.5662**
- **Top 2 命中**`Expenses:Shopping:Digital`(相似度 0.5302
### 2. 路径 B 的完整实现逻辑拆解
```text
[导入交易 ImportedEvent]
商户: GOOGLE *ChatGPT | 备注: 信用卡尾号3315
├───> 资金搜索文本 ──> BAAI/bge-m3 ──> 向量对比 ──> 提取最高分: Liabilities:CreditCard:CMB:3315
└───> 分类搜索文本 ──> BAAI/bge-m3 ──> 向量对比 ──> 提取最高分: Expenses:Software:Subscription
```
1. **账户隔离与语义增强 (离线缓存)**
- 将账户拆分为【资金空间 Assets/Liabilities】与【分类空间 Expenses/Income】。
- 为账号补充别名描述(如 `Liabilities:CreditCard:CMB:3315``"招商银行 信用卡 尾号3315 掌上生活"`)。
2. **构建向量索引**:使用 `BAAI/bge-m3` 将所有账户转为向量缓存在本地内存/SQLite 中。
3. **实时查询向量化**:收到交易时,分别生成资金查询与分类查询,耗时约 150 毫秒。
4. **双路余弦相似度检索**:通过点积公式计算相似度,并提取最高分。
5. **阈值判定与推荐**:高分自动填充,低分弹出 Top 3 快捷芯片让用户点选。
---
### 三、 相关代码位置
- **原始事件类型定义**[src/domain/core/types.ts:L31-L38](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/core/types.ts#L31-L38)
- **账单流水线责任链**[src/domain/pipeline/billPipeline.ts:L94-L180](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/pipeline/billPipeline.ts#L94-L180)
- **规则分类与账户映射引擎**[src/domain/rules/rules.ts:L1-L60](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/rules/rules.ts#L1-L60)
- **AI 识图处理器服务**[src/services/ocr/AiVisionProcessor.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/services/ocr/AiVisionProcessor.ts)
+117
View File
@@ -0,0 +1,117 @@
// @ts-check
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import reactNative from 'eslint-plugin-react-native';
export default tseslint.config(
// 全局忽略
{
ignores: [
'node_modules/**',
'android/**',
'ios/**',
'.expo/**',
'reference_project/**',
'outputs/**',
'*.js', // 根目录脚本(fix_*.js 等)
'plugins/**/android/**',
],
},
// 基础推荐规则
js.configs.recommended,
...tseslint.configs.recommended,
// 全局规则调整
{
rules: {
// RN 中原生模块加载常使用 require()
'@typescript-eslint/no-require-imports': 'off',
// 不强制要求 error cause
'preserve-caught-error': 'off',
},
},
// React Hooks 规则
{
plugins: {
'react-hooks': reactHooks,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// 以下规则对 RN 项目过于严格,暂时关闭
'react-hooks/purity': 'off', // Date.now() 在渲染中使用是常见模式
'react-hooks/immutability': 'off', // 修改外部变量(如 i18n.locale)是有意为之
'react-hooks/refs': 'off', // 访问 ref 值在 RN 中常见
'react-hooks/preserve-caught-error': 'off', // 不强制要求 cause
},
},
// React Native 规则(仅对 src/ 下的 tsx/ts 文件)
{
files: ['src/**/*.{ts,tsx}'],
plugins: {
'react-native': reactNative,
},
rules: {
'react-native/no-unused-styles': 'warn',
'react-native/no-inline-styles': 'off', // 项目中存在合理的内联样式
'react-native/no-color-literals': 'off', // 使用主题 token,但允许少量直接颜色
'react-native/no-raw-text': 'off', // 不强制所有文本包裹
},
},
// 项目自定义规则
{
files: ['src/**/*.{ts,tsx}'],
rules: {
// TypeScript 严格相关
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
}],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
// RN 中原生模块加载常使用 require(),允许
'@typescript-eslint/no-require-imports': 'off',
// 代码质量
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'error',
'no-var': 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
// Domain 层纯净性:禁止引入 React/RN
'no-restricted-imports': ['error', {
patterns: [{
group: ['react', 'react-native', 'expo-*'],
message: 'Domain 层(src/domain/)不允许引入 React/RN/Expo 依赖。',
}],
}],
},
},
// Domain 层豁免(domain 目录不需要 React 限制,但上面已全局限制)
// 实际上 no-restricted-imports 对 domain 层生效即可
// 非 domain 层解除限制
{
files: ['src/**/*.{ts,tsx}', '!src/domain/**'],
rules: {
'no-restricted-imports': 'off',
},
},
// 测试文件宽松规则
{
files: ['tests/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'warn', // 测试中未使用变量降为警告
'@typescript-eslint/ban-ts-comment': 'off', // 测试中允许 @ts-ignore
'no-console': 'off',
},
},
);
+3731 -599
View File
File diff suppressed because it is too large Load Diff
+38 -4
View File
@@ -1,26 +1,60 @@
{
"name": "beancount-mobile",
"name": "drift-ledger",
"version": "0.1.0",
"private": true,
"main": "node_modules/expo/AppEntry.js",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"test": "vitest run",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"lint": "eslint src/ tests/",
"lint:fix": "eslint src/ tests/ --fix"
},
"dependencies": {
"@expo/metro-runtime": "^6.1.2",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/drawer": "^7.5.0",
"expo": "~54.0.0",
"expo-constants": "^18.0.13",
"expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.0",
"expo-image-picker": "~17.0.11",
"expo-linking": "^8.0.12",
"expo-local-authentication": "~17.0.8",
"expo-localization": "~17.0.9",
"expo-notifications": "~0.32.17",
"expo-router": "~6.0.24",
"expo-secure-store": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-sqlite": "~16.0.0",
"expo-status-bar": "~3.0.0",
"i18n-js": "^4.5.3",
"react": "19.1.0",
"react-native": "0.81.0"
"react-dom": "^19.1.0",
"react-native": "0.81.0",
"react-native-gesture-handler": "^2.28.0",
"react-native-reanimated": "^3.18.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.5",
"react-native-view-shot": "4.0.3",
"text-encoding-gbk": "^0.7.3",
"xlsx": "^0.18.5",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "~19.1.0",
"eslint": "^9.39.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-native": "^5.0.0",
"typescript": "~5.9.0",
"typescript-eslint": "^8.65.0",
"vitest": "^3.2.0"
},
"overrides": {
"expo-font": "~14.0.12"
}
}
-458
View File
@@ -1,458 +0,0 @@
# OCR 自动记账集成方案
## 摘要
借鉴 AutoAccounting 项目的 OCR 模式,为 beancount-mobile 增加屏幕识别自动记账能力。用户在支付宝/微信/银行 App 付款后,通过无障碍服务截屏 → OCR 识别 → 解析账单 → 生成 Beancount 复式分录 → 用户确认入账。
**核心原则**:不修改任何应用、不需要 Root、不需要 Shizuku,仅依赖 Android 无障碍权限。
## 技术方案
### 架构概览
```
用户在支付 App 完成付款
Android 无障碍服务检测到页面变化
AccessibilityService.takeScreenshot() 截屏(API 30+
Google ML Kit OCR 识别文字
正则 + 规则引擎解析为 ImportedEvent
复用现有 classify() → TransactionDraft
用户确认 → commitMobileTransaction() → mobile.bean
```
### 依赖 AutoAccounting 的部分
| AutoAccounting 组件 | 用途 | beancount-mobile 替代方案 |
| --------------------------- | ------------------------- | ---------------------------------- |
| `OcrTools.kt` | 无障碍截屏 + 前台应用检测 | 原生模块重写,逻辑一致 |
| `OcrProcessor.kt` | PP-OCRv5 文字识别 | Google ML Kit(免费,无需 AAR |
| `JsExecutor.kt` | QuickJS 规则引擎 | 复用现有`importStatement` + 规则 |
| `BillService.kt` | 账单分析流程 | 复用现有`classify()` 流程 |
| `PageSignatureManager.kt` | 页面特征匹配 | 可选,首版不做 |
| `FlipDetector.kt` | 翻转触发 | 改为悬浮按钮触发 |
### 不依赖的部分
- Shizuku SDK(无障碍模式不需要)
- Xposed/LSPatch 框架
- Ktor 嵌入式服务器
- Room 数据库
- MMKV 配置存储
- TapBack 双击背部模块
## 实现计划
### 阶段一:原生模块搭建(3 天)
#### 1.1 创建 Android 原生模块目录结构
```
android/app/src/main/java/com/beancount/mobile/
├── ocr/
│ ├── OcrModule.kt # React Native 原生模块
│ ├── OcrAccessibilityService.kt # 无障碍服务
│ └── OcrManager.kt # OCR 处理管理器
```
#### 1.2 实现 OcrAccessibilityService.kt
参考 AutoAccounting 的 `OcrTools.kt`,实现:
```kotlin
class OcrAccessibilityService : AccessibilityService() {
// 1. 监听窗口变化事件
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// 检测前台应用变化
// 触发截屏回调
}
// 2. 截屏功能(API 30+
fun takeScreenshot(callback: (Bitmap?) -> Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
takeScreenshot(
Display.DEFAULT_DISPLAY,
mainExecutor,
object : TakeScreenshotCallback {
override fun onSuccess(result: ScreenshotResult) {
val bitmap = Bitmap.wrapHardwareBuffer(
result.hardwareBuffer, result.colorSpace
)
callback(bitmap)
result.hardwareBuffer.close()
}
override fun onFailure(errorCode: Int) {
callback(null)
}
}
)
}
}
// 3. 获取前台应用包名
fun getTopPackage(): String? {
// 通过 rootInActiveWindow 获取
}
}
```
#### 1.3 实现 OcrModule.ktReact Native Bridge
```kotlin
@ReactModule(name = "OcrModule")
class OcrModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
@ReactMethod
fun startOcrService(promise: Promise) {
// 启动无障碍服务
}
@ReactMethod
fun stopOcrService(promise: Promise) {
// 停止无障碍服务
}
@ReactMethod
fun takeScreenshot(promise: Promise) {
// 调用无障碍服务截屏
// 返回 base64 编码的图片
}
@ReactMethod
fun getTopApp(promise: Promise) {
// 返回前台应用包名
}
@ReactMethod
fun isServiceEnabled(promise: Promise) {
// 检查无障碍服务是否已启用
}
}
```
#### 1.4 配置 AndroidManifest.xml
```xml
<service
android:name=".ocr.OcrAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
```
#### 1.5 创建无障碍服务配置
`android/app/src/main/res/xml/accessibility_service_config.xml`:
```xml
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/ocr_service_description"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="100"
android:canTakeScreenshot="true"
android:canRetrieveWindowContent="false" />
```
### 阶段二:OCR 引擎集成(2 天)
#### 2.1 添加 ML Kit 依赖
`android/app/build.gradle`:
```gradle
dependencies {
// Google ML Kit OCR
implementation 'com.google.mlkit:text-recognition-chinese:16.0.0'
}
```
#### 2.2 实现 OcrManager.kt
```kotlin
class OcrManager(private val context: Context) {
private val recognizer = TextRecognition.getClient(
ChineseTextRecognizerOptions.Builder().build()
)
suspend fun recognizeText(bitmap: Bitmap): String {
val image = InputImage.fromBitmap(bitmap, 0)
val result = recognizer.process(image).await()
return result.text
}
}
```
#### 2.3 金额/商户正则解析
参考 AutoAccounting 的 JS 规则,用 Kotlin 正则实现:
```kotlin
object BillParser {
// 金额匹配:¥100.00 / 100.00元 / -50.50
private val amountPattern = Regex("""[¥¥]?\s*(-?\d+\.?\d*)\s*元?""")
// 时间匹配:2026-07-10 14:30:00 / 07-10 14:30
private val timePattern = Regex("""(\d{4}[-/]\d{2}[-/]\d{2}\s+\d{2}:\d{2}(?::\d{2})?)""")
// 商户匹配:支付成功至 XXX / 商户名称:XXX
private val merchantPattern = Regex("""(?:商户|商家|收款方)[:]\s*(.+?)(?:\s|$)""")
fun parse(ocrText: String, appPackage: String): ImportedEvent? {
val amount = amountPattern.find(ocrText)?.groupValues?.get(1) ?: return null
val time = timePattern.find(ocrText)?.groupValues?.get(1) ?: return null
val merchant = merchantPattern.find(ocrText)?.groupValues?.get(1) ?: "未知商户"
// 根据 appPackage 判断渠道
val channel = when {
appPackage.contains("alipay") -> "Alipay"
appPackage.contains("wechat") -> "WeChat"
else -> "Bank"
}
return ImportedEvent(
id = "ocr-${System.currentTimeMillis()}",
occurredAt = time.replace("/", "-").take(10),
amount = amount,
currency = "CNY",
direction = if (amount.startsWith("-")) "expense" else "income",
channel = channel,
counterparty = merchant,
memo = "OCR 自动识别",
raw = mapOf("ocrText" to ocrText)
)
}
}
```
### 阶段三:JS 层集成(2 天)
#### 3.1 创建 OCR Bridge 模块
`src/ocr/OcrBridge.ts`:
```typescript
import { NativeModules, Platform } from 'react-native';
const { OcrModule } = NativeModules;
export interface OcrResult {
text: string;
imagePath: string;
}
export class OcrBridge {
static async isAvailable(): Promise<boolean> {
if (Platform.OS !== 'android') return false;
return await OcrModule?.isServiceEnabled() ?? false;
}
static async startService(): Promise<void> {
await OcrModule?.startOcrService();
}
static async takeScreenshot(): Promise<string | null> {
return await OcrModule?.takeScreenshot();
}
static async getTopApp(): Promise<string | null> {
return await OcrModule?.getTopApp();
}
}
```
#### 3.2 OCR 识别流程
`src/ocr/OcrProcessor.ts`:
```typescript
import { OcrBridge } from './OcrBridge';
import { importStatement, type ImportedEvent } from '../domain';
export async function processOcrScreenshot(): Promise<ImportedEvent | null> {
// 1. 截屏
const base64Image = await OcrBridge.takeScreenshot();
if (!base64Image) return null;
// 2. 获取前台应用
const topApp = await OcrBridge.getTopApp();
if (!topApp) return null;
// 3. OCR 识别(通过原生模块)
const ocrText = await OcrModule.recognizeText(base64Image);
if (!ocrText) return null;
// 4. 解析为 ImportedEvent
const event = parseOcrText(ocrText, topApp);
return event;
}
```
#### 3.3 集成到现有导入流程
修改 `App.tsx` 的导入标签页,添加 OCR 入口:
```typescript
const handleOcrImport = async () => {
const event = await processOcrScreenshot();
if (event) {
setEvents(current => [...current, event]);
setMessage('OCR 识别成功,请确认账单。');
} else {
setMessage('OCR 识别失败,请重试。');
}
};
```
### 阶段四:UI 适配(2 天)
#### 4.1 添加 OCR 触发按钮
在导入标签页添加"屏幕识别"按钮:
```tsx
{tab === '导入' && (
<>
<Card title="自动记账">
<Button label="屏幕识别(OCR" onPress={handleOcrImport} />
<Button label="导入 CSV 文件" onPress={loadDemo} />
</Card>
{/* 现有的事件列表 */}
</>
)}
```
#### 4.2 OCR 结果确认界面
展示识别结果,允许用户修正:
```tsx
<Card title={`OCR 识别 · ${event.amount} ${event.currency}`}>
<Text>{event.counterparty} · {event.memo}</Text>
<Text style={styles.muted}>{event.channel} · {event.occurredAt}</Text>
{/* 编辑按钮 */}
<Button label="确认入账" onPress={() => confirm(event)} />
</Card>
```
#### 4.3 无障碍权限引导
首次使用时引导用户开启无障碍权限:
```tsx
const enableOcr = async () => {
const available = await OcrBridge.isAvailable();
if (!available) {
// 打开系统无障碍设置
await OcrBridge.startService();
}
};
```
### 阶段五:测试与优化(2 天)
#### 5.1 测试用例
| 测试场景 | 预期结果 |
| ---------------------- | ------------------------ |
| 支付宝付款成功页 OCR | 正确识别金额、商户、时间 |
| 微信支付凭证页 OCR | 正确识别金额、商户 |
| 银行卡扣款通知页 OCR | 正确识别金额、来源 |
| 识别失败(非支付页面) | 返回 null,提示重试 |
| 重复识别同一笔交易 | 去重,提示已存在 |
#### 5.2 性能优化
- 截屏后立即回收 Bitmap,避免内存泄漏
- OCR 识别在后台线程执行
- 缓存最近识别结果,避免重复处理
#### 5.3 兼容性处理
- Android 11 以下不支持 `takeScreenshot()`,降级为提示用户手动截图
- 不同支付 App 的页面布局差异,通过正则适配
## 文件清单
### 新增文件
```
android/app/src/main/java/com/beancount/mobile/ocr/
├── OcrModule.kt # React Native 原生模块
├── OcrAccessibilityService.kt # 无障碍服务
└── OcrManager.kt # OCR 处理管理器
android/app/src/main/res/xml/
└── accessibility_service_config.xml # 无障碍服务配置
src/ocr/
├── OcrBridge.ts # JS 层 Bridge
└── OcrProcessor.ts # OCR 处理逻辑
```
### 修改文件
```
android/app/build.gradle # 添加 ML Kit 依赖
android/app/src/main/AndroidManifest.xml # 注册无障碍服务
App.tsx # 添加 OCR 入口
package.json # 无变化(纯原生模块)
```
## 依赖清单
| 依赖 | 版本 | 用途 | 必需 |
| ------------------------------ | ------- | -------- | ---- |
| Google ML Kit Text Recognition | 16.0.0 | 中文 OCR | 是 |
| React Native | 0.81.0 | 框架 | 是 |
| Expo | ~54.0.0 | 开发框架 | 是 |
**不需要的依赖**
- Shizuku SDK
- Xposed/LSPatch
- PP-OCR AAR
- QuickJS
## 风险与限制
| 风险 | 影响 | 缓解措施 |
| --------------------------- | ------------------ | ------------------ |
| Android < 11 不支持截屏 API | 低版本设备无法使用 | 降级为手动截图导入 |
| 支付 App 页面更新 | OCR 正则失效 | 正则设计为宽松匹配 |
| 无障碍权限被系统回收 | 服务停止 | 前台通知保活 |
| OCR 识别准确率 | 可能识别错误 | 用户确认环节兜底 |
## 预估工期
| 阶段 | 工作量 | 产出 |
| ---------------- | --------------- | ----------------- |
| 阶段一:原生模块 | 3 天 | 无障碍服务 + 截屏 |
| 阶段二:OCR 引擎 | 2 天 | ML Kit 集成 |
| 阶段三:JS 集成 | 2 天 | Bridge + 解析 |
| 阶段四:UI 适配 | 2 天 | 按钮 + 确认界面 |
| 阶段五:测试优化 | 2 天 | 测试用例 + 兼容性 |
| **总计** | **11 天** | |
## 与 plan.md 的关系
本方案是 plan.md 的扩展,补充了 OCR 自动记账能力。plan.md 中明确"首版不做 OCR",本方案作为第二阶段实现。
两份文档的关系:
- `plan.md`:核心架构 + CSV 导入 + 手工记账(首版)
- `plan-ocr.md`:OCR 屏幕识别 + 自动记账(第二阶段)
实现顺序:先完成 plan.md 的核心功能,再实现 plan-ocr.md 的 OCR 能力。
+4562 -48
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
# Expo Config Plugins
本目录包含所有原生模块的 Expo Config Plugin。由于 `android/` 目录由 `expo prebuild` 自动生成且被 Git 忽略,所有原生代码必须以 Config Plugin 形式存在。
## 插件结构
```
plugins/<name>/
app.plugin.js # prebuild 时执行,注入 Kotlin/XML、修改 gradle、注册服务
package.json # 插件元数据
android/*.kt # 原生 Kotlin 代码
assets/ # 模型文件等静态资源(仅 ppocr)
```
## 当前插件
| 插件 | 功能 | 备注 |
|------|------|------|
| `ppocr` | PP-OCRv6 检测+识别 | ONNX Runtime,模型在 `assets/` |
| `accessibility` | 无障碍服务账单抓取 + 悬浮窗 + OCR 贴片 | 仅侧载场景 |
| `notification-listener` | 通知栏账单监听 | |
| `sms-receiver` | 短信账单解析 | |
| `screenshot-monitor` | 截图触发 OCR | |
| `size-optimization` | ABI 分包 + NDK 版本统一 | 仅影响 gradle 配置 |
## 开发须知
1. 每个插件函数**必须 `return config`**,否则后续插件会崩溃
2. 修改插件后需重新运行 `npx expo prebuild --platform android`
3. 原生服务通过 `NativeEventEmitter` 向 JS 层推送事件,**不直接写入**数据库或文件
4. `ppocr` 依赖 `onnxruntime-android:1.20.0`prebuild 后请验证版本
@@ -0,0 +1,324 @@
package com.beancount.mobile.accessibility
import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
/**
* 无障碍服务 RN 桥接模块plan.md3.6 无障碍服务JS 接线
*
* SelectToSpeakService AccessibilityService 子类 RN 模块
* 其方法无法直接从 JS 调用本模块作为中间层通过 instance 静态引用
* JS 调用委托给服务实例
*
* Config Plugin withMainApplication 注入 add(AccessibilityBridgePackage())
*/
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
companion object {
private const val TAG = "AccessibilityBridge"
}
init {
ReactContextHolder.context = reactContext
}
override fun invalidate() {
super.invalidate()
if (ReactContextHolder.context === reactContext) {
ReactContextHolder.context = null
}
}
override fun getName() = "AccessibilityBridge"
/** 无障碍服务是否已连接(用户已在系统设置中启用)。 */
@ReactMethod
fun isServiceRunning(promise: Promise) {
promise.resolve(SelectToSpeakService.instance != null)
}
/**
* 记住当前页面把当前顶部 App pkg|activity 加入白名单
* 之后该页面内容变化时自动截图 OCR
*/
@ReactMethod
fun rememberCurrentPage(promise: Promise) {
val service = SelectToSpeakService.instance
if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return
}
val pkg = service.getTopPackage()
if (pkg == null) {
promise.reject("NO_TOP_PACKAGE", "当前没有检测到前台 App")
return
}
service.rememberCurrentPage()
val result = WritableNativeMap()
result.putString("package", pkg)
result.putString("activity", service.getTopActivity() ?: "")
result.putString("signature", "$pkg|${service.getTopActivity() ?: ""}")
promise.resolve(result)
}
/** 手动触发一次 OCR(截取当前屏幕并发送给 JS 层处理)。 */
@ReactMethod
fun triggerManualOcr(promise: Promise) {
val service = SelectToSpeakService.instance
if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return
}
try {
service.triggerManualOcr()
promise.resolve(true)
} catch (e: Exception) {
promise.reject("OCR_TRIGGER_FAIL", e.message)
}
}
/** 获取所有已记住的页面签名列表。 */
@ReactMethod
fun getPageSignatures(promise: Promise) {
val service = SelectToSpeakService.instance
val sigsSet = if (service != null) {
service.getPageSignatures()
} else {
try {
val prefs = reactContext.getSharedPreferences("billing_accessibility_prefs", android.content.Context.MODE_PRIVATE)
prefs.getStringSet("page_signatures", emptySet()) ?: emptySet()
} catch (e: Exception) {
emptySet()
}
}
val arr = WritableNativeArray()
for (sig in sigsSet) {
val parts = sig.split("|", limit = 2)
val map = WritableNativeMap()
map.putString("signature", sig)
map.putString("package", parts.getOrNull(0) ?: "")
map.putString("activity", parts.getOrNull(1) ?: "")
arr.pushMap(map)
}
promise.resolve(arr)
}
/** 清空所有已记住的页面签名。 */
@ReactMethod
fun clearPageSignatures(promise: Promise) {
val service = SelectToSpeakService.instance
if (service != null) {
service.clearPageSignatures()
} else {
try {
val prefs = reactContext.getSharedPreferences("billing_accessibility_prefs", android.content.Context.MODE_PRIVATE)
prefs.edit().putStringSet("page_signatures", emptySet()).apply()
} catch (e: Exception) {
promise.reject("CLEAR_PREFS_FAIL", e.message)
return
}
}
promise.resolve(true)
}
/** 删除指定页面签名。 */
@ReactMethod
fun removePageSignature(signature: String, promise: Promise) {
val service = SelectToSpeakService.instance
if (service != null) {
service.removePageSignature(signature)
} else {
try {
val prefs = reactContext.getSharedPreferences("billing_accessibility_prefs", android.content.Context.MODE_PRIVATE)
val saved = prefs.getStringSet("page_signatures", emptySet()) ?: emptySet()
val mutable = HashSet(saved)
if (mutable.remove(signature)) {
prefs.edit().putStringSet("page_signatures", mutable).apply()
}
} catch (e: Exception) {
promise.reject("REMOVE_PREFS_FAIL", e.message)
return
}
}
promise.resolve(true)
}
/** 获取支付 App 白名单(供 JS 端展示)。 */
@ReactMethod
fun getPaymentPackages(promise: Promise) {
val arr = WritableNativeArray()
for (pkg in SelectToSpeakService.PAYMENT_PACKAGES) {
arr.pushString(pkg)
}
promise.resolve(arr)
}
/** 获取当前顶部 App 信息(供 JS 判断用户是否在支付页面)。 */
@ReactMethod
fun getTopApp(promise: Promise) {
val service = SelectToSpeakService.instance
if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return
}
val map = WritableNativeMap()
map.putString("package", service.getTopPackage() ?: "")
map.putString("activity", service.getTopActivity() ?: "")
promise.resolve(map)
}
/** 将当前应用拉起至前台,用以在后台识别出账单后,弹窗让用户进行交易确认 */
@ReactMethod
fun bringAppToForeground(promise: Promise) {
val service = SelectToSpeakService.instance
if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return
}
try {
service.bringAppToForeground()
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FAIL", e.message)
}
}
/** 显示账单浮窗(直接在当前其他应用上方渲染,不返回 App 内) */
@ReactMethod
fun showFloatingBill(
amount: String,
merchant: String,
time: String,
packageName: String,
categories: ReadableArray,
accounts: ReadableArray,
direction: String,
currency: String,
draftId: String,
promise: Promise
) {
val context = SelectToSpeakService.instance ?: reactContext.currentActivity
if (context == null) {
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
return
}
val categoryList = mutableListOf<Map<String, String>>()
for (i in 0 until categories.size()) {
val map = categories.getMap(i)
categoryList.add(mapOf(
"id" to (map?.getString("id") ?: ""),
"name" to (map?.getString("name") ?: ""),
"account" to (map?.getString("account") ?: ""),
"type" to (map?.getString("type") ?: "")
))
}
val accountList = mutableListOf<String>()
for (i in 0 until accounts.size()) {
accountList.add(accounts.getString(i) ?: "")
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
try {
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction, currency)
floatingView.show()
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FAIL", e.message)
}
}
}
/** 设置悬浮球开关状态。 */
@ReactMethod
fun setFloatingBallEnabled(enabled: Boolean, promise: Promise) {
SelectToSpeakService.floatingBallEnabled = enabled
val service = SelectToSpeakService.instance
if (service == null) {
promise.resolve(true)
return
}
if (!enabled) {
service.dismissFloatingHelper()
} else {
service.refreshFloatingHelper()
}
promise.resolve(true)
}
/** 从 APK assets 复制 OCR 模型文件到 filesDir/ocr_models_v6。 */
@ReactMethod
fun copyOcrModelsFromAssets(promise: Promise) {
try {
val destDir = java.io.File(reactContext.filesDir, "ocr_models_v6")
if (!destDir.exists()) destDir.mkdirs()
val assetManager = reactContext.assets
val files = listOf("ppocrv6_det.onnx", "ppocrv6_rec.onnx", "ppocrv6_dict.txt")
for (filename in files) {
val destFile = java.io.File(destDir, filename)
if (destFile.exists() && destFile.length() > 0) continue
assetManager.open(filename).use { input ->
java.io.FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
}
promise.resolve(destDir.absolutePath)
} catch (e: Exception) {
promise.reject("COPY_MODEL_FAIL", e.message)
}
}
/** 检查通知监听服务是否已启用(反射调用 getEnabledListenerPackages,避免 API level lint 错误)。 */
@ReactMethod
fun isNotificationListenerEnabled(promise: Promise) {
try {
// 通过 Settings.Secure 读取系统设置(公开 API,无需反射,不受 hidden API 限制)
// enabled_notification_listeners 格式:
// "com.pkg1/com.pkg1.Service:com.pkg2/com.pkg2.Service"
val flat = android.provider.Settings.Secure.getString(
reactContext.contentResolver,
"enabled_notification_listeners"
) ?: ""
val myPkg = reactContext.packageName
promise.resolve(flat.split(":").any { it.startsWith(myPkg) })
} catch (e: Exception) {
Log.w(TAG, "isNotificationListenerEnabled 检测失败", e)
promise.resolve(false)
}
}
/** 检查悬浮窗权限(SYSTEM_ALERT_WINDOW)是否已授予。 */
@ReactMethod
fun canDrawOverlays(promise: Promise) {
try {
promise.resolve(android.provider.Settings.canDrawOverlays(reactContext))
} catch (e: Exception) {
promise.resolve(false)
}
}
/**
* 下发浮层 UI 配置颜色 + 文案
* JS 侧从 theme tokens + i18n 构建 config
* 原生存 SharedPreferences三个浮层组件读取
*/
@ReactMethod
fun setFloatingUiConfig(config: ReadableMap, promise: Promise) {
try {
FloatingUiConfigStore.save(reactContext, config)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("CONFIG_SAVE_FAIL", e.message)
}
}
}
@@ -0,0 +1,20 @@
package com.beancount.mobile.accessibility
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
/**
* ReactPackage 注册 AccessibilityBridgeModule
* Config Plugin withMainApplication 注入 add(AccessibilityBridgePackage())
*/
class AccessibilityBridgePackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(AccessibilityBridgeModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
@@ -0,0 +1,825 @@
package com.beancount.mobile.accessibility
import android.content.Context
import android.graphics.PixelFormat
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.TextView
import android.widget.EditText
import android.widget.HorizontalScrollView
import android.widget.LinearLayout
import android.text.InputType
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.math.BigDecimal
/**
* 浮窗账单提示直接呈现高度优化支持三方向切换的修改入账面板
* P6 重设计颜色/文案走 FloatingUiConfigStore新增币种 chip金额校验改正则+BigDecimal
*/
class FloatingBillView(
private val context: Context,
private val draftId: String,
private val amount: String,
private val merchant: String,
private val time: String,
private val packageName: String,
private val categories: List<Map<String, String>> = emptyList(),
private val accounts: List<String> = emptyList(),
private val initialDirection: String = "expense",
private val initialCurrency: String = "CNY"
) {
companion object {
private const val TAG = "FloatingBillView"
private val DEFAULT_CURRENCY_LIST = listOf("CNY", "USD", "HKD", "JPY", "EUR", "GBP")
}
// 从 JS 下发的配置读取颜色与文案(缺失字段回退默认值,保证旧 JS 行为不变)
private val config = FloatingUiConfigStore.load(context)
// 显示币种列表;若 initialCurrency 不在默认列表中则临时插入首位
private val currencyList: List<String> = run {
val list = DEFAULT_CURRENCY_LIST.toMutableList()
if (!list.contains(initialCurrency)) {
list.add(0, initialCurrency)
}
list
}
private var currentCurrency: String = initialCurrency
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private var view: View? = null
private val handler = Handler(Looper.getMainLooper())
private var selectedCategoryAccount: String = ""
private var selectedSourceAccount: String = ""
private var currentDirection: String = "expense"
private var categoryLabel: TextView? = null
private var categoryContainer: LinearLayout? = null
private var accountLabel: TextView? = null
private var accountContainer: LinearLayout? = null
private var saveBtn: Button? = null
// 预解析常用颜色(parseColorOr 异常时回退默认值)
private val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
private val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
private val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
private val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
private val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
private val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
private val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
private val colorIncome = FloatingUiConfigStore.parseColorOr(config.colors.income, "#FF10B981")
private val colorExpense = FloatingUiConfigStore.parseColorOr(config.colors.expense, "#FFE11D48")
private val colorTransfer = FloatingUiConfigStore.parseColorOr(config.colors.transfer, "#FF10B981")
// 容器边框直接使用 config border(不再从 accent 派生 alpha
private fun dp(value: Float): Int {
val density = context.resources.displayMetrics.density
return (value * density).toInt()
}
private fun createChipView(text: String): TextView {
return TextView(context).apply {
this.text = text
textSize = 11f
setPadding(dp(8f), dp(4f), dp(8f), dp(4f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
rightMargin = dp(6f)
}
}
}
/** 显示悬浮窗。 */
fun show() {
try {
currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense"
// 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部
val overlayType = if (context is android.accessibilityservice.AccessibilityService) {
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY
} else {
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
}
val windowParams = WindowManager.LayoutParams(
dp(310f),
WindowManager.LayoutParams.WRAP_CONTENT,
overlayType,
0, // 0 标志代表可获取焦点
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
y = dp(48f) // 留出底部导航栏空间 + 按钮/键盘安全区
}
// 2. 实色卡片背景(不再半透明磨砂),圆角 16dp,1dp 描边
val containerBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(16f).toFloat()
setColor(colorCardBg)
setStroke(dp(1f), colorBorder) // 使用 config border 颜色
}
val container = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
background = containerBg
setPadding(dp(16f), dp(16f), dp(16f), dp(16f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
// 顶部标题与方向选择器布局
val headerLayout = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
val titleText = TextView(context).apply {
text = config.labels.billTitle
textSize = 13f
setTextColor(colorFgPrimary)
paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
headerLayout.addView(titleText)
// 三方向分段选择器 (支出 / 收入 / 转账)
val segmentContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
setColor((colorFgPrimary and 0x00FFFFFF) or 0x12000000) // 7% fg overlay
}
}
val tabTexts = listOf(config.labels.dirExpense, config.labels.dirIncome, config.labels.dirTransfer)
val tabDirections = listOf("expense", "income", "transfer")
val tabViews = mutableListOf<TextView>()
fun updateTabStyle() {
for (i in tabViews.indices) {
val active = tabDirections[i] == currentDirection
tabViews[i].apply {
setTextColor(if (active) colorAccentFg else colorFgSecondary)
background = if (active) {
GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
setColor(colorAccent)
}
} else {
null
}
}
}
}
for (i in tabTexts.indices) {
val tab = TextView(context).apply {
text = tabTexts[i]
textSize = 10f
setPadding(dp(6f), dp(3f), dp(6f), dp(3f))
gravity = Gravity.CENTER
setOnClickListener {
if (currentDirection != tabDirections[i]) {
currentDirection = tabDirections[i]
updateTabStyle()
rebuildChips()
}
}
}
segmentContainer.addView(tab)
tabViews.add(tab)
}
updateTabStyle()
headerLayout.addView(segmentContainer)
container.addView(headerLayout)
// 金额行:币种 chip(左)+ 金额输入框(右)
val amountLabel = TextView(context).apply {
text = config.labels.amountLabel
textSize = 10f
paint.isFakeBoldText = true
setTextColor(colorFgSecondary)
setPadding(0, dp(8f), 0, dp(1f))
}
container.addView(amountLabel)
val amountRow = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
// 币种 chip
val currencyChip = TextView(context).apply {
text = currentCurrency
textSize = 11f
setTextColor(colorFgSecondary)
setPadding(dp(8f), dp(4f), dp(8f), dp(4f))
gravity = Gravity.CENTER
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
setOnClickListener {
val idx = currencyList.indexOf(currentCurrency)
currentCurrency = currencyList[(idx + 1) % currencyList.size]
text = currentCurrency
}
}
amountRow.addView(currencyChip)
val amountInput = EditText(context).apply {
textSize = 13f
setTextColor(colorFgPrimary)
setHintTextColor(colorFgSecondary)
background = GradientDrawable().apply {
setColor(colorInputBg)
cornerRadius = dp(10f).toFloat()
setStroke(dp(1f), colorBorder)
}
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
layoutParams = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
).apply {
leftMargin = dp(8f)
}
}
amountRow.addView(amountInput)
container.addView(amountRow)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
// 商户编辑
val merchantLabel = TextView(context).apply {
text = config.labels.payeeLabel
textSize = 10f
paint.isFakeBoldText = true
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(1f))
}
container.addView(merchantLabel)
val merchantInput = EditText(context).apply {
setText(merchant)
textSize = 12f
setTextColor(colorFgPrimary)
setHintTextColor(colorFgSecondary)
background = GradientDrawable().apply {
setColor(colorInputBg)
cornerRadius = dp(10f).toFloat()
setStroke(dp(1f), colorBorder)
}
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(merchantInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
// 叙述备注编辑
val narrationLabel = TextView(context).apply {
text = config.labels.narrationLabel
textSize = 10f
paint.isFakeBoldText = true
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(1f))
}
container.addView(narrationLabel)
val narrationInput = EditText(context).apply {
hint = config.labels.narrationHint
setHintTextColor(colorFgSecondary)
textSize = 12f
setTextColor(colorFgPrimary)
background = GradientDrawable().apply {
setColor(colorInputBg)
cornerRadius = dp(10f).toFloat()
setStroke(dp(1f), colorBorder)
}
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(narrationInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
// Row 1 分类/转入选择
categoryLabel = TextView(context).apply {
text = config.labels.categoryExpense
textSize = 10f
paint.isFakeBoldText = true
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(2f))
}
container.addView(categoryLabel)
categoryContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
}
val categoryScroll = HorizontalScrollView(context).apply {
isHorizontalScrollBarEnabled = false
addView(categoryContainer)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(categoryScroll)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
// Row 2 资金出入账户选择
accountLabel = TextView(context).apply {
text = config.labels.accountExpense
textSize = 10f
paint.isFakeBoldText = true
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(2f))
}
container.addView(accountLabel)
accountContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
}
val accountScroll = HorizontalScrollView(context).apply {
isHorizontalScrollBarEnabled = false
addView(accountContainer)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(accountScroll)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(10f)) })
// 底部操作栏
val btnContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
// 1) 打开应用按钮
val openAppBtn = Button(context).apply {
text = config.labels.openApp
setTextColor(colorFgPrimary)
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener {
val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim()
val newNarration = narrationInput.text.toString().trim()
sendOpenAppEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
dismiss()
SelectToSpeakService.instance?.bringAppToForeground()
}
}
// 2) 忽略按钮
val cancelBtn = Button(context).apply {
text = config.labels.dismiss
setTextColor(colorFgPrimary)
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener {
sendCancelEvent()
dismiss()
}
}
// 3) 确认入账按钮
saveBtn = Button(context).apply {
text = config.labels.confirm
setTextColor(colorAccentFg)
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorAccent)
}
textSize = 11f
paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1.3f)
setOnClickListener {
val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim()
val newNarration = narrationInput.text.toString().trim()
sendSaveEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
dismiss()
}
}
btnContainer.addView(openAppBtn)
btnContainer.addView(cancelBtn)
btnContainer.addView(saveBtn)
container.addView(btnContainer)
// 金额安全校验(BigDecimal + 正则)
val amountWatcher = object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: android.text.Editable?) {
try {
val text = s?.toString()?.trim() ?: ""
val pattern = Regex("^\\d+(\\.\\d{1,2})?$")
val value = text.toBigDecimalOrNull()
val isValid = pattern.matches(text) && value != null && value > BigDecimal.ZERO
saveBtn?.isEnabled = isValid
saveBtn?.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(if (isValid) colorAccent else colorInputBg)
}
saveBtn?.setTextColor(if (isValid) colorAccentFg else colorFgSecondary)
} catch (e: Exception) {
Log.e(TAG, "金额校验异常", e)
saveBtn?.isEnabled = false
}
}
}
amountInput.addTextChangedListener(amountWatcher)
// 填充金额触发 Watcher 校验
amountInput.setText(amount)
// 动态初始构建两行胶囊
rebuildChips()
view = container
windowManager.addView(view, windowParams)
Log.i(TAG, "悬浮修改记账面板已显示: $currentCurrency $amount")
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
handler.removeCallbacksAndMessages(null)
Log.d(TAG, "已获得输入焦点,取消自动消失定时器")
}
}
amountInput.onFocusChangeListener = cancelTimerListener
merchantInput.onFocusChangeListener = cancelTimerListener
narrationInput.onFocusChangeListener = cancelTimerListener
container.setOnTouchListener { _, _ ->
handler.removeCallbacksAndMessages(null)
Log.d(TAG, "已触摸卡片,取消自动消失定时器")
false
}
// 30 秒无操作自动消失(如果用户没有交互的话)
handler.postDelayed({
sendCancelEvent()
dismiss()
}, 30000L)
} catch (e: Exception) {
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
}
}
/** 动态根据交易方向重绘第一行与第二行滑动的胶囊列表 */
private fun rebuildChips() {
categoryContainer?.removeAllViews()
accountContainer?.removeAllViews()
val categoryChips = mutableListOf<Pair<String, TextView>>()
val accountChips = mutableListOf<Pair<String, TextView>>()
// === 1. 绘制第一行 (分类 / 转入) ===
if (currentDirection == "expense") {
categoryLabel?.text = config.labels.categoryExpense
val filteredCats = categories.filter { it["type"] == "expense" }
for (cat in filteredCats) {
val catAccount = cat["account"] ?: ""
val catName = cat["name"] ?: ""
val chip = createChipView(catName)
fun updateStyle(selected: String) {
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
chip.setOnClickListener {
selectedCategoryAccount = catAccount
updateStyle(catAccount)
}
categoryContainer?.addView(chip)
categoryChips.add(catAccount to chip)
}
selectedCategoryAccount = filteredCats.firstOrNull()?.get("account") ?: ""
categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
} else if (currentDirection == "income") {
categoryLabel?.text = config.labels.categoryIncome
val filteredCats = categories.filter { it["type"] == "income" }
for (cat in filteredCats) {
val catAccount = cat["account"] ?: ""
val catName = cat["name"] ?: ""
val chip = createChipView(catName)
fun updateStyle(selected: String) {
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
chip.setOnClickListener {
selectedCategoryAccount = catAccount
updateStyle(catAccount)
}
categoryContainer?.addView(chip)
categoryChips.add(catAccount to chip)
}
selectedCategoryAccount = filteredCats.firstOrNull()?.get("account") ?: ""
categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
} else {
// transfer — 第一行 = 转入账户,选中色 = transfer
categoryLabel?.text = config.labels.transferTarget
for (acct in accounts) {
val shortName = acct.split(":").lastOrNull() ?: acct
val chip = createChipView(shortName)
fun updateStyle(selected: String) {
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorTransfer)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
chip.setOnClickListener {
selectedCategoryAccount = acct
updateStyle(acct)
}
categoryContainer?.addView(chip)
categoryChips.add(acct to chip)
}
selectedCategoryAccount = if (accounts.size > 1 && accounts[0] == selectedSourceAccount) accounts[1] else (accounts.firstOrNull() ?: "")
categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorTransfer)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
// === 2. 绘制第二行 (资金账户) ===
if (currentDirection == "expense") {
accountLabel?.text = config.labels.accountExpense
} else if (currentDirection == "income") {
accountLabel?.text = config.labels.accountIncome
} else {
accountLabel?.text = config.labels.accountTransfer
}
// 账户行选中色按方向:expense→expense色,income→income色,transfer→expense色
val accountSelectedColor = if (currentDirection == "income") colorIncome else colorExpense
for (acct in accounts) {
val shortName = acct.split(":").lastOrNull() ?: acct
val chip = createChipView(shortName)
fun updateStyle(selected: String) {
for (pair in accountChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) {
setColor(accountSelectedColor)
} else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
chip.setOnClickListener {
selectedSourceAccount = acct
updateStyle(acct)
// 若是转账,且转入转出账户冲突,自动移开转入账户
if (currentDirection == "transfer" && selectedCategoryAccount == selectedSourceAccount) {
val nextAvail = categoryChips.find { it.first != selectedSourceAccount }
if (nextAvail != null) {
selectedCategoryAccount = nextAvail.first
for (p in categoryChips) {
val act = p.first == selectedCategoryAccount
p.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (act) setColor(colorTransfer)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
p.second.setTextColor(if (act) colorAccentFg else colorFgSecondary)
}
}
}
}
accountContainer?.addView(chip)
accountChips.add(acct to chip)
}
selectedSourceAccount = accounts.firstOrNull() ?: ""
accountChips.forEach { pair ->
val active = pair.first == selectedSourceAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) {
setColor(accountSelectedColor)
} else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
/** 推送保存事件到 JS 层。 */
private fun sendSaveEvent(newAmount: String, newPayee: String, newNarration: String, categoryAccount: String, sourceAccount: String) {
val reactContext = ReactContextHolder.context ?: return
try {
val map = WritableNativeMap().apply {
putString("draftId", draftId)
putString("amount", newAmount)
putString("merchant", newPayee)
putString("narration", newNarration)
putString("category", categoryAccount)
putString("account", sourceAccount)
putString("time", time)
putString("direction", currentDirection)
putString("packageName", packageName)
putString("currency", currentCurrency)
putBoolean("confirmed", true)
putBoolean("editRequested", false)
putBoolean("isManualEdit", true)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingConfirmed", map)
} catch (e: Exception) {
Log.e(TAG, "推送修改保存事件失败: ${e.message}")
}
}
/** 推送打开应用事件到 JS 层,以便前台加载详细表单。 */
private fun sendOpenAppEvent(newAmount: String, newPayee: String, newNarration: String, categoryAccount: String, sourceAccount: String) {
val reactContext = ReactContextHolder.context ?: return
try {
val map = WritableNativeMap().apply {
putString("draftId", draftId)
putString("amount", newAmount)
putString("merchant", newPayee)
putString("narration", newNarration)
putString("category", categoryAccount)
putString("account", sourceAccount)
putString("time", time)
putString("direction", currentDirection)
putString("packageName", packageName)
putString("currency", currentCurrency)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingOpenApp", map)
} catch (e: Exception) {
Log.e(TAG, "推送打开应用事件失败: ${e.message}")
}
}
/** 推送取消/忽略事件到 JS 层。 */
private fun sendCancelEvent() {
val reactContext = ReactContextHolder.context ?: return
try {
val map = WritableNativeMap().apply {
putString("draftId", draftId)
putBoolean("confirmed", false)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingConfirmed", map)
} catch (e: Exception) {
Log.e(TAG, "推送取消事件失败: ${e.message}")
}
}
/** 关闭浮窗。 */
fun dismiss() {
handler.removeCallbacksAndMessages(null)
try {
view?.let { windowManager.removeView(it) }
} catch (_: Exception) {}
view = null
}
/** Kotlin 中缺失的 toBigDecimalOrNull 扩展。 */
private fun String.toBigDecimalOrNull(): BigDecimal? {
return try {
BigDecimal(this)
} catch (_: Exception) {
null
}
}
}
@@ -0,0 +1,468 @@
package com.beancount.mobile.accessibility
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
/**
* 记账助手悬浮球边缘竖线胶囊面板
* 贴合在屏幕边缘采用高透超轻量竖线指示器点击后展开垂直对齐的功能菜单
*/
class FloatingHelper(
private val service: SelectToSpeakService
) {
companion object {
private const val TAG = "FloatingHelper"
private const val PREFS_NAME = "billing_accessibility_prefs"
private const val KEY_X = "floating_ball_x"
private const val KEY_Y = "floating_ball_y"
private var lastX = 0
private var lastY = 400
}
private val windowManager = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private var containerView: LinearLayout? = null
private var bubbleView: View? = null
private var menuView: LinearLayout? = null
private var isExpanded = false
private val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.START
x = lastX
y = lastY
}
@SuppressLint("ClickableViewAccessibility")
fun show() {
if (containerView != null) return
try {
val context = service
// 加载浮层 UI 配置(颜色与文案均从 JS 下发的 config 读取,未下发时回退默认值)
val config = FloatingUiConfigStore.load(service)
// 预解析颜色
val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
// 位置持久化:从 SharedPreferences 读取,回退到 companion 缓存
val prefs = service.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
params.x = prefs.getInt(KEY_X, lastX)
params.y = prefs.getInt(KEY_Y, lastY)
lastX = params.x
lastY = params.y
val density = service.resources.displayMetrics.density
fun dp(value: Float) = (value * density).toInt()
// 1. 创建整体包裹容器 (水平排列,当贴在左侧时,菜单向右展开)
containerView = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_OUTSIDE) {
if (isExpanded) {
collapse()
}
}
false
}
}
// 2. 创建高透明度悬浮球/边缘竖线 (仅 3dp 宽的极简胶囊,搭配 24dp 的宽触控区)
val bubbleLayout = FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f))
}
// 边缘指示竖线:accent 色取 RGB + 固定 0xB0 alpha(保持 70% 透明)
val indicatorColor = (colorAccent and 0x00FFFFFF) or 0xB0000000.toInt()
val indicatorView = View(context).apply {
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(1.5f).toFloat()
setColor(indicatorColor)
}
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
gravity = Gravity.CENTER_VERTICAL or Gravity.START
}
}
bubbleLayout.addView(indicatorView)
bubbleView = bubbleLayout
// 3. 创建展开菜单 (垂直布局,高紧凑度设计)
// 菜单背景:实色 cardBgper plan §T4
val menuBgColor = colorCardBg
// 菜单描边:border 色 + 半透明 alpha
val menuBorderColor = (colorBorder and 0x00FFFFFF) or 0x22000000.toInt()
val menuBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(10f).toFloat()
setColor(menuBgColor)
setStroke(dp(0.8f), menuBorderColor)
}
menuView = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
background = menuBg
setPadding(dp(4f), dp(4f), dp(4f), dp(4f))
visibility = View.GONE
layoutParams = LinearLayout.LayoutParams(
dp(96f),
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = dp(4f)
}
}
// Vector icons
val sizePx = dp(14f)
val strokePx = dp(1.4f).toFloat()
// ScanIconDrawableaccentFg 色(在 accent 底按钮上可见),laserColor 保留红色语义
val ocrIcon = ScanIconDrawable(colorAccentFg, strokePx, 0xFFF87171.toInt(), sizePx)
// PinIconDrawablefgSecondary 色
val pinIcon = PinIconDrawable(colorFgSecondary, strokePx, sizePx)
// Button 1: 识别账单(主操作:accent 底 accentFg 字)
// 背景常时:accent 色取 RGB + 固定 0x1F alpha(约 12%
val btnOcrBgNormal = (colorAccent and 0x00FFFFFF) or 0x1F000000.toInt()
// 按下加深:accent 色取 RGB + 固定 0x40 alpha(约 25%
val btnOcrBgPressed = (colorAccent and 0x00FFFFFF) or 0x40000000.toInt()
val btnOcr = TextView(context).apply {
text = config.labels.ballOcr
setTextColor(colorAccentFg)
textSize = 11f
paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(btnOcrBgNormal)
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dp(32f)
).apply {
bottomMargin = dp(5f)
}
setCompoundDrawablesWithIntrinsicBounds(ocrIcon, null, null, null)
compoundDrawablePadding = dp(4f)
setOnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(btnOcrBgPressed)
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(btnOcrBgNormal)
}
if (event.action == MotionEvent.ACTION_UP) {
view.performClick()
}
}
}
true
}
setOnClickListener {
service.triggerManualExtraction()
}
}
// Button 2: 记住此页(次要操作:inputBg 底 fgPrimary 字,per plan §T4
// 背景常时:inputBg 色取 RGB + 固定 0x15 alpha
val btnRememberBgNormal = (colorInputBg and 0x00FFFFFF) or 0x15000000.toInt()
// 按下加深:inputBg 色取 RGB + 固定 0x30 alpha
val btnRememberBgPressed = (colorInputBg and 0x00FFFFFF) or 0x30000000.toInt()
val btnRemember = TextView(context).apply {
text = config.labels.ballRemember
setTextColor(colorFgPrimary)
textSize = 11f
paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(btnRememberBgNormal)
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dp(32f)
)
setCompoundDrawablesWithIntrinsicBounds(pinIcon, null, null, null)
compoundDrawablePadding = dp(4f)
setOnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(btnRememberBgPressed)
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(btnRememberBgNormal)
}
if (event.action == MotionEvent.ACTION_UP) {
view.performClick()
}
}
}
true
}
setOnClickListener {
try {
service.rememberCurrentPage()
FloatingTip(service, config.labels.rememberSuccess, FloatingTip.TipPosition.TOP, 2500L).show()
} catch (e: Exception) {
FloatingTip(service, "${config.labels.rememberFail}: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show()
}
collapse()
}
}
menuView?.addView(btnOcr)
menuView?.addView(btnRemember)
containerView?.addView(bubbleView)
containerView?.addView(menuView)
// 4. 设置悬浮条拖动事件 (点击即展开,拖动则调整位置)
var initialX = 0
var initialY = 0
var initialTouchX = 0f
var initialTouchY = 0f
var isMoving = false
bubbleView?.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
initialX = params.x
initialY = params.y
initialTouchX = event.rawX
initialTouchY = event.rawY
isMoving = false
true
}
MotionEvent.ACTION_MOVE -> {
val dx = (event.rawX - initialTouchX).toInt()
val dy = (event.rawY - initialTouchY).toInt()
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
isMoving = true
}
params.x = initialX + dx
params.y = initialY + dy
containerView?.let { windowManager.updateViewLayout(it, params) }
lastX = params.x
lastY = params.y
true
}
MotionEvent.ACTION_UP -> {
if (!isMoving) {
toggleMenu()
} else {
// 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘)
if (params.x < service.resources.displayMetrics.widthPixels / 2) {
params.x = -dp(1.5f)
} else {
params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f)
}
containerView?.let { windowManager.updateViewLayout(it, params) }
lastX = params.x
lastY = params.y
// 位置持久化:吸附后写入 SharedPreferences
prefs.edit()
.putInt(KEY_X, params.x)
.putInt(KEY_Y, params.y)
.apply()
}
true
}
else -> false
}
}
windowManager.addView(containerView, params)
Log.i(TAG, "记账助手悬浮窗显示成功")
} catch (e: Exception) {
Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e)
// 修复:失败时清理状态,允许下次重试
containerView = null
bubbleView = null
menuView = null
}
}
private fun toggleMenu() {
if (isExpanded) {
collapse()
} else {
expand()
}
}
private fun expand() {
bubbleView?.visibility = View.GONE
menuView?.visibility = View.VISIBLE
isExpanded = true
}
fun collapse() {
menuView?.visibility = View.GONE
bubbleView?.visibility = View.VISIBLE
isExpanded = false
}
fun hideTemporarily() {
containerView?.visibility = View.GONE
}
fun showTemporarily() {
containerView?.visibility = View.VISIBLE
}
fun dismiss() {
try {
containerView?.let { windowManager.removeView(it) }
} catch (_: Exception) {}
containerView = null
bubbleView = null
menuView = null
isExpanded = false
}
}
/**
* 扫码/OCR 矢量图标 drawable
*/
class ScanIconDrawable(
private val color: Int,
private val strokeWidthPx: Float,
private val laserColor: Int,
private val sizePx: Int
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = strokeWidthPx
strokeCap = Paint.Cap.ROUND
}
override fun draw(canvas: Canvas) {
val w = bounds.width().toFloat()
val h = bounds.height().toFloat()
paint.color = color
paint.style = Paint.Style.STROKE
// 绘制 4 个角的扫描框
val len = w * 0.25f
val pad = strokeWidthPx
// 左上
canvas.drawLine(pad, pad, pad + len, pad, paint)
canvas.drawLine(pad, pad, pad, pad + len, paint)
// 右上
canvas.drawLine(w - pad, pad, w - pad - len, pad, paint)
canvas.drawLine(w - pad, pad, w - pad, pad + len, paint)
// 左下
canvas.drawLine(pad, h - pad, pad + len, h - pad, paint)
canvas.drawLine(pad, h - pad, pad, h - pad - len, paint)
// 右下
canvas.drawLine(w - pad, h - pad, w - pad - len, h - pad, paint)
canvas.drawLine(w - pad, h - pad, w - pad, h - pad - len, paint)
// 绘制扫描红线 (激光)
paint.style = Paint.Style.FILL
paint.color = laserColor
val laserY = h / 2f
canvas.drawRect(pad * 2f, laserY - strokeWidthPx / 2f, w - pad * 2f, laserY + strokeWidthPx / 2f, paint)
}
override fun getIntrinsicWidth() = sizePx
override fun getIntrinsicHeight() = sizePx
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
override fun getOpacity() = PixelFormat.TRANSLUCENT
}
/**
* 图钉/记住当前页 矢量图标 drawable
*/
class PinIconDrawable(
private val color: Int,
private val strokeWidthPx: Float,
private val sizePx: Int
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = strokeWidthPx
strokeCap = Paint.Cap.ROUND
}
override fun draw(canvas: Canvas) {
val w = bounds.width().toFloat()
val h = bounds.height().toFloat()
paint.color = color
val cx = w / 2f
// 图钉头部 (帽)
paint.style = Paint.Style.FILL
val hatW = w * 0.35f
val hatH = h * 0.12f
canvas.drawRoundRect(RectF(cx - hatW, hatH, cx + hatW, hatH * 2.2f), strokeWidthPx, strokeWidthPx, paint)
// 图钉身体 (中)
val bodyW = w * 0.22f
canvas.drawRect(cx - bodyW, hatH * 2.2f, cx + bodyW, h * 0.58f, paint)
// 针尖 (底)
paint.style = Paint.Style.STROKE
canvas.drawLine(cx, h * 0.58f, cx, h - strokeWidthPx, paint)
}
override fun getIntrinsicWidth() = sizePx
override fun getIntrinsicHeight() = sizePx
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
override fun getOpacity() = PixelFormat.TRANSLUCENT
}
@@ -0,0 +1,106 @@
package com.beancount.mobile.accessibility
import android.content.Context
import android.graphics.PixelFormat
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.TextView
/**
* 浮窗提示plan.md3.8 浮窗账单提示
*
* 参考 AutoAccounting FloatingTip + RepeatToast
* - 轻量浮窗非全屏滑入动画自动消失
* - 三种布局顶部 / 左侧 / 右侧
* - 倒计时进度环
* - 重复账单提示RepeatToast
*
* FloatingBillView 更轻仅展示提示不交互
* SYSTEM_ALERT_WINDOW 权限 Config Plugin 注册
*/
class FloatingTip(
private val context: Context,
private val message: String,
private val position: TipPosition = TipPosition.TOP,
private val durationMs: Long = 3000L,
) {
companion object {
private const val TAG = "FloatingTip"
private const val ANIM_DURATION = 300L
}
enum class TipPosition { TOP, LEFT, RIGHT }
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private var view: View? = null
private val handler = Handler(Looper.getMainLooper())
/** 显示浮窗提示。 */
fun show() {
try {
val config = FloatingUiConfigStore.load(context)
val bgColor = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF000000") or 0xFF000000.toInt() // 强制不透明化
val textColor = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT,
).apply {
gravity = when (position) {
TipPosition.TOP -> Gravity.TOP or Gravity.CENTER_HORIZONTAL
TipPosition.LEFT -> Gravity.LEFT or Gravity.CENTER_VERTICAL
TipPosition.RIGHT -> Gravity.RIGHT or Gravity.CENTER_VERTICAL
}
y = if (position == TipPosition.TOP) 100 else 0
x = if (position != TipPosition.TOP) 50 else 0
}
val container = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
setBackgroundColor(bgColor)
setPadding(32, 16, 32, 16)
}
val text = TextView(context).apply {
text = message
textSize = 13f
setTextColor(textColor)
}
container.addView(text)
view = container
windowManager.addView(view, layoutParams)
Log.d(TAG, "FloatingTip 显示: $message")
handler.postDelayed({ dismiss() }, durationMs)
} catch (e: Exception) {
Log.e(TAG, "FloatingTip 显示失败: ${e.message}")
}
}
/** 关闭浮窗。 */
fun dismiss() {
handler.removeCallbacksAndMessages(null)
try { view?.let { windowManager.removeView(it) } } catch (_: Exception) {}
view = null
}
}
/**
* 重复账单提示plan.md3.8+ AutoAccounting RepeatToast
* 当检测到重复账单时轻量提示用户不弹浮窗用系统 Toast 风格
*/
class RepeatToast(private val context: Context, private val message: String) {
fun show() {
val tip = FloatingTip(context, message, FloatingTip.TipPosition.TOP, 2000L)
tip.show()
Log.d("RepeatToast", "重复提示: $message")
}
}
@@ -0,0 +1,211 @@
package com.beancount.mobile.accessibility
import android.content.Context
import android.graphics.Color
import android.util.Log
import com.facebook.react.bridge.ReadableMap
import org.json.JSONObject
/**
* 浮层 UI 颜色契约docs/ui-redesign-p6-plan.mdFloatingUiConfig 契约
* 字段名camelCase src/services/floatingUiConfig.ts FloatingUiConfig.colors 一一对应
* 默认值 = 现状硬编码颜色JS 未推送时行为不变注释标明出处
*/
data class FloatingUiConfigColors(
/** 按钮/选中态背景。 */ val accent: String = "#FF5E6AD2", // 原 FloatingBillView saveBg / 分段选中态
/** accent 上的文字色。 */ val accentFg: String = "#FFFFFFFF", // 原 FloatingBillView 按钮文字色
/** 卡片底色(原 0x8C050506 半透明,默认实色化到 F0 保证可读)。 */
val cardBg: String = "#F0050506", // 原 FloatingBillView containerBg 0x8C050506
/** 输入框/未选中 chip 底色。 */ val inputBg: String = "#4012131A", // 原 FloatingBillView 输入框底 0x4012131A
val fgPrimary: String = "#FFFFFFFF", // 原 FloatingBillView 输入框文字色
val fgSecondary: String = "#FF9CA3AF", // 原 FloatingBillView 未选中 tab/chip 文字色
val border: String = "#80222433", // 原 FloatingBillView 输入框/chip 描边 0x80222433
/** financial.income。 */ val income: String = "#FF10B981", // 原 FloatingBillView 收入/转账 chip 选中色
/** financial.expense。 */ val expense: String = "#FFE11D48", // 原 FloatingBillView 支出账户 chip 选中色
/** financial.transfer。 */ val transfer: String = "#FF10B981", // 原 FloatingBillView 转账转入 chip 选中色
)
/**
* 浮层 UI 文案契约
* 字段名camelCase src/services/floatingUiConfig.ts FloatingUiConfig.labels 一一对应
* 默认值 = 现状中文硬编码文案 emoji
*/
data class FloatingUiConfigLabels(
/** 浮窗标题。 */ val billTitle: String = "调整交易草稿",
val dirExpense: String = "支出",
val dirIncome: String = "收入",
val dirTransfer: String = "转账",
val amountLabel: String = "金额",
val payeeLabel: String = "交易对手",
val narrationLabel: String = "描述/备注",
val narrationHint: String = "输入交易叙述",
/** 支出方向分类行标签。 */ val categoryExpense: String = "交易分类",
/** 收入方向分类行标签。 */ val categoryIncome: String = "收入分类",
/** 转账方向第一行标签。 */ val transferTarget: String = "转入账户",
/** 支出方向账户行标签。 */ val accountExpense: String = "资金来源",
/** 收入方向账户行标签。 */ val accountIncome: String = "存入账户",
/** 转账方向账户行标签。 */ val accountTransfer: String = "转出账户",
val openApp: String = "打开应用",
val dismiss: String = "忽略",
val confirm: String = "确认入账",
/** 悬浮球「识别账单」。 */ val ballOcr: String = "识别账单",
/** 悬浮球「记住此页」。 */ val ballRemember: String = "记住此页",
/** 记住页面成功提示(不含 emoji)。 */ val rememberSuccess: String = "已将当前页面加入识别白名单",
/** 失败提示前缀(原生拼接 ': ' + e.message)。 */ val rememberFail: String = "记录失败",
/** SelectToSpeakService Toast「已记住页面签名」前缀。 */ val pageRemembered: String = "已记住页面签名",
/** 「该页面签名已存在」前缀。 */ val pageSignatureExists: String = "该页面签名已存在",
)
/** 原生浮层 UI 配置(JS 经 AccessibilityBridge.setFloatingUiConfig 下发)。 */
data class FloatingUiConfig(
val colors: FloatingUiConfigColors = FloatingUiConfigColors(),
val labels: FloatingUiConfigLabels = FloatingUiConfigLabels(),
)
/**
* FloatingUiConfig SharedPreferences 持久化plan原生持久化
*
* - 文件名 floating_ui_config单键 config_json 存整个 JSON
* - save 采用合并策略 load 出现存 JSON再覆盖传入键避免部分更新丢字段
* - load 逐字段 optString 缺省回退 data class 默认值任何异常返回全默认
*/
object FloatingUiConfigStore {
private const val TAG = "FloatingUiConfigStore"
private const val PREFS = "floating_ui_config"
private const val KEY = "config_json"
/** 契约内已知 colors 键(save 时只写这些键)。 */
private val COLOR_KEYS = listOf(
"accent", "accentFg", "cardBg", "inputBg", "fgPrimary",
"fgSecondary", "border", "income", "expense", "transfer",
)
/** 契约内已知 labels 键(save 时只写这些键)。 */
private val LABEL_KEYS = listOf(
"billTitle", "dirExpense", "dirIncome", "dirTransfer", "amountLabel",
"payeeLabel", "narrationLabel", "narrationHint", "categoryExpense",
"categoryIncome", "transferTarget", "accountExpense", "accountIncome",
"accountTransfer", "openApp", "dismiss", "confirm", "ballOcr",
"ballRemember", "rememberSuccess", "rememberFail", "pageRemembered",
"pageSignatureExists",
)
/** 保存(合并):先读出现存 JSON,再覆盖传入的已知键。 */
fun save(context: Context, map: ReadableMap) {
val root = readRawJson(context)
if (map.hasKey("colors")) {
val colorsMap = map.getMap("colors")
val colorsJson = root.optJSONObject("colors") ?: JSONObject().also { root.put("colors", it) }
if (colorsMap != null) {
for (key in COLOR_KEYS) {
if (colorsMap.hasKey(key) && !colorsMap.isNull(key)) {
colorsJson.put(key, colorsMap.getString(key))
}
}
}
}
if (map.hasKey("labels")) {
val labelsMap = map.getMap("labels")
val labelsJson = root.optJSONObject("labels") ?: JSONObject().also { root.put("labels", it) }
if (labelsMap != null) {
for (key in LABEL_KEYS) {
if (labelsMap.hasKey(key) && !labelsMap.isNull(key)) {
labelsJson.put(key, labelsMap.getString(key))
}
}
}
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY, root.toString())
.apply()
}
/** 读取配置;缺失字段回退默认值,任何异常返回全默认。 */
fun load(context: Context): FloatingUiConfig {
return try {
val root = readRawJson(context)
val defaultColors = FloatingUiConfigColors()
val defaultLabels = FloatingUiConfigLabels()
val colorsJson = root.optJSONObject("colors")
val labelsJson = root.optJSONObject("labels")
val colors = FloatingUiConfigColors(
accent = colorsJson.optStringOr("accent", defaultColors.accent),
accentFg = colorsJson.optStringOr("accentFg", defaultColors.accentFg),
cardBg = colorsJson.optStringOr("cardBg", defaultColors.cardBg),
inputBg = colorsJson.optStringOr("inputBg", defaultColors.inputBg),
fgPrimary = colorsJson.optStringOr("fgPrimary", defaultColors.fgPrimary),
fgSecondary = colorsJson.optStringOr("fgSecondary", defaultColors.fgSecondary),
border = colorsJson.optStringOr("border", defaultColors.border),
income = colorsJson.optStringOr("income", defaultColors.income),
expense = colorsJson.optStringOr("expense", defaultColors.expense),
transfer = colorsJson.optStringOr("transfer", defaultColors.transfer),
)
val labels = FloatingUiConfigLabels(
billTitle = labelsJson.optStringOr("billTitle", defaultLabels.billTitle),
dirExpense = labelsJson.optStringOr("dirExpense", defaultLabels.dirExpense),
dirIncome = labelsJson.optStringOr("dirIncome", defaultLabels.dirIncome),
dirTransfer = labelsJson.optStringOr("dirTransfer", defaultLabels.dirTransfer),
amountLabel = labelsJson.optStringOr("amountLabel", defaultLabels.amountLabel),
payeeLabel = labelsJson.optStringOr("payeeLabel", defaultLabels.payeeLabel),
narrationLabel = labelsJson.optStringOr("narrationLabel", defaultLabels.narrationLabel),
narrationHint = labelsJson.optStringOr("narrationHint", defaultLabels.narrationHint),
categoryExpense = labelsJson.optStringOr("categoryExpense", defaultLabels.categoryExpense),
categoryIncome = labelsJson.optStringOr("categoryIncome", defaultLabels.categoryIncome),
transferTarget = labelsJson.optStringOr("transferTarget", defaultLabels.transferTarget),
accountExpense = labelsJson.optStringOr("accountExpense", defaultLabels.accountExpense),
accountIncome = labelsJson.optStringOr("accountIncome", defaultLabels.accountIncome),
accountTransfer = labelsJson.optStringOr("accountTransfer", defaultLabels.accountTransfer),
openApp = labelsJson.optStringOr("openApp", defaultLabels.openApp),
dismiss = labelsJson.optStringOr("dismiss", defaultLabels.dismiss),
confirm = labelsJson.optStringOr("confirm", defaultLabels.confirm),
ballOcr = labelsJson.optStringOr("ballOcr", defaultLabels.ballOcr),
ballRemember = labelsJson.optStringOr("ballRemember", defaultLabels.ballRemember),
rememberSuccess = labelsJson.optStringOr("rememberSuccess", defaultLabels.rememberSuccess),
rememberFail = labelsJson.optStringOr("rememberFail", defaultLabels.rememberFail),
pageRemembered = labelsJson.optStringOr("pageRemembered", defaultLabels.pageRemembered),
pageSignatureExists = labelsJson.optStringOr("pageSignatureExists", defaultLabels.pageSignatureExists),
)
FloatingUiConfig(colors, labels)
} catch (e: Exception) {
Log.e(TAG, "读取浮层 UI 配置失败,回退默认值: ${e.message}")
FloatingUiConfig()
}
}
/** 解析颜色字符串;失败/为空时解析 fallbackHexfallback 也不合法则返回 Color.BLACK)。 */
fun parseColorOr(value: String, fallbackHex: String): Int {
if (value.isNotBlank()) {
try {
return Color.parseColor(value)
} catch (_: Exception) {}
}
return try {
Color.parseColor(fallbackHex)
} catch (_: Exception) {
Color.BLACK
}
}
/** 读出现存 JSON;无数据或解析失败返回空 JSONObject。 */
private fun readRawJson(context: Context): JSONObject {
return try {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY, null)
if (raw.isNullOrBlank()) JSONObject() else JSONObject(raw)
} catch (_: Exception) {
JSONObject()
}
}
/** optString 包装:JSON 为 null / 键缺失 / 空串时回退 default。 */
private fun JSONObject?.optStringOr(key: String, default: String): String {
if (this == null) return default
val v = optString(key, default)
return if (v.isBlank()) default else v
}
}
@@ -0,0 +1,62 @@
package com.beancount.mobile.accessibility
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.util.Log
import android.content.Intent
import android.app.PendingIntent
/**
* 快速设置磁贴plan.md3.11 快速设置磁贴
*
* 参考 AutoAccounting OcrTileService
* - 用户下拉快速设置点击OCR 记账磁贴触发一次手动 OCR
* - Android 14+ PendingIntent + startActivityAndCollapse
*
* 触发后调用 SelectToSpeakService.triggerManualOcr()
*/
class OcrTileService : TileService() {
companion object {
private const val TAG = "OcrTileService"
}
override fun onStartListening() {
super.onStartListening()
qsTile?.let { tile ->
tile.state = Tile.STATE_ACTIVE
tile.label = "OCR 记账"
tile.updateTile()
}
Log.d(TAG, "磁贴开始监听")
}
override fun onClick() {
super.onClick()
Log.i(TAG, "磁贴被点击,触发手动 OCR")
triggerManualOcr()
}
/** 触发手动 OCR(通过 SelectToSpeakService)。 */
private fun triggerManualOcr() {
val service = SelectToSpeakService.instance
if (service != null) {
service.triggerManualOcr()
return
}
// 服务未运行,尝试启动(Android 14+ 用 collapse
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pi = PendingIntent.getActivity(
this, 0,
Intent().apply {
setClassName(packageName, "$packageName.MainActivity")
action = "$packageName.TRIGGER_OCR"
flags = Intent.FLAG_ACTIVITY_NEW_TASK
},
PendingIntent.FLAG_IMMUTABLE,
)
startActivityAndCollapse(pi)
}
}
}
@@ -0,0 +1,708 @@
package com.beancount.mobile.accessibility
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.Context
import android.graphics.Bitmap
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Display
import android.view.Surface
import android.view.accessibility.AccessibilityEvent
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.bridge.WritableNativeArray
import java.io.ByteArrayOutputStream
import android.util.Base64
import android.view.accessibility.AccessibilityNodeInfo
/**
* 无障碍账单识别服务plan.md3.6 无障碍服务+决策 4 Config Plugin
*
* 参考 AutoAccounting SelectToSpeakService
* - 监听支付 App 的页面切换TYPE_WINDOW_STATE_CHANGED
* - 页面签名匹配时自动截图 OCR 推送到 JS
* - 横屏免打扰游戏/视频时不触发
* - ocrDoing 守卫防止重复触发
*
* 伪装说明绕过微信 8.0.52+ 节点混淆
* 微信按 ComponentName包名/类名识别系统服务白名单TalkBackSelectToSpeak
* 等系统服务不受节点混淆影响第三方服务则被混淆
* 本服务的 package + class 全部伪装为
* com.google.android.accessibility.selecttospeak.SelectToSpeakService
* 使微信将其识别为系统 SelectToSpeak 服务从而拿到未混淆的真实节点文本
*
* 伪装由 Config Pluginplugins/accessibility/app.plugin.js prebuild 时完成
* - kt 源码 package rewrite com.google.android.accessibility.selecttospeak
* - Manifest android:name 写成完整全限定名
*
* 注意plan.md 决策 3纯开源侧载保留无障碍伪装非应用商店分发
*
* 通过 DeviceEventEmitter 把识别事件推送到 JS automationStore
*
* src/domain/constants.ts 同步PAYMENT_PACKAGES
* 修改时需两边同时更新
*/
class SelectToSpeakService : AccessibilityService() {
companion object {
private const val TAG = "BillingAccessibility"
private const val PREFS_NAME = "billing_accessibility_prefs"
private const val PREF_PAGE_SIGNATURES = "page_signatures"
@Volatile
var instance: SelectToSpeakService? = null
private set
/** 悬浮球全局开关(由 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", // 微信
"com.unionpay", // 银联
"com.cmbchina", // 招商银行
"com.icbc", // 工商银行
"com.chinamworld.main", // 中国银行
"com.ccbrcb", // 建设银行
"com.bankcomm.Bankcomm", // 交通银行
"com.tencent.mobileqq", // 手机QQ
"com.tencent.tim" // TIM
)
/** 厂商桌面包名(过滤,不触发 OCR)。 */
private val LAUNCHER_PACKAGES = setOf(
"com.google.android.apps.nexuslauncher",
"com.sec.android.app.launcher",
"com.miui.home",
"com.huawei.android.launcher",
"com.oppo.launcher",
"com.bbk.launcher2",
"com.android.launcher3",
)
/** OCR 触发防抖:500ms 内的内容变化合并为一次。 */
private const val CONTENT_CHANGE_DEBOUNCE_MS = 500L
}
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
@Volatile private var topActivity: String? = null
/** 已记住的页面签名(pkg|activity),匹配时自动触发 OCR。持久化到 SharedPreferences。 */
private val pageSignatures = java.util.concurrent.CopyOnWriteArraySet<String>()
private var floatingHelper: FloatingHelper? = null
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
Log.i(TAG, "无障碍账单识别服务已连接")
loadPageSignatures()
configureService()
// 修复:检查当前前台 App,恢复悬浮球
val root = rootInActiveWindow
val pkg = root?.packageName?.toString()
root?.recycle()
if (pkg != null) {
topPackage = pkg
updateFloatingHelperVisibility(pkg)
}
}
/** 动态配置服务能力(截图 + 页面变化监听)。 */
private fun configureService() {
val info = AccessibilityServiceInfo().apply {
eventTypes = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED or
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED
feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC
flags = AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY or
AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or
AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS or
AccessibilityServiceInfo.DEFAULT
notificationTimeout = 100L
}
serviceInfo = info
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// 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) {
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
val activityName = event.className?.toString() ?: ""
if (filterPackage(eventPackage, activityName)) return
topPackage = eventPackage
topActivity = activityName
Log.d(TAG, "页面切换: $eventPackage / $activityName")
// 更新悬浮窗助手状态
updateFloatingHelperVisibility(eventPackage)
// 页面切换时检查是否有已记住的签名需要触发文本提取
scheduleContentChange()
// 注意:自动监听调试日志已移除(微信 8.0.52+ 混淆节点文本,自动监听无意义)。
// 账单识别仅在用户点击悬浮球时触发(isManual=true),此时提取完整节点文本。
}
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
// 内容变化时也检查(防抖合并)
scheduleContentChange()
}
}
}
private fun updateFloatingHelperVisibility(pkg: String?) {
handler.post {
val shouldShow = floatingBallEnabled
&& pkg != null
&& !filterPackage(pkg, topActivity ?: "")
&& !isLandscape()
if (shouldShow) {
if (floatingHelper == null) {
floatingHelper = FloatingHelper(this)
floatingHelper?.show()
}
} else {
floatingHelper?.dismiss()
floatingHelper = null
}
}
}
/** 防抖:500ms 内的多次内容变化合并。 */
private fun scheduleContentChange() {
handler.removeCallbacks(debounceRunnable)
handler.postDelayed(debounceRunnable, CONTENT_CHANGE_DEBOUNCE_MS)
}
/** 内容变化处理:检查页面签名 → 提取文本 → 发送给 JS(JS 控制是否 OCR 兜底)。 */
private fun processContentChange() {
if (ocrDoing) return
val pkg = topPackage ?: return
// 横屏免打扰(plan.md「3.10」)
if (isLandscape()) {
Log.d(TAG, "横屏免打扰,跳过")
return
}
// 页面签名匹配(若已记住页面则触发)
val activity = topActivity ?: ""
val sigKey = "$pkg|$activity"
if (!pageSignatures.contains(sigKey)) {
return // 未记住的页面不自动触发
}
// 抓取并提取屏幕所有无障碍文本(含 WebView 子窗口),并推送至 JS 侧
val texts = dumpAllTexts()
val reactContext = ReactContextHolder.context
if (reactContext != null) {
try {
val map = WritableNativeMap().apply {
putString("package", pkg)
putString("activity", activity)
putString("signature", sigKey)
val array = WritableNativeArray()
for (t in texts) {
array.pushString(t)
}
putArray("texts", array)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingDebugNodes", map)
} catch (e: Exception) {
Log.e(TAG, "推送内容变化节点文本失败: ${e.message}")
}
}
}
/** 截图并触发 OCR 处理(Android 11+)。 */
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
}
try {
takeScreenshot(
Display.DEFAULT_DISPLAY,
mainExecutor,
object : TakeScreenshotCallback {
override fun onSuccess(result: ScreenshotResult) {
try {
val bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
result.hardwareBuffer.close()
if (bitmap != null) {
val base64 = bitmapToBase64(bitmap, packageName)
bitmap.recycle()
// 推送到 JS 层(NativeEventEmitter
sendScreenshotEvent(base64, packageName)
}
} finally {
ocrDoing = false
}
}
override fun onFailure(errorCode: Int) {
Log.e(TAG, "截图失败: errorCode=$errorCode")
ocrDoing = false
}
}
)
} catch (e: Throwable) {
Log.e(TAG, "调用 takeScreenshot 失败: ${e.message}", e)
ocrDoing = false
}
}
/** 把截图以 base64 推送到 JS 层(由 JS 端 OcrProcessor 处理)。 */
private fun sendScreenshotEvent(base64: String, packageName: String) {
val reactContext = ReactContextHolder.context ?: return
try {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingScreenshot", writableMapOf(
"base64" to base64,
"packageName" to packageName,
"timestamp" to System.currentTimeMillis()
))
} catch (e: Exception) {
Log.e(TAG, "推送截图事件失败: ${e.message}")
}
}
/** 手动触发一次 OCR(临时隐藏悬浮窗避开遮挡,并在 150ms 后触发截图)。 */
fun triggerManualOcr() {
val pkg = topPackage ?: return
handler.post {
floatingHelper?.collapse()
floatingHelper?.hideTemporarily()
}
// 150ms 后触发截图(此时悬浮窗已瞬间隐藏,避免遮挡和误匹配)
handler.postDelayed({
try {
takeScreenshotAndProcess(pkg)
} catch (e: Exception) {
Log.e(TAG, "手动触发 OCR 失败: ${e.message}")
} finally {
// 截图完成,瞬间恢复显示悬浮球
handler.post {
floatingHelper?.showTemporarily()
}
}
}, 150)
}
/**
* 手动触发一次节点文本提取并发送到 JS从而让 JS 优先尝试直接文本解析
*
* 使用 dumpAllTexts() 覆盖所有窗口 WebView 子窗口而非仅 rootInActiveWindow
* 伪装生效后这条路径会成为微信账单页的主识别路径必须保证完整性
*/
fun triggerManualExtraction() {
handler.post {
floatingHelper?.collapse()
}
val pkg = topPackage ?: return
val activity = topActivity ?: ""
val sigKey = "$pkg|$activity"
val texts = dumpAllTexts()
val reactContext = ReactContextHolder.context
if (reactContext != null) {
try {
val map = WritableNativeMap().apply {
putString("package", pkg)
putString("activity", activity)
putString("signature", sigKey)
putBoolean("isManual", true)
val array = WritableNativeArray()
for (t in texts) {
array.pushString(t)
}
putArray("texts", array)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingDebugNodes", map)
} catch (e: Exception) {
Log.e(TAG, "手动触发节点文本提取失败: ${e.message}")
}
}
}
/** 记住当前页面(用户主动标记应触发 OCR 的页面)。持久化到 SharedPreferences。 */
fun rememberCurrentPage() {
val pkg = topPackage ?: return
val activity = topActivity ?: ""
val sig = "$pkg|$activity"
val uiLabels = FloatingUiConfigStore.load(this).labels
if (pageSignatures.add(sig)) {
savePageSignatures()
Log.i(TAG, "已记住页面: $sig")
handler.post {
android.widget.Toast.makeText(this, "${uiLabels.pageRemembered}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
}
// 抓取并提取屏幕所有无障碍文本
val texts = mutableListOf<String>()
val rootNode = rootInActiveWindow
dumpNodeTexts(rootNode, texts)
rootNode?.recycle()
// 推送事件到 JS 端,使 npx expo start 终端控制台可以接收并打印日志
val reactContext = ReactContextHolder.context
if (reactContext != null) {
try {
val map = WritableNativeMap().apply {
putString("package", pkg)
putString("activity", activity)
putString("signature", sig)
val array = WritableNativeArray()
for (t in texts) {
array.pushString(t)
}
putArray("texts", array)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingPageRemembered", map)
} catch (e: Exception) {
Log.e(TAG, "推送记住页面事件失败: ${e.message}")
}
}
} else {
handler.post {
android.widget.Toast.makeText(this, "${uiLabels.pageSignatureExists}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
}
}
}
/** 获取已记住的页面签名列表(供 JS 端展示)。 */
fun getPageSignatures(): Set<String> {
return pageSignatures.toSet()
}
/** 清空所有已记住的页面签名。 */
fun clearPageSignatures() {
pageSignatures.clear()
try {
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().remove(PREF_PAGE_SIGNATURES).apply()
Log.i(TAG, "已清空全部记住的页面并从 SharedPreferences 中移除键值")
} catch (e: Exception) {
Log.e(TAG, "清空 SharedPreferences 失败: ${e.message}")
}
}
/** 删除指定页面签名。 */
fun removePageSignature(sig: String) {
if (pageSignatures.remove(sig)) {
savePageSignatures()
Log.i(TAG, "已删除页面签名: $sig")
}
}
/** 获取当前顶部包名(供 JS 判断当前页面)。 */
fun getTopPackage(): String? = topPackage
/** 获取当前顶部 Activity。 */
fun getTopActivity(): String? = topActivity
/** 从 SharedPreferences 加载已记住的页面签名。 */
private fun loadPageSignatures() {
try {
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val saved = prefs.getStringSet(PREF_PAGE_SIGNATURES, emptySet()) ?: emptySet()
pageSignatures.clear()
pageSignatures.addAll(saved)
Log.i(TAG, "已加载 ${pageSignatures.size} 个记住的页面签名")
} catch (e: Exception) {
Log.e(TAG, "加载页面签名失败: ${e.message}")
}
}
/** 保存页面签名到 SharedPreferences。 */
private fun savePageSignatures() {
try {
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
if (pageSignatures.isEmpty()) {
prefs.edit().remove(PREF_PAGE_SIGNATURES).apply()
} else {
prefs.edit().putStringSet(PREF_PAGE_SIGNATURES, HashSet(pageSignatures)).apply()
}
} catch (e: Exception) {
Log.e(TAG, "保存页面签名失败: ${e.message}")
}
}
/** 关闭悬浮球(由 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)
?: return false
val rotation = dm.rotation
return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270
}
/** 过滤不应处理的系统组件(桌面启动器、SystemUI、输入法等)。 */
private fun filterPackage(pkg: String, className: String): Boolean {
val p = pkg.lowercase()
// 自身 App:只放行 MainActivity,避免悬浮球容器触发自毁式关闭
if (pkg == packageName) {
if (className.isEmpty()) return false
return !className.endsWith(".MainActivity")
}
// 桌面启动器(悬浮球在桌面无意义)
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
}
/** Bitmap → base64JPEG 质量 60,参考 AutoAccounting bitmapToBase64)。 */
private fun bitmapToBase64(bitmap: Bitmap, packageName: String = ""): String {
// 安全起见,如果 bitmap 是 HARDWARE 格式,将其复制为 ARGB_8888 软件格式
val softwareBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.config == Bitmap.Config.HARDWARE) {
bitmap.copy(Bitmap.Config.ARGB_8888, false)
} else {
bitmap
}
// 诊断:分析截图有效性(区分「截到空白帧」与「有内容但 OCR 失败」)
analyzeScreenshot(softwareBitmap, packageName)
val baos = ByteArrayOutputStream()
softwareBitmap.compress(Bitmap.CompressFormat.JPEG, 60, baos)
if (softwareBitmap !== bitmap) {
softwareBitmap.recycle()
}
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
}
/**
* 截图有效性诊断纯日志不影响主流程
*
* 通过网格采样统计像素输出
* - 尺寸采样数
* - 是否纯色所有采样像素颜色完全一致 疑似空白/纯色帧
* - 亮度均值与方差方差0 无内容方差高 有内容
* - 深色/浅色像素占比
*
* 用于定位微信 WebView 场景takeScreenshot 是否截到了未渲染的空白帧
*/
private fun analyzeScreenshot(bitmap: Bitmap, packageName: String) {
try {
val w = bitmap.width
val h = bitmap.height
if (w <= 0 || h <= 0) {
Log.w(TAG, "[截图诊断] pkg=$packageName 无效尺寸: ${w}x${h}")
return
}
// 网格采样:目标约 400 个采样点,避免大图全像素扫描阻塞
val stepX = Math.max(1, w / 20)
val stepY = Math.max(1, h / 20)
var sum = 0L
var sumSq = 0L
var count = 0
var firstPixel = 0
var isSolidColor = true
var darkCount = 0 // 亮度 < 30(近黑)
var lightCount = 0 // 亮度 > 225(近白)
for (y in 0 until h step stepY) {
for (x in 0 until w step stepX) {
val pixel = bitmap.getPixel(x, y)
if (count == 0) {
firstPixel = pixel
} else if (pixel != firstPixel) {
isSolidColor = false
}
// 亮度(ITU-R BT.601):0.299R + 0.587G + 0.114B
val r = (pixel shr 16) and 0xFF
val g = (pixel shr 8) and 0xFF
val b = pixel and 0xFF
val lum = (r * 299 + g * 587 + b * 114) / 1000
sum += lum
sumSq += lum.toLong() * lum
if (lum < 30) darkCount++
if (lum > 225) lightCount++
count++
}
}
val mean = if (count > 0) sum.toDouble() / count else 0.0
val variance = if (count > 0) sumSq.toDouble() / count - mean * mean else 0.0
val darkRatio = if (count > 0) darkCount.toDouble() / count else 0.0
val lightRatio = if (count > 0) lightCount.toDouble() / count else 0.0
// 判定:纯色 或 方差<10 视为「疑似空白帧」
val likelyBlank = isSolidColor || variance < 10.0
Log.i(TAG, "[截图诊断] pkg=$packageName 尺寸=${w}x${h} 采样=$count " +
"纯色=$isSolidColor 亮度均值=${"%.1f".format(mean)} 方差=${"%.1f".format(variance)} " +
"深色占比=${"%.2f".format(darkRatio)} 浅色占比=${"%.2f".format(lightRatio)} " +
"疑似空白帧=$likelyBlank base64大小约=${(w * h) / 8}字节")
} catch (e: Exception) {
Log.w(TAG, "[截图诊断] pkg=$packageName 分析异常: ${e.message}")
}
}
/** 辅助:构造 WritableNativeMap。 */
private fun writableMapOf(vararg pairs: Pair<String, Any?>): WritableNativeMap {
val map = WritableNativeMap()
for ((k, v) in pairs) {
when (v) {
is String -> map.putString(k, v)
is Int -> map.putInt(k, v)
is Long -> map.putDouble(k, v.toDouble())
is Boolean -> map.putBoolean(k, v)
is Number -> map.putDouble(k, v.toDouble())
is WritableNativeMap -> map.putMap(k, v)
is WritableNativeArray -> map.putArray(k, v)
else -> map.putNull(k)
}
}
return map
}
/** 将当前应用拉到前台以显示确认弹窗 */
fun bringAppToForeground() {
try {
val intent = packageManager.getLaunchIntentForPackage(packageName)
if (intent != null) {
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
startActivity(intent)
Log.i(TAG, "已成功拉起本应用到前台显示弹窗")
}
} catch (e: Exception) {
Log.e(TAG, "拉起应用到前台失败: ${e.message}")
}
}
/** 递归提取无障碍节点树的全部文本(text + contentDescription)。 */
private fun dumpNodeTexts(node: AccessibilityNodeInfo?, list: MutableList<String>) {
if (node == null) return
val text = node.text?.toString()
if (!text.isNullOrBlank()) {
list.add(text)
}
// 修复:微信 8.0.52+ 混淆了 text,但部分节点仍通过 contentDescription 暴露内容
val desc = node.contentDescription?.toString()
if (!desc.isNullOrBlank() && desc != text) {
list.add(desc)
}
val childCount = node.childCount
for (i in 0 until childCount) {
val child = node.getChild(i) ?: continue
dumpNodeTexts(child, list)
child.recycle()
}
}
/**
* 增强版文本提取先尝试 rootInActiveWindow若为空则遍历所有窗口
* 修复新版 Android WebView 可能不在 activeWindow 中暴露节点
* 需要通过 getWindows() 获取 WebView 子窗口的节点树
*/
private fun dumpAllTexts(): List<String> {
val texts = mutableListOf<String>()
// 1. 先尝试 rootInActiveWindow(常规路径)
val rootNode = rootInActiveWindow
if (rootNode != null) {
dumpNodeTexts(rootNode, texts)
rootNode.recycle()
}
// 2. 如果 activeWindow 为空或无文本,遍历所有窗口(WebView 子窗口可能在这里)
if (texts.isEmpty()) {
try {
val allWindows = windows
for (window in allWindows) {
val wRoot = window.root ?: continue
dumpNodeTexts(wRoot, texts)
wRoot.recycle()
}
} catch (e: Exception) {
Log.w(TAG, "getWindows() 遍历失败: ${e.message}")
}
}
return texts
}
/** 递归遍历无障碍节点树,检查是否包含指定的任意一个关键字。 */
private fun findTextInNode(node: AccessibilityNodeInfo?, keywords: List<String>): Boolean {
if (node == null) return false
val text = node.text?.toString()
if (text != null) {
for (keyword in keywords) {
if (text.contains(keyword)) {
return true
}
}
}
val childCount = node.childCount
for (i in 0 until childCount) {
val child = node.getChild(i) ?: continue
val found = findTextInNode(child, keywords)
child.recycle()
if (found) return true
}
return false
}
override fun onInterrupt() {
Log.w(TAG, "无障碍服务被中断")
}
override fun onUnbind(intent: android.content.Intent?): Boolean {
floatingHelper?.dismiss()
floatingHelper = null
instance = null
return super.onUnbind(intent)
}
}
/**
* RN 上下文持有者 MainApplication 注入
* 无障碍服务运行在系统进程需通过静态引用访问 RN 上下文以发送事件
*/
object ReactContextHolder {
@Volatile var context: ReactApplicationContext? = null
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
无障碍服务配置(plan.md「3.6 无障碍服务」)。
注册监听支付 App 的页面变化 + 截图能力。
canTakeScreenshot="true" 是 Android 11+ AccessibilityService.takeScreenshot() 的前提。
-->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/accessibility_service_description"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="100"
android:canRequestEnhancedWebAccessibility="true"
android:canRetrieveWindowContent="true"
android:canTakeScreenshot="true"
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows" />
+240
View File
@@ -0,0 +1,240 @@
/**
* 无障碍服务 Config Pluginplan.md3.6 无障碍服务+决策 4
*
* expo prebuild 时注册 Android 无障碍服务manifest service + xml 配置
*
* 伪装机制绕过微信 8.0.52+ 节点混淆
* 微信按 ComponentName包名/类名识别系统服务白名单早期只伪装类名不伪装包名
* 因此被微信识别为第三方服务对节点 text/contentDescription 做混淆
* 现在把 7 kt 文件整体移到 com.google.android.accessibility.selecttospeak
* 并把 Manifest 服务名写成完整全限定名让微信将其识别为系统 SelectToSpeak 服务
* 从而拿到未混淆的真实节点文本
*
* 注意withAndroidManifest modResults 结构是 { manifest: { ... } }
* 操作 application 必须通过 modResults.manifest.application
*/
const { withAndroidManifest, withDangerousMod, withMainApplication } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
/**
* 伪装目标包名系统 SelectToSpeak 服务的完整包名
* 7 kt 文件会被复制到此包路径下Manifest 服务名也用此包的全限定名
*/
const FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
/** SelectToSpeakService 的完整 ComponentNameManifest android:name 用)。 */
const FAKE_SERVICE_NAME = `${FAKE_PACKAGE}.SelectToSpeakService`;
/** OcrTileService 的完整 ComponentName(与 Service 同包,保持同 package 引用)。 */
const FAKE_TILE_SERVICE_NAME = `${FAKE_PACKAGE}.OcrTileService`;
/**
* 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) {
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()) copyDir(s, d);
else fs.copyFileSync(s, d);
}
}
function withAccessibilityService(config) {
// 7 个 kt 文件全部跟随 SelectToSpeakService 伪装到 FAKE_PACKAGE 包下,
// 不再使用应用真实包名(appId)。
// 1. 复制 Kotlin 源码 + res/xml 资源到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
// Kotlin 源码统一复制到 FAKE_PACKAGE 目录下(伪装为系统 SelectToSpeak 服务包名)。
// kt 源码里仍写 `com.beancount.mobile`,此处用正则替换为 FAKE_PACKAGE。
const fakePkgPath = FAKE_PACKAGE.replace(/\./g, '/');
const ktDest = path.join(projectRoot, 'app/src/main/java', fakePkgPath);
fs.mkdirSync(ktDest, { recursive: true });
const androidDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(androidDir)) {
if (f.endsWith('.kt')) {
let content = fs.readFileSync(path.join(androidDir, f), 'utf8');
// kt 源码 package 为 com.beancount.mobile.accessibility,整体替换为 FAKE_PACKAGE。
// 注意正则要匹配到 .accessibility 后缀,否则会多出 .accessibility 导致与 Manifest 不一致。
content = content.replace(/package\s+com\.beancount\.mobile\.accessibility/g, `package ${FAKE_PACKAGE}`);
content = content.replace(/import\s+com\.beancount\.mobile\.accessibility/g, `import ${FAKE_PACKAGE}`);
fs.writeFileSync(path.join(ktDest, f), content, 'utf8');
}
}
// res/xml 资源
const resSrc = path.join(__dirname, 'android/res');
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, 'SelectToSpeakService.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)) {
let content = fs.readFileSync(stringsXmlPath, 'utf8');
if (!content.includes('accessibility_service_description')) {
content = content.replace(
/<\/resources>/,
' <string name="accessibility_service_description">自动识别支付账单页面,辅助快速记账</string>\n</resources>',
);
fs.writeFileSync(stringsXmlPath, content, 'utf8');
}
}
return modConfig;
},
]);
// 2. 注册 AccessibilityBridgePackage 到 MainApplication
// 注意:AccessibilityBridgePackage 跟随其他 kt 文件一起被复制到 FAKE_PACKAGE 下,
// 因此 import 路径要用 FAKE_PACKAGE 而非 PACKAGE(应用包名)。
config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents;
// 2a. 注入 importAccessibilityBridgePackage
content = content.replace(/^import\s+[\w.]+\.AccessibilityBridgePackage\s*$/gm, '');
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${FAKE_PACKAGE}.AccessibilityBridgePackage`,
);
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
if (!content.includes('add(AccessibilityBridgePackage())')) {
if (/PackageList\(this\)\.packages\.apply\s*\{/.test(content)) {
content = content.replace(
/(PackageList\(this\)\.packages\.apply\s*\{)/,
`$1\n add(AccessibilityBridgePackage())`,
);
} else if (/PackageList\(this\)\.packages\b/.test(content)) {
content = content.replace(
/PackageList\(this\)\.packages\b/,
`PackageList(this).packages.apply { add(AccessibilityBridgePackage()) }`,
);
}
}
modConfig.modResults.contents = content;
return modConfig;
});
// 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': FAKE_SERVICE_NAME,
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
'android:label': '浮记-账单识别',
'android:exported': 'false',
},
'intent-filter': [{
action: [{ $: { 'android:name': 'android.accessibilityservice.AccessibilityService' } }],
}],
'meta-data': [{
$: {
'android:name': 'android.accessibilityservice',
'android:resource': '@xml/accessibility_service_config',
},
}],
};
// 2. 确保 application[0] 存在
if (!Array.isArray(manifest.application) || manifest.application.length === 0) {
manifest.application = [{ $: {} }];
}
const app = manifest.application[0];
if (!app.service) {
app.service = [];
}
const exists = app.service.some(
s => s.$['android:name'] === FAKE_SERVICE_NAME
);
if (!exists) {
app.service.push(serviceNode);
}
// 3. 添加 OcrTileService 声明(快速设置磁贴,plan.md「3.11」)
const tileServiceNode = {
$: {
'android:name': FAKE_TILE_SERVICE_NAME,
'android:label': 'OCR 记账',
'android:icon': '@android:drawable/ic_menu_camera',
'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE',
'android:exported': 'true',
},
'intent-filter': [{
action: [{ $: { 'android:name': 'android.service.quicksettings.action.QS_TILE' } }],
}],
};
const tileExists = app.service.some(
s => s.$['android:name'] === FAKE_TILE_SERVICE_NAME
);
if (!tileExists) {
app.service.push(tileServiceNode);
}
return modConfig;
});
return config;
}
module.exports = withAccessibilityService;
+6
View File
@@ -0,0 +1,6 @@
{
"name": "drift-ledger-plugin-accessibility",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"
}
@@ -0,0 +1,85 @@
package com.beancount.mobile.notification
import android.app.Notification
import android.content.ComponentName
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
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.SelectToSpeakService
import com.beancount.mobile.accessibility.ReactContextHolder
/**
* 通知监听服务plan.md4.1 通知监听服务+决策 4 Config Plugin
*
* 参考 AutoAccounting NotificationListenerService
* - 提取支付 App 通知的 title/text
* - 白名单过滤复用 SelectToSpeakService.PAYMENT_PACKAGES单一数据源
* - 关键词黑白名单JS keywordFilter 进一步过滤
* - MD5 去重JS NotificationChannel 处理避免原生持有状态
* - onListenerDisconnected requestRebind 自动重连
*
* 通过 DeviceEventEmitter 把通知事件推送到 JS NotificationChannel
*/
class BillingNotificationListenerService : NotificationListenerService() {
companion object {
private const val TAG = "BillingNotification"
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
runCatching {
val packageName = sbn?.packageName?.toString() ?: return
// 白名单过滤(复用无障碍服务的 PAYMENT_PACKAGES
if (!SelectToSpeakService.PAYMENT_PACKAGES.contains(packageName)) return
val notification = sbn.notification
val extras = notification.extras
val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: ""
val text = (extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)
?: extras?.getCharSequence(Notification.EXTRA_TEXT))?.toString() ?: ""
if (title.isBlank() && text.isBlank()) return
Log.d(TAG, "收到支付通知: pkg=$packageName, title=$title")
// 推送到 JS 层
sendNotificationEvent(packageName, title, text)
}.onFailure {
Log.e(TAG, "通知处理异常: ${it.message}", it)
}
}
/**
* 监听断开时自动重连参考 AutoAccounting requestRebind
* Android Doze / App Standby 可能断开通知监听
*/
override fun onListenerDisconnected() {
super.onListenerDisconnected()
Log.w(TAG, "通知监听断开,尝试重连")
requestRebind(ComponentName(this, BillingNotificationListenerService::class.java))
}
/** 把通知事件推送到 JS 层 NotificationChannel.handleNotification。 */
private fun sendNotificationEvent(packageName: String, title: String, text: String) {
val reactContext = ReactContextHolder.context ?: run {
Log.w(TAG, "RN 上下文未就绪,丢弃通知")
return
}
try {
val map = WritableNativeMap().apply {
putString("packageName", packageName)
putString("title", title)
putString("text", text)
putDouble("timestamp", System.currentTimeMillis().toDouble())
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingNotification", map)
} catch (e: Exception) {
Log.e(TAG, "推送通知事件失败: ${e.message}")
}
}
}
@@ -0,0 +1,92 @@
/**
* 通知监听 Config Pluginplan.md4.1 通知监听服务+决策 4
*
* expo prebuild 时注册 Android NotificationListenerServicemanifest service + 权限
*
* 注意withAndroidManifest modResults 结构是 { manifest: { ... } }
* 操作 application 必须通过 modResults.manifest.application
*/
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';
}
/**
* 无障碍服务伪装目标包名必须与 accessibility plugin 保持一致
* 本服务的 Kotlin 源码 import SelectToSpeakService/ReactContextHolder
* 而这些类在 prebuild 时被 accessibility plugin 整体迁移到此伪装包下
* 因此本 plugin 复制源码时必须把对 accessibility 子包的引用一并改写到此包
* 否则会产生 "Unresolved reference" 编译错误
*/
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
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 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')) {
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
// 必须先改写 accessibility 子包引用(迁移到伪装包),再做通用包名替换,
// 否则通用规则会把 com.beancount.mobile.accessibility 错误地改成 appId.accessibility。
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
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. 注册服务到 AndroidManifest
config = withAndroidManifest(config, (modConfig) => {
const manifest = modConfig.modResults.manifest;
// 1. 添加通知监听服务
const serviceNode = {
$: {
'android:name': `${PACKAGE}.BillingNotificationListenerService`,
'android:permission': 'android.permission.BIND_NOTIFICATION_LISTENER_SERVICE',
'android:exported': 'true',
},
'intent-filter': [{
action: [{ $: { 'android:name': 'android.service.notification.NotificationListenerService' } }],
}],
};
// 2. 确保 application[0] 存在
if (!Array.isArray(manifest.application) || manifest.application.length === 0) {
manifest.application = [{ $: {} }];
}
const app = manifest.application[0];
if (!app.service) {
app.service = [];
}
const exists = app.service.some(
s => s.$['android:name'] === `${PACKAGE}.BillingNotificationListenerService`
);
if (!exists) {
app.service.push(serviceNode);
}
return modConfig;
});
return config;
}
module.exports = withNotificationListener;
@@ -0,0 +1,6 @@
{
"name": "drift-ledger-plugin-notification-listener",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"
}
+102
View File
@@ -0,0 +1,102 @@
# PP-OCR (ONNX Runtime) Config Plugin
本插件在 `expo prebuild` 时注入 PP-OCR 本地 OCR 原生模块(plan.md 决策 4)。
引擎选用 **ONNX Runtime**(跨平台、微软官方、Windows 友好),替代原 NCNN 方案。
## 当前版本
**PP-OCRv6 small**det 2.5M 参数 + rec 5.3M 参数,精度显著优于 v5 mobile)
| 指标 | PP-OCRv5 mobile | PP-OCRv6 small |
| ------------- | --------------- | -------------- |
| det Hmean | 75.2% | 84.1% |
| rec W-Avg | 73.7% | 81.3% |
| rec ONNX 大小 | ~17 MB | ~21 MB |
## 文件结构
```
plugins/ppocr/
├── app.plugin.js # Config Plugin 入口(prebuild 时执行)
├── android/ # Kotlin 原生实现(prebuild 时复制进原生工程)
│ ├── OcrModule.kt # React Native BridgeONNX Runtime 推理 + det/rec 前后处理
│ └── OcrPackage.kt # RN Package 注册(注入到 MainApplication.getPackages
└── assets/ # ONNX 模型 + 字典(需自行下载放置)
├── ppocrv6_det.onnx # 文本检测模型(PP-OCRv6 small
├── ppocrv6_rec.onnx # 文本识别模型(PP-OCRv6 small,多语言)
└── ppocrv6_dict.txt # PP-OCRv6 多语言字典(CTC 解码用)
```
## 模型获取(一键下载)
PP-OCRv6 官方 ONNX 模型来自 [PaddlePaddle/PP-OCRv6 系列](https://huggingface.co/collections/PaddlePaddle/pp-ocrv6)
```bash
# 在项目根目录执行
mkdir -p plugins/ppocr/assets
cd plugins/ppocr/assets
# det 模型(PP-OCRv6 small
curl -L -o ppocrv6_det.onnx \
https://huggingface.co/PaddlePaddle/PP-OCRv6_small_det_onnx/resolve/main/inference.onnx
# rec 模型(PP-OCRv6 small
curl -L -o ppocrv6_rec.onnx \
https://huggingface.co/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/main/inference.onnx
# PP-OCRv6 多语言字典(必须与上面的 rec 模型配套)
curl -L -o ppocrv6_dict.txt \
https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv6_dict.txt
```
> ⚠️ **字典必须与 rec 模型配套**:v6 字典字符集与 v5 完全不同,混用会导致 CTC 解码乱码。
> 若之前使用过 v5 模型,务必删除旧文件(`ppocrv5_det.onnx``ppocrv5_rec.onnx``ppocrv5_dict.txt`)。
## 性能配置(参考 AutoAccounting OcrProcessor.kt
| 优化项 | 配置 |
| -------- | -------------------------------------- |
| 引擎 | ONNX Runtime Android |
| 执行器 | CPU(兼容性最稳,部分设备 GPU 会崩溃) |
| 线程 | intraOp=2 / interOp=2 |
| det 图像 | 最大边 960px,短边压缩 720px |
| rec 图像 | 固定高度 48px |
| ABI | arm64-v8a(主流设备) |
## 使用
`app.json` 注册插件:
```json
{
"plugins": ["./plugins/ppocr"]
}
```
JS 层通过 `src/services/ocrBridge.ts``NativeOcrBridge` 调用,桥接到 `NativeModules.PpOcr`
- `recognizeText(base64)` → 返回纯文本(多行用 `\n` 连接)
- `recognizeTextBlocks(base64)` → 返回带坐标的文本块数组 `[{text, x, y, width, height, confidence}]`
- `isReady()` → 模型是否加载完成
## 当前状态
- `app.plugin.js`:✅ Config Plugin 逻辑(prebuild 注入 Kotlin + 模型 + gradle 依赖 + MainApplication 注册)
- `android/OcrModule.kt`:✅ ONNX Runtime 推理(det DB 后处理 + rec CTC 解码)
- `android/OcrPackage.kt`:✅ RN Package 注册
- `assets/`:需自行下载放置(见上「模型获取」),版权/体积原因不入仓库
真机构建步骤:放置模型文件 → `npx expo prebuild --platform android`Config Plugin 会把 Kotlin 源码与 `assets/` 下的模型/字典复制进 `android/`)→ `npx expo run:android`
> 若之前已 prebuild 过且更换过字典/模型文件,务必重新执行 `npx expo prebuild --clean`,否则 `android/app/src/main/assets/` 下可能残留旧模型/字典。
## v5 → v6 迁移说明
若从 PP-OCRv5 升级,需完成以下步骤:
1. **下载新模型**:按上述「模型获取」章节下载 v6 模型和字典
2. **删除旧文件**:移除 `ppocrv5_det.onnx``ppocrv5_rec.onnx``ppocrv5_dict.txt`
3. **代码已自动适配**`OcrModule.kt` 中的常量已更新为 v6 文件名
4. **重新 prebuild**`npx expo prebuild --clean` 确保旧资产被清理
5. **验证 tensor 名称**v6 ONNX 模型的输入 tensor 名可能与 v5 不同,若推理报错需用 Netron 检查并调整 `OcrModule.kt` 中的 `detInputs`/`recInputs` map key
+767
View File
@@ -0,0 +1,767 @@
package com.beancount.mobile.ppocr
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import android.util.Log
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.module.annotations.ReactModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.BufferedReader
import java.io.InputStreamReader
import java.nio.FloatBuffer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
import kotlin.math.max
import kotlin.math.min
/**
* PP-OCRv6 (ONNX Runtime) React Native Bridgeplan.md3.4 Layer 2+决策 4 Config Plugin
*
* 引擎ONNX Runtime跨平台微软官方Windows 友好替代 NCNN 路线
* 模型ppocrv6_det.onnx + ppocrv6_rec.onnxPP-OCRv6 small PaddlePaddle 官方 HuggingFace 下载
* 字典ppocrv6_dict.txtPP-OCRv6 多语言字典18708 字符rec 模型 18710 维输出 = 字典 + blank + 特殊位
*
* 流水线
* 1. det文本检测bitmap DB 后处理得到文本框
* 2. rec文本识别每个框 crop resize 48px CTC 解码
*
* 性能优化参考 AutoAccounting OcrProcessor.kt
* - 短边压缩到 720px像素量比 1440p 减少约 75%
* - CPU 执行兼容性最稳部分设备 GPU 会崩溃
* - det 最大边限制 960PaddleOCR 默认 limit_max_side_len
*
* JS 层通过 NativeModules.PpOcr.recognizeText(base64) 调用
*/
const val OCR_MODULE_NAME = "PpOcr"
@ReactModule(name = OCR_MODULE_NAME)
class OcrModule(private val context: ReactApplicationContext) :
ReactContextBaseJavaModule(context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val lock = ReentrantLock()
private var ortEnv: OrtEnvironment? = null
private var detSession: OrtSession? = null
private var recSession: OrtSession? = null
private var dictionary: List<String> = emptyList()
@Volatile private var initialized = false
@Volatile private var initFailed = false
/** 模型文件目录(filesystem 绝对路径)。非空时从该目录加载模型,否则回退到 assets。 */
@Volatile private var modelDir: String? = null
/** 初始化完成信号,ensureReady 可等待异步 initEngine 完成(最多等 15 秒)。 */
@Volatile private var initLatch = CountDownLatch(1)
override fun getName(): String = OCR_MODULE_NAME
override fun initialize() {
super.initialize()
// 异步加载模型,避免阻塞 RN 桥初始化
scope.launch { initEngine() }
}
/** 从 assets 或 filesystem 加载 det/rec ONNX 模型与字典。 */
private fun initEngine() {
lock.lock()
try {
if (initialized || initFailed) return
val env = OrtEnvironment.getEnvironment()
val opts = OrtSession.SessionOptions().apply {
// CPU 线程数:2 是兼容性/性能的稳妥折中(高端机可调高)
setInterOpNumThreads(2)
setIntraOpNumThreads(2)
// 移动端关闭内存优化里的图优化级别过高(部分模型会崩)
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT)
}
val dir = modelDir
val (det, rec, dict) = if (dir != null) {
// 从 filesystem 加载(P8:模型按需下载到本地目录)
val detPath = dir + java.io.File.separator + ASSET_DET_MODEL
val recPath = dir + java.io.File.separator + ASSET_REC_MODEL
val dictPath = dir + java.io.File.separator + ASSET_DICT
Log.i(OCR_MODULE_NAME, "从 filesystem 加载模型: det=$detPath, rec=$recPath")
Triple(
env.createSession(detPath, opts),
env.createSession(recPath, opts),
loadDictionaryFromFile(dictPath)
)
} else {
// 回退:从 assets 加载(兼容未迁移的老用户)
val detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() }
val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() }
Log.i(OCR_MODULE_NAME, "从 assets 加载模型(兼容模式)")
Triple(
env.createSession(detBytes, opts),
env.createSession(recBytes, opts),
loadDictionaryFromAssets()
)
}
detSession = det
recSession = rec
ortEnv = env
dictionary = dict
initialized = true
initLatch.countDown()
Log.i(OCR_MODULE_NAME, "PP-OCRv6 ONNX 模型加载成功(det+rec, dict=${dict.size}")
} catch (e: Exception) {
initFailed = true
initLatch.countDown()
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
val dir = modelDir
if (dir != null) {
Log.e(OCR_MODULE_NAME, "请确认 $dir 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
} else {
Log.e(OCR_MODULE_NAME, "请确认 assets 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
}
} finally {
lock.unlock()
}
}
/** rec 推理用的 OrtEnvironment(复用 ortEnv 单例)。 */
private val recEnv: OrtEnvironment? get() = ortEnv
/**
* assets 加载 ppocrv6_dict.txt 字典
*
* PaddleOCR CTC 约定模型输出 logits index 0 blank字符从 index 1 开始
* 字典条目 dictionary[i] 对应模型输出 index i+1解码时 dictIdx = argmaxIdx - 1
* 字典本身不含 blank运行时固定用 index 0 blank ctcGreedyDecode
*/
private fun loadDictionaryFromAssets(): List<String> {
val words = mutableListOf<String>()
context.assets.open(ASSET_DICT).use { stream ->
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines ->
lines.forEach { line ->
// PaddleOCR 字典每行一个字符(去掉行尾换行)
words.add(line.trimEnd('\r', '\n'))
}
}
}
return words
}
/** 从 filesystem 路径加载字典文件。 */
private fun loadDictionaryFromFile(path: String): List<String> {
val words = mutableListOf<String>()
java.io.File(path).bufferedReader(Charsets.UTF_8).useLines { lines ->
lines.forEach { line ->
words.add(line.trimEnd('\r', '\n'))
}
}
return words
}
/**
* 识别图片文本
* @param imageBase64 base64 编码的图片JPEG/PNG
* @return 识别出的纯文本所有行用 \n 连接
*/
@ReactMethod
fun recognizeText(imageBase64: String, promise: Promise) {
scope.launch {
var bitmap: Bitmap? = null
var scaled: Bitmap? = null
try {
ensureReady()
bitmap = decodeBase64(imageBase64)
if (bitmap == null) {
promise.reject("DECODE_FAILED", "base64 解码失败")
return@launch
}
scaled = capLongEdge(bitmap, CAP_LONG_EDGE)
val blocks = runInference(scaled)
val text = blocks.joinToString("\n") { it.text }
promise.resolve(text)
} catch (e: Exception) {
Log.e(OCR_MODULE_NAME, "recognizeText 异常: ${e.message}", e)
promise.reject("OCR_ERROR", e.message)
} finally {
bitmap?.recycle()
if (scaled !== bitmap) {
scaled?.recycle()
}
}
}
}
/**
* 识别并返回带坐标的文本块用于复杂版面
* @return JSON 数组字符串[{text, x, y, width, height, confidence}]
*/
@ReactMethod
fun recognizeTextBlocks(imageBase64: String, promise: Promise) {
scope.launch {
var bitmap: Bitmap? = null
var scaled: Bitmap? = null
try {
ensureReady()
bitmap = decodeBase64(imageBase64)
if (bitmap == null) {
promise.reject("DECODE_FAILED", "base64 解码失败")
return@launch
}
scaled = capLongEdge(bitmap, CAP_LONG_EDGE)
val blocks = runInference(scaled)
// 序列化为 RN WritableArray
val arr = WritableNativeArray()
for (b in blocks) {
val map: WritableMap = WritableNativeMap()
map.putString("text", b.text)
map.putDouble("x", b.x.toDouble())
map.putDouble("y", b.y.toDouble())
map.putDouble("width", b.width.toDouble())
map.putDouble("height", b.height.toDouble())
map.putDouble("confidence", b.confidence.toDouble())
arr.pushMap(map)
}
promise.resolve(arr)
} catch (e: Exception) {
Log.e(OCR_MODULE_NAME, "recognizeTextBlocks 异常: ${e.message}", e)
promise.reject("OCR_ERROR", e.message)
} finally {
bitmap?.recycle()
if (scaled !== bitmap) {
scaled?.recycle()
}
}
}
}
/** 引擎是否已就绪(模型加载完成)。 */
@ReactMethod
fun isReady(promise: Promise) {
promise.resolve(initialized)
}
/** 设置模型文件目录(绝对路径)。若引擎已初始化则释放并重新加载。 */
@ReactMethod
fun setModelDir(dir: String, promise: Promise) {
// 修复:expo-file-system 返回 file:// URI,需转为文件系统绝对路径
val newDir = dir.removePrefix("file://")
// 修复:目录未变且引擎已就绪时跳过重新加载,避免冗余 release 导致竞态
if (newDir == modelDir && initialized) {
promise.resolve(true)
return
}
modelDir = newDir
// 修复:无论之前是成功还是失败,设置新目录后都应重新加载模型
if (initialized || initFailed) {
release()
initFailed = false
initLatch = CountDownLatch(1)
scope.launch { initEngine() }
}
promise.resolve(true)
}
// ============== 推理流水线 ==============
private fun ensureReady() {
if (!initialized && !initFailed) initEngine()
// 修复:异步 initEngine 进行中时等待完成(最多 15 秒),而非立即抛异常
if (!initialized && !initFailed) {
initLatch.await(15, TimeUnit.SECONDS)
}
if (!initialized) throw IllegalStateException("OCR 引擎未就绪(模型未加载,${if (initFailed) "初始化失败" else "加载中"}")
}
/** 完整推理:det 检测文本框 → 对每个框 rec 识别 → 返回带坐标的文本块。 */
private fun runInference(bitmap: Bitmap): List<OcrBlock> {
val totalStartTime = System.currentTimeMillis()
Log.i(OCR_MODULE_NAME, "runInference 开始: bitmap 尺寸 = ${bitmap.width}x${bitmap.height}")
val det = detSession ?: run {
Log.e(OCR_MODULE_NAME, "detSession 为空,放弃推理")
return emptyList()
}
val rec = recSession ?: run {
Log.e(OCR_MODULE_NAME, "recSession 为空,放弃推理")
return emptyList()
}
var resized: Bitmap? = null
var detInputTensor: OnnxTensor? = null
var detOutputs: OrtSession.Result? = null
val results = mutableListOf<OcrBlock>()
var detTime = 0L
var postTime = 0L
var recTime = 0L
var boxCount = 0
try {
// ---- 1. 文本检测(DB----
val detStartTime = System.currentTimeMillis()
resized = resizeForDet(bitmap, DET_LIMIT_MAX_SIDE)
Log.i(OCR_MODULE_NAME, "det 图像缩放后尺寸 = ${resized.width}x${resized.height}")
val ratioX = bitmap.width.toFloat() / resized.width
val ratioY = bitmap.height.toFloat() / resized.height
val detInput = preprocessDet(resized)
detInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(detInput.data), longArrayOf(1L, 3L, detInput.h.toLong(), detInput.w.toLong()))
val detInputs = mapOf("x" to detInputTensor)
val detRunStart = System.currentTimeMillis()
detOutputs = det.run(detInputs)
detTime = System.currentTimeMillis() - detRunStart
@Suppress("UNCHECKED_CAST")
val detProb = (detOutputs[0].value as Array<Array<Array<FloatArray>>>)[0][0] // [H,W]
Log.i(OCR_MODULE_NAME, "det 推理完成 (耗时: ${detTime}ms),概率图尺寸 = ${detProb.size}x${detProb[0].size}")
// DB 后处理:threshold → 轮廓 → 最小外接矩形
val postStart = System.currentTimeMillis()
val boxes = dbPostprocess(detProb, detInput.h, detInput.w, ratioX, ratioY)
postTime = System.currentTimeMillis() - postStart
boxCount = boxes.size
Log.i(OCR_MODULE_NAME, "dbPostprocess 后处理完成 (耗时: ${postTime}ms),检测到文本框数量 = ${boxCount}")
if (boxes.isEmpty()) {
val totalTime = System.currentTimeMillis() - totalStartTime
Log.i(OCR_MODULE_NAME, "PP-OCRv6 推理完成(无文本): 总耗时 = ${totalTime}ms (det模型推理 = ${detTime}ms, det后处理 = ${postTime}ms)")
return emptyList()
}
// ---- 2. 文本识别(CRNN+CTC----
val recStart = System.currentTimeMillis()
for ((idx, box) in boxes.withIndex()) {
var crop: Bitmap? = null
var recInputTensor: OnnxTensor? = null
var recOutputs: OrtSession.Result? = null
try {
crop = cropBox(bitmap, box)
if (crop == null) {
Log.w(OCR_MODULE_NAME, "裁剪文本框失败 (index = $idx)")
continue
}
val recInput = preprocessRec(crop)
recInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(recInput.data), longArrayOf(1L, 3L, REC_IMAGE_HEIGHT.toLong(), recInput.w.toLong()))
val recInputs = mapOf("x" to recInputTensor)
recOutputs = rec.run(recInputs)
// 输出 shape: [1, T, numClasses]
@Suppress("UNCHECKED_CAST")
val logits = (recOutputs[0].value as Array<Array<FloatArray>>)[0]
val (text, conf) = ctcGreedyDecode(logits)
val xs = box.map { it[0] }
val ys = box.map { it[1] }
val minX = (xs.minOrNull() ?: 0f).toInt()
val minY = (ys.minOrNull() ?: 0f).toInt()
val maxX = (xs.maxOrNull() ?: 0f).toInt()
val maxY = (ys.maxOrNull() ?: 0f).toInt()
val w = maxX - minX
val h = maxY - minY
Log.i(OCR_MODULE_NAME, "文本框 $idx 识别结果 = '$text', 坐标 = ($minX, $minY, $w, $h), 置信度 = $conf")
if (text.isNotEmpty()) {
results.add(OcrBlock(text, minX.toFloat(), minY.toFloat(), w.toFloat(), h.toFloat(), conf))
}
} finally {
crop?.recycle()
recInputTensor?.close()
recOutputs?.close()
}
}
recTime = System.currentTimeMillis() - recStart
} finally {
if (resized !== bitmap) {
resized?.recycle()
}
detInputTensor?.close()
detOutputs?.close()
}
val totalTime = System.currentTimeMillis() - totalStartTime
Log.i(
OCR_MODULE_NAME,
"PP-OCRv6 ONNX 推理整体完成: 总耗时 = ${totalTime}ms (det模型推理 = ${detTime}ms, det后处理 = ${postTime}ms, rec文本识别 = ${recTime}ms, 识别文本框数 = ${results.size}/${boxCount})"
)
return results
}
// ============== 前处理 ==============
/** det 前处理:resize → BCHW → normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])。 */
private fun preprocessDet(bmp: Bitmap): TensorData {
val w = bmp.width
val h = bmp.height
val pixels = IntArray(w * h)
bmp.getPixels(pixels, 0, w, 0, 0, w, h)
val data = FloatArray(3 * w * h)
// BCHW 顺序
val means = floatArrayOf(0.485f, 0.456f, 0.406f)
val stds = floatArrayOf(0.229f, 0.224f, 0.225f)
for (c in 0..2) {
val mean = means[c]
val std = stds[c]
for (y in 0 until h) {
for (x in 0 until w) {
val px = pixels[y * w + x]
// 对齐官方训练通道序 BGRc=0→B, 1→G, 2→R);mean/std 数值按 BGR 顺序配套
val channelVal = (px shr (8 * c)) and 0xFF
data[c * w * h + y * w + x] = (channelVal / 255.0f - mean) / std
}
}
}
return TensorData(data, w, h)
}
/** rec 前处理:crop → resize 到 48 高(保持宽高比)→ pad 到整除 4 → normalize。 */
private fun preprocessRec(bmp: Bitmap): TensorData {
var w = bmp.width
val h = bmp.height
// resize 到高度 48,宽度等比缩放(ceil 取整对齐官方,避免右缘字符因 floor 被裁)
// 注:java.lang.Math.ceil 仅有 double 重载,故用 toDouble()Math.round 有 float 重载所以无需)
var resizedW = Math.ceil(w.toDouble() / h * REC_IMAGE_HEIGHT).toInt()
// 宽度上限,避免单行过长爆显存(官方 cap=3200,移动端折中 REC_MAX_WIDTH=1280
resizedW = min(resizedW, REC_MAX_WIDTH)
resizedW = max(resizedW, 1)
val resized = if (resizedW == w && h == REC_IMAGE_HEIGHT) bmp
else Bitmap.createScaledBitmap(bmp, resizedW, REC_IMAGE_HEIGHT, true)
w = resized.width
val pixels = IntArray(w * REC_IMAGE_HEIGHT)
resized.getPixels(pixels, 0, w, 0, 0, w, REC_IMAGE_HEIGHT)
val data = FloatArray(3 * w * REC_IMAGE_HEIGHT)
val means = floatArrayOf(0.5f, 0.5f, 0.5f)
val stds = floatArrayOf(0.5f, 0.5f, 0.5f)
for (c in 0..2) {
val mean = means[c]
val std = stds[c]
for (y in 0 until REC_IMAGE_HEIGHT) {
for (x in 0 until w) {
val px = pixels[y * w + x]
val channelVal = (px shr (16 - 8 * c)) and 0xFF
data[c * w * REC_IMAGE_HEIGHT + y * w + x] = (channelVal / 255.0f - mean) / std
}
}
}
if (resized !== bmp) resized.recycle()
return TensorData(data, w, REC_IMAGE_HEIGHT)
}
// ============== DB 后处理(连通域法,对齐 PaddleOCR 官方) ==============
// sigmoid → 阈值二值化 → 4-连通域标记 → 每域 bbox 按官方 unclip 公式外扩 → box_score_fast 过滤。
// 取代旧的「水平/垂直投影法」:投影法会把基线孤立小数点切到行外导致金额丢点(¥143.97→¥14397)。
/**
* DB 后处理sigmoid + 阈值 0.3 二值图 连通域外接矩形
* 简化版用水平投影切行 + 垂直投影切列得到矩形框对账单类版面够用
*/
private fun dbPostprocess(prob: Array<FloatArray>, h: Int, w: Int, ratioX: Float, ratioY: Float): List<List<FloatArray>> {
// 自动检测是否需要 Sigmoid
var minVal = Float.MAX_VALUE
var maxVal = Float.MIN_VALUE
for (y in 0 until h) {
for (x in 0 until w) {
val v = prob[y][x]
if (v < minVal) minVal = v
if (v > maxVal) maxVal = v
}
}
val needSigmoid = minVal < -0.05f || maxVal > 1.05f
Log.i(OCR_MODULE_NAME, "DB prob min=$minVal, max=$maxVal, needSigmoid=$needSigmoid")
// 过滤状态栏(前8%)和导航栏(后8%),避免其上的干扰字符(如电池、时间、返回键)影响识别或导致行粘连
val startY = (h * 0.08).toInt()
val endY = (h * 0.92).toInt()
val binMask = Array(h) { IntArray(w) }
val sigMap = Array(h) { FloatArray(w) } // 概率图(sigmoid 后),供连通域 box_score_fast 取文本像素均值
var activeCountTotal = 0
for (y in 0 until h) {
for (x in 0 until w) {
if (y < startY || y > endY) {
binMask[y][x] = 0
continue
}
val raw = prob[y][x]
val sig = if (needSigmoid) {
1.0f / (1.0f + Math.exp(-raw.toDouble()).toFloat())
} else {
raw
}
sigMap[y][x] = sig
val isActive = if (sig > DET_THRESH) 1 else 0
binMask[y][x] = isActive
if (isActive == 1) activeCountTotal++
}
}
Log.i(OCR_MODULE_NAME, "二值化完成: 活跃像素 = $activeCountTotal / ${w * h}")
// 清理垂直干扰线(如滚动条、背景边框线):如果某列在文本有效区域内的活跃像素超过该区域高度的 30%,视为干扰列,整列清零
val maxColActive = ((endY - startY) * 0.3).toInt()
var clearedColsCount = 0
for (x in 0 until w) {
var colActive = 0
for (y in startY..endY) {
if (binMask[y][x] == 1) colActive++
}
if (colActive > maxColActive) {
clearedColsCount++
for (y in 0 until h) {
binMask[y][x] = 0
}
}
}
Log.i(OCR_MODULE_NAME, "垂直线噪清理完成: 清理了 $clearedColsCount / $w")
// 连通域标记(4-连通,迭代 BFS 防栈溢出):取代旧的「水平投影切行 + 垂直投影切列」。
// 投影法会把基线/顶线上的孤立标点(小数点等,该行水平投影 < w/20)切到行外,
// 导致 crop 不含标点、rec 漏识(金额 ¥143.97 → ¥14397 的根因,已用官方同模型对照坐实)。
// 连通域天然把「数字 + 基线点」归为同一域,根治丢点;并正确处理断裂字符与多栏版面。
val labels = Array(h) { IntArray(w) }
var nComp = 0
val compX0 = mutableListOf<Int>()
val compY0 = mutableListOf<Int>()
val compX1 = mutableListOf<Int>()
val compY1 = mutableListOf<Int>()
val compArea = mutableListOf<Int>()
val compSum = mutableListOf<Float>() // 域内文本像素 sig 累加,供 box_score_fast 取均值
val stack = ArrayDeque<IntArray>()
for (y in 0 until h) {
for (x in 0 until w) {
if (binMask[y][x] == 1 && labels[y][x] == 0) {
nComp++
val id = nComp
var x0 = x; var y0 = y; var x1 = x; var y1 = y
var area = 0; var sum = 0f
stack.clear()
stack.addLast(intArrayOf(x, y))
labels[y][x] = id
while (stack.isNotEmpty()) {
val p = stack.removeLast()
val px = p[0]; val py = p[1]
area++
sum += sigMap[py][px]
if (px < x0) x0 = px; if (px > x1) x1 = px
if (py < y0) y0 = py; if (py > y1) y1 = py
if (px > 0 && binMask[py][px - 1] == 1 && labels[py][px - 1] == 0) { labels[py][px - 1] = id; stack.addLast(intArrayOf(px - 1, py)) }
if (px < w - 1 && binMask[py][px + 1] == 1 && labels[py][px + 1] == 0) { labels[py][px + 1] = id; stack.addLast(intArrayOf(px + 1, py)) }
if (py > 0 && binMask[py - 1][px] == 1 && labels[py - 1][px] == 0) { labels[py - 1][px] = id; stack.addLast(intArrayOf(px, py - 1)) }
if (py < h - 1 && binMask[py + 1][px] == 1 && labels[py + 1][px] == 0) { labels[py + 1][px] = id; stack.addLast(intArrayOf(px, py + 1)) }
}
compX0.add(x0); compY0.add(y0); compX1.add(x1); compY1.add(y1)
compArea.add(area); compSum.add(sum)
}
}
}
Log.i(OCR_MODULE_NAME, "dbPostprocess 连通域标记完成,连通域数 = $nComp")
val boxes = mutableListOf<List<FloatArray>>()
var dropSmall = 0; var dropScore = 0
for (i in 0 until nComp) {
val bx0 = compX0[i]; val by0 = compY0[i]; val bx1 = compX1[i]; val by1 = compY1[i]
val bw = bx1 - bx0 + 1; val bh = by1 - by0 + 1
val area = compArea[i]
val perim = 2 * (bw + bh)
// 官方 unclip 公式:distance = 连通域面积 × 比例 / 周长(水平文本下≈各向同性外扩,把基线标点纳入框)
var d = if (perim > 0) area * UNCLIP_RATIO / perim else 0f
if (d < 1f) d = 1f
val nx0 = max(0, Math.round(bx0 - d))
val ny0 = max(0, Math.round(by0 - d))
val nx1 = min(w - 1, Math.round(bx1 + d))
val ny1 = min(h - 1, Math.round(by1 + d))
if ((nx1 - nx0 + 1) < MIN_SIZE || (ny1 - ny0 + 1) < MIN_SIZE) { dropSmall++; continue }
// box_score_fast:域内文本像素 prob 均值(非整 bbox 均值,否则被背景稀释而误杀)
val score = compSum[i] / area
if (score < BOX_THRESH) { dropScore++; continue }
val fx0 = nx0 * ratioX; val fy0 = ny0 * ratioY
val fx1 = nx1 * ratioX; val fy1 = ny1 * ratioY
boxes.add(listOf(
floatArrayOf(fx0, fy0),
floatArrayOf(fx1, fy0),
floatArrayOf(fx1, fy1),
floatArrayOf(fx0, fy1),
))
}
Log.i(OCR_MODULE_NAME, "dbPostprocess 取框完成: 扩后min_size丢=$dropSmall, box_score丢=$dropScore, 保留=${boxes.size}")
return boxes
}
// ============== CTC 解码 ==============
/**
* CTC greedy decode每个时间步取 argmax blank 去重复返回 (text, avgConfidence)
*
* PaddleOCR 约定logits index 0 固定是 blank字符从 index 1
* dictionary[i] 对应模型输出 index i+1因此 dictIdx = argmaxIdx - 1
*
* PP-OCRv6 模型输出已经是概率分布值域 [0,1]无需额外 softmax
* 直接取 argmax 对应的值作为该时间步的置信度
*/
private fun ctcGreedyDecode(logits: Array<FloatArray>): Pair<String, Float> {
if (logits.isEmpty()) return "" to 0f
val numClasses = logits[0].size
val blankIdx = 0 // PaddleOCR CTCblank 固定在 index 0
val sb = StringBuilder()
var lastIdx = -1
var confSum = 0.0f
var confCount = 0
for (t in logits.indices) {
var maxIdx = 0
var maxVal = logits[t][0]
for (i in 1 until numClasses) {
if (logits[t][i] > maxVal) { maxVal = logits[t][i]; maxIdx = i }
}
if (maxIdx != blankIdx && maxIdx != lastIdx) {
val dictIdx = maxIdx - 1 // index 1..N → dictionary[0..N-1]
if (dictIdx in 0 until dictionary.size) {
sb.append(dictionary[dictIdx])
confSum += maxVal // v6 输出已是概率,直接用
confCount++
}
}
lastIdx = maxIdx
}
val avgConf = if (confCount > 0) confSum / confCount else 0f
return sb.toString() to avgConf
}
// ============== Bitmap 工具 ==============
private fun cropBox(bmp: Bitmap, box: List<FloatArray>): Bitmap? {
val xs = box.map { it[0] }
val ys = box.map { it[1] }
val paddingX = 4
val paddingY = 2
val minX = max(0, (xs.minOrNull() ?: 0f).toInt() - paddingX)
val minY = max(0, (ys.minOrNull() ?: 0f).toInt() - paddingY)
val maxX = min(bmp.width, ((xs.maxOrNull() ?: 0f) + 1).toInt() + paddingX)
val maxY = min(bmp.height, ((ys.maxOrNull() ?: 0f) + 1).toInt() + paddingY)
val w = maxX - minX
val h = maxY - minY
if (w < 2 || h < 2) return null
var crop = Bitmap.createBitmap(bmp, minX, minY, w, h)
// 竖排文本旋转 90°(对齐官方 get_rotate_crop_image:高/宽 ≥ 1.5 视为竖排,
// 否则 rec 模型按水平行识别会乱码;账单侧边竖排小字借此可识别)
if (crop.height >= crop.width * 1.5f) {
val m = android.graphics.Matrix()
m.postRotate(90f)
val rotated = Bitmap.createBitmap(crop, 0, 0, crop.width, crop.height, m, true)
if (rotated !== crop) crop.recycle()
crop = rotated
}
return crop
}
private fun resizeForDet(bmp: Bitmap, maxSide: Int): Bitmap {
// 长边上限 + 无条件对齐 32(det 下采样要求),half-up 取整对齐官方 round。
// 关键修复:旧实现 ratio>=1 时直接 return 不对齐,若整图未预压且非 32 倍数会喂入非法尺寸,
// 导致 det 内部特征图广播报错;现无条件对齐,且 cap 后图通常 ≤32 倍数时仅轻微取整。
val ratioC = minOf(1f, maxSide.toFloat() / max(bmp.width, bmp.height))
val newW = Math.round(bmp.width * ratioC)
val newH = Math.round(bmp.height * ratioC)
val alignedW = max(32, Math.round(newW / 32f) * 32)
val alignedH = max(32, Math.round(newH / 32f) * 32)
if (alignedW == bmp.width && alignedH == bmp.height) return bmp
return Bitmap.createScaledBitmap(bmp, alignedW, alignedH, true)
}
// ============== 公共工具(复用) ==============
/** 解码 base64 图片为 Bitmap。 */
private fun decodeBase64(base64: String): Bitmap? {
return try {
// 去除 data:image/...;base64, 前缀
val data = if (base64.contains(",")) base64.substringAfter(",") else base64
val bytes = Base64.decode(data, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (e: Exception) {
Log.e(OCR_MODULE_NAME, "base64 解码失败: ${e.message}")
null
}
}
/**
* 整图长边上限降采样仅当长边超过 cap 才按比例缩小half-up 取整否则原样返回
* 使 rec crop 源尽量高清手机截图通常不触发仅防超大图 OOM
* 取代旧的短边压 720旧法把整图先砍掉 75% 像素叠加 det resize 后小数点等细笔画被严重淡化
*/
private fun capLongEdge(bitmap: Bitmap, capLong: Int): Bitmap {
val width = bitmap.width
val height = bitmap.height
val longEdge = maxOf(width, height)
if (longEdge <= capLong) return bitmap
val scale = capLong.toFloat() / longEdge
val newWidth = Math.round(width * scale)
val newHeight = Math.round(height * scale)
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
override fun onCatalystInstanceDestroy() {
super.onCatalystInstanceDestroy()
release()
}
override fun invalidate() {
super.invalidate()
release()
}
private fun release() {
lock.lock()
try {
detSession?.close()
recSession?.close()
// OrtEnvironment 是单例,不主动 close(进程级)
detSession = null
recSession = null
ortEnv = null
initialized = false
} catch (_: Exception) {
} finally {
lock.unlock()
}
}
/** 识别结果块。 */
private data class OcrBlock(val text: String, val x: Float, val y: Float, val width: Float, val height: Float, val confidence: Float)
/** 预处理后的张量数据 + 宽高。 */
private data class TensorData(val data: FloatArray, val w: Int, val h: Int)
companion object {
/** 整图长边上限仅超大图降采样防 OOM手机截图(2400)不预压使 rec crop 源为高清原图
* 对齐官方只放大不缩小语义的移动端折中官方 max_side_limit=4000 */
private const val CAP_LONG_EDGE = 3000
/** det 输入长边上限(官方 OCR 管线不降采样;移动端为性能折中取 1600,旧值 960 会让小数点仅 1-2px 而糊掉)。 */
private const val DET_LIMIT_MAX_SIDE = 1600
/** DB 二值化阈值(对齐官方模型 inference.yml=0.2,对细笔画/小数点更敏感;噪声框由 BOX_THRESH 兜底)。 */
private const val DET_THRESH = 0.2f
/** box_score_fast 过滤阈值:连通域文本像素 prob 均值低于此值视为噪声框(对齐官方 OCR 管线=0.6)。 */
private const val BOX_THRESH = 0.6f
/** 官方 unclip 外扩比例:distance = 连通域面积 × 比例 / 周长。 */
private const val UNCLIP_RATIO = 1.5f
/** 外扩后文本框短边下限(像素),小于此值丢弃(对齐官方 min_size=5)。 */
private const val MIN_SIZE = 5
/** rec 固定图像高度。 */
private const val REC_IMAGE_HEIGHT = 48
/** rec 单行最大宽度(官方 cap=3200,移动端折中 1280;旧值 320 会把长商户名水平压扁 5×+)。 */
private const val REC_MAX_WIDTH = 1280
/** assets 中的模型/字典文件名。 */
private const val ASSET_DET_MODEL = "ppocrv6_det.onnx"
private const val ASSET_REC_MODEL = "ppocrv6_rec.onnx"
// PP-OCRv6 多语言识别模型的配套字典(18708 字符 + 运行时 1 blank = 18710 维输出)。
// 注意:必须与 rec 模型配套,错用 v5 字典(18383)会导致 CTC 解码丢字符。
private const val ASSET_DICT = "ppocrv6_dict.txt"
}
}
+24
View File
@@ -0,0 +1,24 @@
package com.beancount.mobile.ppocr
import android.view.View
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ReactShadowNode
import com.facebook.react.uimanager.ViewManager
/**
* 注册 OcrModule React Native Packageplan.md3.2 Config Plugin
*
* app.plugin.js withMainApplication 注入到 MainApplication.getPackages() 列表
* RN 在启动时遍历所有 Package调用 createNativeModules 注册原生模块
*/
class OcrPackage : ReactPackage {
override fun createNativeModules(rc: ReactApplicationContext): List<NativeModule> {
return listOf(OcrModule(rc))
}
override fun createViewManagers(rc: ReactApplicationContext): List<ViewManager<View, ReactShadowNode<*>>> {
return emptyList()
}
}
+149
View File
@@ -0,0 +1,149 @@
/**
* PP-OCRv5 (ONNX Runtime) Config Pluginplan.md决策 4 Expo Config Plugin 方案+3.4 Layer 2
*
* 本插件在 expo prebuild
* 1. 复制 Kotlin 源码OcrModule.kt + OcrPackage.kt android/app/src/main/java/...
* 2. 复制 ONNX 模型与字典到 android/app/src/main/assets
* 3. 注册 OcrPackage MainApplicationgetPackages
* 4. 添加 onnxruntime-android 依赖app/build.gradle
*
* 引擎ONNX Runtime跨平台微软官方Windows 友好替代 NCNN 路线
* 注意SDK 54 @expo/config-plugins 暴露的是 withMainApplication / withAppBuildGradle
* 没有 withAndroidMainApplication / withAndroidGradle文件复制用 withDangerousMod
*
* 模型文件放 plugins/ppocr/assets/版权/体积原因不入仓库需自行下载 README.md
*/
const {
withMainApplication,
withAppBuildGradle,
withDangerousMod,
} = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
/** 递归复制目录,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 });
for (const entry of fs.readdirSync(src)) {
const s = path.join(src, entry);
const d = path.join(dest, entry);
if (fs.statSync(s).isDirectory()) copyDir(s, d);
else fs.copyFileSync(s, d);
}
}
function withPpOcr(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.ppocr`;
// 1+2. 复制 Kotlin 源码与 ONNX 模型/字典到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const pkgPath = appId.replace(/\./g, '/');
const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'ppocr');
// Kotlin 源码并自动重命名包名
copyAndReplaceDir(
path.join(__dirname, 'android'),
ktDest,
appId,
);
// ONNX 模型 + 字典(若已下载)
const assetsSrc = path.join(__dirname, 'assets');
if (fs.existsSync(assetsSrc)) {
copyDir(
assetsSrc,
path.join(projectRoot, 'app/src/main/assets'),
);
}
return modConfig;
},
]);
// 3. 注册 OcrPackage 到 MainApplication
config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents;
// 3a. 注入 import(在 package 声明行后插入)
content = content.replace(/^import\s+[\w.]+\.OcrPackage\s*$/gm, '');
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.OcrPackage`,
);
// 3b. 在 getPackages() 的 .apply {} 块里注入 add(OcrPackage())
if (!content.includes('add(OcrPackage())')) {
if (/PackageList\(this\)\.packages\.apply\s*\{/.test(content)) {
// 新架构 KotlinSDK 54 默认):模板已有空 .apply {} 块,在块开头注入
content = content.replace(
/(PackageList\(this\)\.packages\.apply\s*\{)/,
`$1\n // PP-OCRv5 ONNX 原生模块(由 Config Plugin 注入)\n add(OcrPackage())`,
);
} else if (/PackageList\(this\)\.packages\b/.test(content)) {
// 新架构变体:.packages 后没有 .apply,包一层
content = content.replace(
/PackageList\(this\)\.packages\b/,
`PackageList(this).packages.apply { add(OcrPackage()) }`,
);
} else if (/\breturn\s+packages\s*;/.test(content)) {
// 老架构 Java:在 return packages; 前插入
content = content.replace(
/(\breturn\s+packages\s*;)/,
`packages.add(new OcrPackage());\n $1`,
);
}
}
modConfig.modResults.contents = content;
return modConfig;
});
// 4. 配置 app/build.gradle 依赖(ONNX Runtime Android
config = withAppBuildGradle(config, (modConfig) => {
let gradle = modConfig.modResults.contents;
if (!gradle.includes('onnxruntime')) {
// 在 dependencies { 开头处插入,避免嵌套花括号的正则匹配错误
gradle = gradle.replace(
/(dependencies\s*\{)/,
`$1\n // PP-OCRv5 ONNX Runtime(由 Config Plugin 注入)\n implementation 'com.microsoft.onnxruntime:onnxruntime-android:1.20.0'`
);
}
modConfig.modResults.contents = gradle;
return modConfig;
});
return config;
}
module.exports = withPpOcr;
+6
View File
@@ -0,0 +1,6 @@
{
"name": "drift-ledger-plugin-ppocr",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"
}
@@ -0,0 +1,90 @@
package com.beancount.mobile.screenshot
import android.net.Uri
import android.util.Base64
import android.provider.MediaStore
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.beancount.mobile.accessibility.ReactContextHolder
/**
* 截图监控 RN Moduleplan.md3.13 截图自动记账通道
*
* JS 端调用 start()/stop() 控制 ContentObserver 的注册/注销
* 检测到截图时读取 base64 并通过 DeviceEventEmitter 发送到 JS
*
* 事件格式WritableNativeMap BillingAccessibilityService.sendScreenshotEvent 一致
* { base64: "data:image/jpeg;base64,...", packageName: "...", timestamp: Long, displayName: "..." }
*/
class ScreenshotModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
private var observer: ScreenshotObserver? = null
override fun getName() = "ScreenshotMonitor"
override fun invalidate() {
stop()
super.invalidate()
}
/**
* 启动截图监控
* 在主线程注册 ContentObserver检测到截图时发送 billingScreenshot 事件
*/
@ReactMethod
fun start() {
if (observer != null) return
// 确保 ReactContextHolder 有上下文
ReactContextHolder.context = reactContext
observer = ScreenshotObserver(reactContext) { uri ->
sendScreenshotEvent(uri)
}
observer?.register()
}
/** 停止截图监控。 */
@ReactMethod
fun stop() {
observer?.unregister()
observer = null
}
/** 读取截图 base64 并发送到 JSWritableNativeMap 格式,与无障碍服务一致)。 */
private fun sendScreenshotEvent(uri: Uri) {
try {
val resolver = reactContext.contentResolver
// 读取图片为 base64
val inputStream = resolver.openInputStream(uri) ?: return
val bytes = inputStream.use { it.readBytes() }
inputStream.close()
val base64 = "data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
// 同时查询显示名
val projection = arrayOf(MediaStore.Images.Media.DISPLAY_NAME)
var displayName = ""
resolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
displayName = cursor.getString(0) ?: ""
}
}
val map = WritableNativeMap()
map.putString("base64", base64)
map.putString("uri", uri.toString())
map.putString("packageName", "")
map.putString("displayName", displayName)
map.putDouble("timestamp", System.currentTimeMillis().toDouble())
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingScreenshot", map)
} catch (e: Exception) {
android.util.Log.e("ScreenshotModule", "发送截图事件失败: ${e.message}", e)
}
}
}
@@ -0,0 +1,116 @@
package com.beancount.mobile.screenshot
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.util.Log
/**
* 截图监听 ContentObserverplan.md3.13 截图自动记账通道
*
* 参考 BeeCount ScreenshotObserver.kt
* - 监听 MediaStore.Images.Media.EXTERNAL_CONTENT_URI 变化
* - 关键词匹配screenshot/截屏/截图/screen_shot
* - 30 秒时间窗过滤旧截图
* - processedPaths 去重最多 200
* - 过滤小米 .pending- 临时文件
* - 500ms 防抖
*
* Config Plugin 注册触发后通过 DeviceEventEmitter 推送到 JS ScreenshotChannel
*
* src/domain/constants.ts 同步TIME_WINDOW_MSMAX_PROCESSEDSCREENSHOT_KEYWORDS
* 修改时需两边同时更新
*/
class ScreenshotObserver(
private val context: Context,
private val handler: Handler = Handler(Looper.getMainLooper()),
private val onScreenshot: (uri: Uri) -> Unit,
) : ContentObserver(handler) {
companion object {
private const val TAG = "ScreenshotObserver"
private const val TIME_WINDOW_MS = 30_000L
private const val MAX_PROCESSED = 200
private val SCREENSHOT_KEYWORDS = listOf("screenshot", "截屏", "截图", "screen_shot", "Screenshot")
}
private val resolver = context.contentResolver
private val processedPaths = LinkedHashSet<String>()
@Volatile private var lastCheckTime = System.currentTimeMillis()
/** 注册监听。 */
fun register() {
resolver.registerContentObserver(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
true,
this,
)
Log.i(TAG, "截图监听已注册")
}
/** 注销监听。 */
fun unregister() {
resolver.unregisterContentObserver(this)
Log.i(TAG, "截图监听已注销")
}
override fun onChange(selfChange: Boolean, uri: Uri?) {
super.onChange(selfChange, uri)
uri ?: return
handler.post { processNewScreenshot(uri) }
}
private fun processNewScreenshot(uri: Uri) {
try {
// 查询截图信息
val projection = arrayOf(
MediaStore.Images.Media.DATA,
MediaStore.Images.Media.DATE_ADDED,
MediaStore.Images.Media.DISPLAY_NAME,
)
resolver.query(uri, projection, null, null, null)?.use { cursor ->
if (!cursor.moveToFirst()) return
val path = cursor.getString(0) ?: ""
val dateAdded = cursor.getLong(1)
val displayName = cursor.getString(2) ?: ""
// 1. 时间窗过滤(30 秒内)
val now = System.currentTimeMillis() / 1000
if (now - dateAdded > TIME_WINDOW_MS / 1000) {
Log.d(TAG, "忽略旧截图(超过30秒): $displayName")
return
}
// 2. 关键词匹配(必须是截图)
if (!SCREENSHOT_KEYWORDS.any { kw -> displayName.contains(kw, true) || path.contains(kw, true) }) {
return
}
// 3. 过滤小米 .pending- 临时文件
if (path.endsWith(".pending-") || path.contains(".pending-")) {
Log.d(TAG, "过滤小米 pending 临时文件: $displayName")
return
}
// 4. 去重
if (processedPaths.contains(path)) return
processedPaths.add(path)
if (processedPaths.size > MAX_PROCESSED) {
val it = processedPaths.iterator()
if (it.hasNext()) {
it.next()
it.remove()
}
}
Log.i(TAG, "检测到新截图: $displayName")
onScreenshot(uri)
}
} catch (e: Exception) {
Log.e(TAG, "处理截图异常: ${e.message}", e)
}
}
}
@@ -0,0 +1,20 @@
package com.beancount.mobile.screenshot
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
/**
* ReactPackage 注册 ScreenshotModule
* Config Plugin withMainApplication 注入 add(ScreenshotPackage()) MainApplication
*/
class ScreenshotPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ScreenshotModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
+106
View File
@@ -0,0 +1,106 @@
/**
* 截图监听 Config Pluginplan.md3.13 截图自动记账通道+决策 4
*
* Android: ContentObserver 监听 MediaStore Screenshots READ_MEDIA_IMAGES 权限
* iOS: AppIntent 自动记账通过 expo 配置原生 Swift 实现
*
* 注意withAndroidManifest modResults 结构是 { manifest: { ... } }
* 操作权限必须通过 modResults.manifest['uses-permission']
*/
const { withAndroidManifest, withDangerousMod, withMainApplication } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
/**
* 无障碍服务伪装目标包名必须与 accessibility plugin 保持一致
* ScreenshotModule import ReactContextHolder该类在 prebuild 时被
* accessibility plugin 整体迁移到此伪装包下故此处需同步改写引用
*/
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
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 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')) {
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
// 必须先改写 accessibility 子包引用(迁移到伪装包),再做通用包名替换,
// 否则通用规则会把 com.beancount.mobile.accessibility 错误地改成 appId.accessibility。
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
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
config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents;
// 2a. 注入 import
content = content.replace(/^import\s+[\w.]+\.ScreenshotPackage\s*$/gm, '');
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.ScreenshotPackage`,
);
// 2b. 在 getPackages() 的 .apply {} 块里注入 add(ScreenshotPackage())
if (!content.includes('add(ScreenshotPackage())')) {
if (/PackageList\(this\)\.packages\.apply\s*\{/.test(content)) {
content = content.replace(
/(PackageList\(this\)\.packages\.apply\s*\{)/,
`$1\n add(ScreenshotPackage())`,
);
} else if (/PackageList\(this\)\.packages\b/.test(content)) {
content = content.replace(
/PackageList\(this\)\.packages\b/,
`PackageList(this).packages.apply { add(ScreenshotPackage()) }`,
);
}
}
modConfig.modResults.contents = content;
return modConfig;
});
// 3. 添加 READ_MEDIA_IMAGES 权限(Android 13+
config = withAndroidManifest(config, (modConfig) => {
const manifest = modConfig.modResults.manifest;
if (!manifest['uses-permission']) {
manifest['uses-permission'] = [];
}
const perms = [
'android.permission.READ_MEDIA_IMAGES',
'android.permission.READ_EXTERNAL_STORAGE',
];
for (const perm of perms) {
const exists = manifest['uses-permission'].some(p => p.$['android:name'] === perm);
if (!exists) {
manifest['uses-permission'].push({ $: { 'android:name': perm } });
}
}
return modConfig;
});
return config;
}
module.exports = withScreenshotMonitor;
+6
View File
@@ -0,0 +1,6 @@
{
"name": "drift-ledger-plugin-screenshot-monitor",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"
}
+152
View File
@@ -0,0 +1,152 @@
const { withDangerousMod, withGradleProperties } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
/**
* gradle.properties modResults 数组中设置某个 key存在则更新不存在则追加
*
* 必须用 withGradleProperties expo 内存 mod 流程而非 withDangerousMod直接改文件
* 否则会被 expo gradleProperties base mod write 阶段用内存快照全量覆盖
* 典型症状reactNativeArchitectures 改了不生效被覆盖回全架构
* minify/shrink 等新 key 反而生效不在 expo 内存快照里未被覆盖
*/
function setGradleProperty(modResults, key, value) {
let found = false;
const updated = modResults.map((prop) => {
if (prop.type === 'property' && prop.key === key) {
found = true;
return { ...prop, value };
}
return prop;
});
if (!found) {
updated.push({ type: 'property', key, value });
}
return updated;
}
function withSizeOptimization(config) {
// 1. 修改 gradle.properties(用 withGradleProperties 避免 base mod write 覆盖)
// - 限制打包架构为 arm64-v8a(加速构建、减小包体)
// - 开启 R8 混淆与资源裁剪
// - 开启 .so 高倍率压缩
config = withGradleProperties(config, (config) => {
let props = config.modResults;
props = setGradleProperty(props, 'reactNativeArchitectures', 'arm64-v8a');
props = setGradleProperty(props, 'android.enableMinifyInReleaseBuilds', 'true');
props = setGradleProperty(props, 'android.enableShrinkResourcesInReleaseBuilds', 'true');
props = setGradleProperty(props, 'expo.useLegacyPackaging', 'true');
config.modResults = props;
return config;
});
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包 + 字体裁剪
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const buildGradlePath = path.join(projectRoot, 'app/build.gradle');
if (fs.existsSync(buildGradlePath)) {
let content = fs.readFileSync(buildGradlePath, 'utf8');
// ABI Splits 分包配置
if (!content.includes('splits {')) {
content = content.replace(
/android\s*\{/,
`android {\n splits {\n abi {\n enable true\n reset()\n include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"\n universalApk true\n }\n }`
);
}
// 3. 裁剪未使用的 @expo/vector-icons 矢量字体
if (!content.includes('variant.mergeAssetsProvider.configure')) {
content += `
android.applicationVariants.all { variant ->
if (variant.buildType.name == "release") {
variant.mergeAssetsProvider.configure {
doLast {
delete fileTree(dir: outputDir, includes: [
"**/fonts/AntDesign.ttf",
"**/fonts/Entypo.ttf",
"**/fonts/EvilIcons.ttf",
"**/fonts/Feather.ttf",
"**/fonts/FontAwesome*.ttf",
"**/fonts/Fontisto.ttf",
"**/fonts/Foundation.ttf",
"**/fonts/MaterialCommunityIcons.ttf",
"**/fonts/MaterialIcons.ttf",
"**/fonts/Octicons.ttf",
"**/fonts/SimpleLineIcons.ttf",
"**/fonts/Zocial.ttf"
])
}
}
}
}
`;
}
fs.writeFileSync(buildGradlePath, content, 'utf8');
}
return modConfig;
}
]);
// 3. 在 prebuild 时修改 proguard-rules.pro 保护原生插件与 ONNX 模块
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const proguardPath = path.join(projectRoot, 'app/proguard-rules.pro');
if (fs.existsSync(proguardPath)) {
let content = fs.readFileSync(proguardPath, 'utf8');
if (!content.includes('com.beancount.mobile')) {
content += `
# Size optimization: keep custom native packages & ONNX
-keep class com.beancount.mobile.** { *; }
-keep class com.example.driftledger.** { *; }
-keep class ai.onnxruntime.** { *; }
`;
fs.writeFileSync(proguardPath, content, 'utf8');
}
}
return modConfig;
}
]);
// 4. 在 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;
}
module.exports = withSizeOptimization;
+4
View File
@@ -0,0 +1,4 @@
{
"name": "drift-ledger-plugin-size-optimization",
"main": "app.plugin.js"
}
@@ -0,0 +1,73 @@
package com.beancount.mobile.sms
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.provider.Telephony
import android.telephony.SmsMessage
import android.util.Log
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.beancount.mobile.accessibility.ReactContextHolder
/**
* 短信监听 Receiverplan.md4.2 短信监听服务+决策 4 Config Plugin
*
* 参考 AutoAccounting SmsReceiver
* - 监听 SMS_RECEIVED_ACTION
* - PDU 解析发送方 + 正文
* - 关键词预过滤银行短信含交易/消费/余额JS 层进一步过滤
* - 通过 DeviceEventEmitter 推送到 JS SmsChannel
*
* 需在 manifest 注册 RECEIVE_SMS 权限 Config Plugin 注入
*/
class BillingSmsReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "BillingSms"
/** 银行短信关键词(预过滤,减少 JS 层负担)。 */
private val BANK_KEYWORDS = listOf("交易", "消费", "收入", "支出", "余额", "转账", "入账", "扣款", "退款")
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
runCatching {
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
for (message in messages) {
val sender = message.displayOriginatingAddress ?: ""
val body = message.displayMessageBody ?: ""
if (sender.isBlank() || body.isBlank()) continue
// 关键词预过滤(仅处理疑似银行短信)
if (!BANK_KEYWORDS.any { body.contains(it) }) continue
Log.d(TAG, "收到银行短信: sender=$sender")
sendSmsEvent(sender, body)
}
}.onFailure {
Log.e(TAG, "短信处理异常: ${it.message}", it)
}
}
/** 把短信事件推送到 JS 层 SmsChannel.handleSms。 */
private fun sendSmsEvent(sender: String, body: String) {
val reactContext = ReactContextHolder.context ?: run {
Log.w(TAG, "RN 上下文未就绪,丢弃短信")
return
}
try {
val map = WritableNativeMap().apply {
putString("sender", sender)
putString("body", body)
putDouble("timestamp", System.currentTimeMillis().toDouble())
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingSms", map)
} catch (e: Exception) {
Log.e(TAG, "推送短信事件失败: ${e.message}")
}
}
}
+101
View File
@@ -0,0 +1,101 @@
/**
* 短信监听 Config Pluginplan.md4.2 短信监听服务+决策 4
*
* expo prebuild 时注册 Android SmsReceivermanifest receiver + RECEIVE_SMS 权限
*
* 注意withAndroidManifest modResults 结构是 { manifest: { ... } }
* 操作权限/application 必须通过 modResults.manifest不是 modResults 本身
*/
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';
}
/**
* 无障碍服务伪装目标包名必须与 accessibility plugin 保持一致
* BillingSmsReceiver import ReactContextHolder该类在 prebuild 时被
* accessibility plugin 整体迁移到此伪装包下故此处需同步改写引用
*/
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
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 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')) {
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
// 必须先改写 accessibility 子包引用(迁移到伪装包),再做通用包名替换,
// 否则通用规则会把 com.beancount.mobile.accessibility 错误地改成 appId.accessibility。
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
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. 注册 receiver + 权限到 AndroidManifest
config = withAndroidManifest(config, (modConfig) => {
const manifest = modConfig.modResults.manifest;
// 1. 添加 RECEIVE_SMS 权限
if (!manifest['uses-permission']) {
manifest['uses-permission'] = [];
}
const perms = ['android.permission.RECEIVE_SMS'];
for (const perm of perms) {
const exists = manifest['uses-permission'].some(p => p.$['android:name'] === perm);
if (!exists) {
manifest['uses-permission'].push({ $: { 'android:name': perm } });
}
}
// 2. 添加短信 Receiver
const receiverNode = {
$: {
'android:name': `${PACKAGE}.BillingSmsReceiver`,
'android:exported': 'true',
},
'intent-filter': [{
action: [{ $: { 'android:name': 'android.provider.Telephony.SMS_RECEIVED' } }],
}],
};
// 确保 application[0] 存在
if (!Array.isArray(manifest.application) || manifest.application.length === 0) {
manifest.application = [{ $: {} }];
}
const app = manifest.application[0];
if (!app.receiver) {
app.receiver = [];
}
const exists = app.receiver.some(
r => r.$['android:name'] === `${PACKAGE}.BillingSmsReceiver`
);
if (!exists) {
app.receiver.push(receiverNode);
}
return modConfig;
});
return config;
}
module.exports = withSmsReceiver;
+6
View File
@@ -0,0 +1,6 @@
{
"name": "drift-ledger-plugin-sms-receiver",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"
}
+106
View File
@@ -0,0 +1,106 @@
/**
* AI plan.md8.1 AI
*
* BeeCount AIChatServiceGLM-4
* -
* - processNaturalLanguage 稿
* - AI
*
* domain/ai.ts processNaturalLanguage + buildNaturalLanguagePrompt
*/
import { CHAT_TRANSACTION_KEYWORDS } from '../domain/core/constants';
import { processNaturalLanguage, removeThink, type AiProvider, type AiBillResult } from '../domain/ai';
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
export interface ChatConversation {
id: string;
messages: ChatMessage[];
createdAt: string;
}
export interface ChatResponse {
type: 'bill_card' | 'text' | 'error';
text?: string;
billCards?: AiBillResult[];
error?: string;
}
const AMOUNT_PATTERN = /\d+(?:\.\d+)?/;
/** 判断是否为记账意图(金额 + 关键词)。 */
export function isTransactionIntent(input: string): boolean {
const hasAmount = AMOUNT_PATTERN.test(input);
const hasKeyword = CHAT_TRANSACTION_KEYWORDS.some(kw => input.includes(kw));
return hasAmount && hasKeyword;
}
import { logger } from '../utils/logger';
/**
*
*/
export async function processChatMessage(
input: string,
provider: AiProvider,
conversation?: ChatConversation,
): Promise<ChatResponse> {
logger.info('aiChat', `收到 AI 助手对话请求 [文本: "${input}"]`);
try {
// 意图判定
if (isTransactionIntent(input)) {
logger.info('aiChat', '检测到记账意图,触发自然语言识别流水线');
const result = await processNaturalLanguage(input, provider);
if (result.type === 'draft') {
logger.info('aiChat', `AI 识别生成交易草稿: [时间: ${result.draft.time}, 金额: ${result.draft.amount} ${result.draft.currency}, 叙述: "${result.draft.narration}"]`);
return { type: 'bill_card', billCards: [result.draft] };
}
// 非 draft 但有意图 → 返回 AI 文本
return { type: 'text', text: result.text };
}
// 自由对话
logger.info('aiChat', '未命中记账模式,发起 AI 自由问答');
const messages = buildChatContext(input, conversation);
const response = await provider.chat(messages);
logger.info('aiChat', 'AI 自由问答回复成功');
return { type: 'text', text: removeThink(response) };
} catch (e) {
logger.error('aiChat', 'AI 对话助手响应失败', e);
return { type: 'error', error: e instanceof Error ? e.message : String(e) };
}
}
/** 构建对话上下文(含历史消息)。 */
function buildChatContext(input: string, conversation?: ChatConversation) {
const systemPrompt = {
role: 'system' as const,
content: '你是一个记账助手。用户描述消费/收入时,帮助解析为交易。其他问题正常回答。回答简洁。',
};
const history = (conversation?.messages ?? []).slice(-10).map(m => ({
role: m.role === 'user' ? 'user' as const : 'assistant' as const,
content: m.content,
}));
return [systemPrompt, ...history, { role: 'user' as const, content: input }];
}
/** 创建新对话。 */
export function createConversation(): ChatConversation {
return {
id: `chat-${Date.now()}`,
messages: [],
createdAt: new Date().toISOString(),
};
}
/** 添加消息到对话。 */
export function appendMessage(conversation: ChatConversation, message: ChatMessage): ChatConversation {
return {
...conversation,
messages: [...conversation.messages, message],
};
}
+103
View File
@@ -0,0 +1,103 @@
/**
* AI plan.md8.3 AI
*
* AutoAccounting SummaryService
* - ///
* - prompt
* - AI
*
* domain/ai.ts buildMonthlySummaryPrompt + annualReport
*/
import { buildMonthlySummaryPrompt, type AiProvider } from '../domain/ai';
import { generateAnnualReport } from '../domain/stats/annualReport';
import { removeThink } from '../domain/ai';
import type { Transaction } from '../domain/core/types';
export interface MonthlyStats {
year: number;
month: number;
totalExpense: string;
totalIncome: string;
transactionCount: number;
topCategories: { category: string; amount: string }[];
}
export interface MonthlySummaryResult {
stats: MonthlyStats;
/** AI 生成的总结文本。 */
summary: string;
}
/** 计算月度统计(纯函数)。 */
export function calculateMonthlyStats(
transactions: Transaction[],
year: number,
month: number,
): MonthlyStats {
const monthStr = `${year}-${String(month).padStart(2, '0')}`;
const monthTx = transactions.filter(t => t.date.startsWith(monthStr));
// 复用年度报告的统计逻辑(过滤到当月)
const report = generateAnnualReport(monthTx, year);
return {
year,
month,
totalExpense: report.totalExpense,
totalIncome: report.totalIncome,
transactionCount: report.transactionCount,
topCategories: report.topCategories.slice(0, 5).map(c => ({ category: c.category, amount: c.amount })),
};
}
import { logger } from '../utils/logger';
/**
* AI
*/
export async function generateMonthlySummary(
transactions: Transaction[],
year: number,
month: number,
provider: AiProvider,
): Promise<MonthlySummaryResult> {
logger.info('aiSummary', `开始生成 AI 月度财务总结 [${year}${month}月]`);
try {
const stats = calculateMonthlyStats(transactions, year, month);
const messages = buildMonthlySummaryPrompt({
totalExpense: stats.totalExpense,
totalIncome: stats.totalIncome,
transactionCount: stats.transactionCount,
topCategories: stats.topCategories,
});
const response = await provider.chat(messages);
logger.info('aiSummary', `AI 月度财务总结生成成功 [${year}${month}月]`);
return {
stats,
summary: removeThink(response),
};
} catch (e) {
logger.error('aiSummary', `生成 AI 月度总结失败 [${year}${month}月]`, e);
throw e;
}
}
/** 无 AI 的纯文本总结(降级,不调 AI)。 */
export function generatePlainTextSummary(stats: MonthlyStats): string {
const lines: string[] = [];
lines.push(`${stats.year}${stats.month}月财务总结`);
lines.push('');
lines.push(`总收入:${stats.totalIncome}`);
lines.push(`总支出:${stats.totalExpense}`);
lines.push(`交易笔数:${stats.transactionCount}`);
lines.push('');
if (stats.topCategories.length > 0) {
lines.push('主要支出分类:');
for (const cat of stats.topCategories) {
lines.push(` ${cat.category}: ${cat.amount}`);
}
}
return lines.join('\n');
}
+186
View File
@@ -0,0 +1,186 @@
/**
* plan.md8.2
*
* BeeCount VoiceBillingHelper /
*
* / expo-av + AI Provider audio/transcriptions
* mock processNaturalLanguage 稿
*/
import { processNaturalLanguage, type AiProvider, type AiBillResult } from '../domain/ai';
import { logger } from '../utils/logger';
/** 录音器抽象(生产用 expo-av,测试用 mock)。 */
export interface AudioRecorder {
/** 请求录音权限,返回是否授权。 */
requestPermission(): Promise<boolean>;
/** 开始录音,返回录音句柄。 */
start(): Promise<RecordingHandle>;
}
export interface RecordingHandle {
/** 停止录音,返回音频 URI。 */
stop(): Promise<string>;
}
/** 语音转文字抽象(生产用 AI Provider 的 audio/transcriptions,测试用 mock)。 */
export interface SpeechToText {
transcribe(audioUri: string): Promise<string>;
}
export type VoiceTriggerMode = 'auto-detect' | 'press-to-talk';
export interface VoiceInputConfig {
mode: VoiceTriggerMode;
/** 自动检测模式的静音超时(毫秒,默认 2000)。 */
silenceTimeoutMs: number;
/** 最大录音时长(毫秒,默认 60000)。 */
maxDurationMs: number;
/** 振幅阈值(自动检测开始说话,0-1,默认 0.58 ≈ -25dB)。 */
amplitudeThreshold: number;
}
export const DEFAULT_VOICE_CONFIG: VoiceInputConfig = {
mode: 'press-to-talk',
silenceTimeoutMs: 2000,
maxDurationMs: 60_000,
amplitudeThreshold: 0.58,
};
export interface VoiceBillingResult {
/** 转写的文字。 */
transcription: string;
/** 解析结果(draft 表示识别为交易,text 表示自由文本)。 */
parsed: { type: 'draft'; draft: AiBillResult } | { type: 'text'; text: string } | null;
}
/**
*
*
*
* const voice = new VoiceInput(recorder, stt, aiProvider);
* const result = await voice.processPressToTalk();
*/
export class VoiceInput {
private currentRecording: RecordingHandle | null = null;
constructor(
private readonly recorder: AudioRecorder,
private readonly stt: SpeechToText,
private readonly aiProvider: AiProvider,
private readonly config: VoiceInputConfig = DEFAULT_VOICE_CONFIG,
) {}
/**
*
* UI start/stop
*/
async startRecording(): Promise<void> {
const granted = await this.recorder.requestPermission();
if (!granted) throw new Error('需要录音权限');
this.currentRecording = await this.recorder.start();
logger.info('voice', '开始录音');
}
/** 停止录音并处理。 */
async stopAndProcess(): Promise<VoiceBillingResult> {
if (!this.currentRecording) throw new Error('未在录音中');
const audioUri = await this.currentRecording.stop();
this.currentRecording = null;
logger.info('voice', `录音完成: ${audioUri}`);
const transcription = await this.stt.transcribe(audioUri);
logger.info('voice', `转写结果: ${transcription}`);
if (!transcription.trim()) {
return { transcription: '', parsed: null };
}
const parsed = await processNaturalLanguage(transcription, this.aiProvider);
return { transcription, parsed };
}
/**
*
* start/stop +
*/
async processAutoDetect(
getAmplitude: () => number,
): Promise<VoiceBillingResult> {
await this.startRecording();
// 等待开始说话(振幅超阈值)
const startDeadline = Date.now() + this.config.maxDurationMs;
let speakingFrames = 0;
while (Date.now() < startDeadline) {
const amp = getAmplitude();
if (amp >= this.config.amplitudeThreshold) {
speakingFrames++;
if (speakingFrames >= 5) break; // 连续 5 帧判定开始说话
} else {
speakingFrames = 0;
}
await sleep(100);
}
// 等待静音(振幅低于阈值超过 silenceTimeoutMs
let lastLoudTime = Date.now();
const stopDeadline = Date.now() + this.config.maxDurationMs;
while (Date.now() < stopDeadline) {
const amp = getAmplitude();
if (amp >= this.config.amplitudeThreshold) {
lastLoudTime = Date.now();
} else if (Date.now() - lastLoudTime > this.config.silenceTimeoutMs) {
break; // 静音超时,停止
}
await sleep(100);
}
return this.stopAndProcess();
}
/** 取消当前录音。 */
cancel(): void {
this.currentRecording = null;
logger.info('voice', '录音已取消');
}
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** 模拟录音器(测试用)。 */
export class MockAudioRecorder implements AudioRecorder {
public permissionGranted = true;
public startCount = 0;
private stopHandler: (() => string) | null = null;
requestPermission(): Promise<boolean> {
return Promise.resolve(this.permissionGranted);
}
async start(): Promise<RecordingHandle> {
this.startCount++;
return {
stop: async () => {
return this.stopHandler?.() ?? 'mock://audio.wav';
},
};
}
/** 测试辅助:设置 stop 返回的 URI。 */
setStopResult(uri: string): void {
this.stopHandler = () => uri;
}
}
/** 模拟语音转文字(测试用)。 */
export class MockSpeechToText implements SpeechToText {
constructor(private response: string = '') {}
async transcribe(): Promise<string> {
return this.response;
}
/** 测试辅助。 */
setResponse(text: string): void {
this.response = text;
}
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import { Tabs } from 'expo-router';
import { useT } from '../../i18n';
import { AppTabBar } from '../../components/layout/AppTabBar';
/** 底部 Tab 导航:首页/交易/报表/设置 + 中央+(AppTabBar)。 */
export default function TabsLayout() {
const t = useT();
return (
<Tabs
screenOptions={{ headerShown: false }}
tabBar={(props) => <AppTabBar {...props} />}
>
<Tabs.Screen
name="index"
options={{ title: t('tab.home') }}
/>
<Tabs.Screen
name="transactions"
options={{ title: t('tab.transactions') }}
/>
<Tabs.Screen
name="report"
options={{ title: t('tab.report') }}
/>
<Tabs.Screen
name="settings"
options={{ title: t('tab.settings') }}
/>
</Tabs>
);
}
+200
View File
@@ -0,0 +1,200 @@
/**
* spec §7.1
* header / 5
* /account
*/
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/ui/Card';
import { TransactionCard } from '../../components/transaction/TransactionCard';
import { TodoStrip } from '../../components/stats/TodoStrip';
import { TrendLine } from '../../components/charts/TrendLine';
import { calculateNetWorth } from '../../domain/stats/netWorth';
import { groupByMonth } from '../../domain/stats/chartStats';
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/finance/recurring';
import { calculateBudgetProgress } from '../../domain/finance/budgets';
import { addDecimals, toDateString } from '../../domain/core/decimal';
import type { Transaction } from '../../domain/core/types';
import { EmptyState } from '../../components/ui/EmptyState';
import { useNumpadUiStore } from '../../store/numpadUiStore';
export default function HomeScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction);
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
const budgets = useMetadataStore(s => s.budgets);
const categories = useMetadataStore(s => s.categories);
const locale = useSettingsStore(s => s.locale);
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
const netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]);
const monthlyData = useMemo(() => groupByMonth(transactions), [transactions]);
const currentMonth = useMemo(() => monthlyData.length ? monthlyData[monthlyData.length - 1] : null, [monthlyData]);
const todayStr = useMemo(() => toDateString(new Date()), []);
const dueRecurring = useMemo(() => getDueRecurring(recurringTransactions, todayStr), [recurringTransactions, todayStr]);
// 周期记账自动触发:启动时自动确认标记了 autoConfirm 的到期项
const autoConfirmed = useRef(false);
useEffect(() => {
let isMounted = true;
if (autoConfirmed.current) return;
const autoDue = dueRecurring.filter(r => r.autoConfirm);
if (autoDue.length === 0) return;
autoConfirmed.current = true;
(async () => {
for (const rec of autoDue) {
if (!isMounted) break;
try {
const draft = instantiateRecurring(rec);
await addTransaction(draft);
const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate);
updateRecurringTransaction(rec.id, { nextDueDate });
} catch {
// 静默失败,用户可在周期管理页手动处理
}
}
})();
return () => { isMounted = false; };
}, [dueRecurring, addTransaction, updateRecurringTransaction]);
// 月度预算剩余合计(无月度预算则不显示)
const budgetRemaining = useMemo(() => {
const monthlyBudgets = budgets.filter(b => b.period === 'monthly');
if (monthlyBudgets.length === 0) return null;
const remainings = monthlyBudgets.map(b => calculateBudgetProgress(b, transactions, todayStr).remaining);
return remainings.reduce((a, b) => addDecimals([a, b]), '0');
}, [budgets, transactions, todayStr]);
// 最近 5 条(按日期+序号倒序)
const recentTxs = useMemo(() =>
transactions.map((tx, idx) => ({ tx, idx }))
.sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
.slice(0, 5)
.map(item => item.tx),
[transactions]);
// 分类匹配(TransactionCard 图标)
const categoryIdFor = useCallback((tx: Transaction): string | undefined => {
const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
}, [categories]);
// 问候语 + 日期行
const hour = new Date().getHours();
const greeting = hour < 12 ? t('home.greetingMorning') : hour < 18 ? t('home.greetingAfternoon') : t('home.greetingEvening');
const localeTag = locale === 'zh' ? 'zh-CN' : 'en-US';
const dateLine = new Date().toLocaleDateString(localeTag, { month: 'long', day: 'numeric', weekday: 'long' });
// 千分位格式化金额
const fmtAmount = (s: string) => {
if (!s || s === '0') return '¥0.00';
const num = parseFloat(s);
if (isNaN(num)) return s;
const formatted = num.toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return num >= 0 ? `¥${formatted}` : `${Math.abs(num).toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{greeting}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>{dateLine}</Text>
</View>
<FlatList
data={recentTxs}
keyExtractor={(tx) => tx.id}
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
ListHeaderComponent={
<View style={{ gap: theme.spacing.md }}>
{/* 净资产白卡 */}
<Card>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorthTitle')}</Text>
<Text style={[theme.typography.display, { color: theme.colors.fgPrimary, marginVertical: 6, fontVariant: ['tabular-nums'] }]}>
{fmtAmount(netWorth.netWorth)}
</Text>
<View style={[styles.heroFooter, { borderTopColor: theme.colors.divider }]}>
<View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthExpense')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
-{fmtAmount(currentMonth?.expense.toString() ?? '0')}
</Text>
</View>
<View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthIncome')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
+{fmtAmount(currentMonth?.income.toString() ?? '0')}
</Text>
</View>
{budgetRemaining !== null && (
<View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.budgetRemaining')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
{fmtAmount(budgetRemaining)}
</Text>
</View>
)}
</View>
</Card>
<TodoStrip />
{/* 月度趋势 */}
{monthlyData.length > 0 && (
<Card title={t('home.monthlyTrend')}>
<TrendLine transactions={transactions} />
</Card>
)}
{/* 最近交易标题 */}
{recentTxs.length > 0 && (
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{t('home.recentTransactions')}</Text>
)}
</View>
}
renderItem={({ item: tx }) => (
<TransactionCard key={tx.id} transaction={tx} categoryId={categoryIdFor(tx)} onPress={() => router.push(`/transaction/${tx.id}`)} />
)}
ListFooterComponent={
<>
{recentTxs.length > 0 && (
<Pressable onPress={() => router.push('/(tabs)/transactions')} style={styles.viewAll} accessibilityRole="button" accessibilityLabel={t('home.viewAll')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>{t('home.viewAll')} </Text>
</Pressable>
)}
{/* 无交易时的记账引导 */}
{transactions.length === 0 && (
<EmptyState
icon="add-circle-outline"
title={t('home.recentEmpty')}
actionLabel={t('home.newTransaction')}
onAction={() => useNumpadUiStore.getState().open()}
/>
)}
</>
}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 40 },
heroFooter: { flexDirection: 'row', gap: 16, borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10, marginTop: 4 },
heroStat: { gap: 2 },
viewAll: { alignItems: 'center', paddingVertical: 6 },
});
+323
View File
@@ -0,0 +1,323 @@
/**
* plan.md5.2 +5.3 P4 spec §7.3
*
*
* - anchor + //periodNav
* - ////
* - AI react-native-view-shot
*/
import React, { useMemo, useRef, useState } from 'react';
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { captureRef } from 'react-native-view-shot';
import * as Sharing from 'expo-sharing';
import { useLedgerStore } from '../../store/ledgerStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/ui/Card';
import { Button } from '../../components/ui/Button';
import { BottomSheet } from '../../components/ui/BottomSheet';
import { CategoryPie } from '../../components/charts/CategoryPie';
import { CalendarView } from '../../components/stats/CalendarView';
import { TransactionCard } from '../../components/transaction/TransactionCard';
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, toDateString } from '../../domain/core/decimal';
import { periodRange, shiftAnchor, isoWeekNumber, type ReportPeriod } from '../../domain/stats/periodNav';
import { SegmentedControl } from '../../components/ui/SegmentedControl';
import { EmptyState } from '../../components/ui/EmptyState';
import { PeriodSwitcher } from '../../components/stats/PeriodSwitcher';
import { RangeStatsCard } from '../../components/stats/RangeStatsCard';
export default function ReportScreen() {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
// 动态样式:使用主题 token 替代硬编码值
const dynamicStyles = useMemo(() => ({
monthBtn: { borderRadius: theme.radii.lg, borderColor: theme.colors.border },
iconBtn: { padding: theme.spacing.xs },
}), [theme]);
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const aiEnabled = useSettingsStore(s => s.aiEnabled);
const aiApiKey = useSettingsStore(s => s.aiApiKey);
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl);
const aiModel = useSettingsStore(s => s.aiModel);
const locale = useSettingsStore(s => s.locale);
// 周期 + 单一 anchor 日期(P4:替代旧版年/月/周三套独立切换状态)
const [period, setPeriod] = useState<ReportPeriod>('monthly');
const [anchor, setAnchor] = useState(() => toDateString(new Date()));
// ⋯ 菜单
const [menuOpen, setMenuOpen] = useState(false);
// 日历选中日期用于明细展开
const [selectedReportDate, setSelectedReportDate] = useState<string | null>(null);
// AI 总结弹窗
const [summaryModal, setSummaryModal] = useState(false);
const [summaryText, setSummaryText] = useState('');
// 截图 ref
const scrollRef = useRef<ScrollView>(null);
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
// anchor 所在周期的起止日期与周期内交易
const range = useMemo(() => periodRange(anchor, period), [anchor, period]);
const rangeTxs = useMemo(
() => transactions.filter(tx => tx.date >= range.start && tx.date <= range.end),
[transactions, range],
);
// 周期收支统计(Income 腿取负、Expenses 腿原值,与旧 weeklyStats/monthlyStats 同算法)
const rangeStats = useMemo(() => {
const incomeAmounts: string[] = [];
const expenseAmounts: string[] = [];
for (const tx of rangeTxs) {
for (const p of tx.postings) {
if (!p.amount) continue;
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: rangeTxs.length };
}, [rangeTxs]);
// 周期切换器 label
const periodLabel = useMemo(() => {
if (period === 'weekly') {
const monday = range.start;
const sunday = range.end;
return `${anchor.slice(0, 4)}年第${isoWeekNumber(anchor)}周 (${Number(monday.slice(5, 7))}/${Number(monday.slice(8))} ~ ${Number(sunday.slice(5, 7))}/${Number(sunday.slice(8))})`;
}
if (period === 'monthly') return anchor.slice(0, 7);
return `${anchor.slice(0, 4)}`;
}, [period, anchor, range]);
// 净资产趋势的日期序列(基于 anchor 所在月,往前 6 个月)
const netWorthDates = useMemo(() => {
const dates: string[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(Number(anchor.slice(0, 4)), Number(anchor.slice(5, 7)) - 1 - i, 1);
dates.push(toDateString(d));
}
return dates;
}, [anchor]);
const shift = (delta: number) => {
setSelectedReportDate(null);
setAnchor(a => shiftAnchor(a, period, delta));
};
// 选中日期的交易明细
const selectedDayTxs = useMemo(() => {
if (!selectedReportDate) return [];
return rangeTxs.filter(tx => tx.date.slice(0, 10) === selectedReportDate);
}, [rangeTxs, selectedReportDate]);
// 千分位格式化金额
const localeTag = locale === 'zh' ? 'zh-CN' : 'en-US';
const fmtAmount = (num: number) => {
const formatted = num.toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return num >= 0 ? `¥${formatted}` : `${Math.abs(num).toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
// 导出报表为图片
const handleExportImage = async () => {
try {
if (!scrollRef.current) return;
const uri = await captureRef(scrollRef, { format: 'png', quality: 0.9 });
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(uri, { mimeType: 'image/png', dialogTitle: t('report.shareTitle') });
} else {
Alert.alert(t('report.exportSuccess'), uri);
}
} catch (e) {
Alert.alert(t('report.exportFail'), String(e));
}
};
// AI / 纯文本月度总结(基于 anchor 所在月)
const handleMonthlySummary = async () => {
const year = Number(anchor.slice(0, 4));
const month = Number(anchor.slice(5, 7));
const stats = calculateMonthlyStats(transactions, year, month);
if (aiEnabled && aiApiKey && aiBaseUrl) {
try {
const provider = new (class extends BaseOpenAIProvider {})({
id: 'report-summary',
name: 'Report Summary',
apiKey: aiApiKey,
baseUrl: aiBaseUrl,
model: aiModel || 'glm-4-flash',
});
const result = await generateMonthlySummary(transactions, year, month, provider);
setSummaryText(result.summary || generatePlainTextSummary(stats));
} catch {
setSummaryText(generatePlainTextSummary(stats));
}
} else {
setSummaryText(generatePlainTextSummary(stats));
}
setSummaryModal(true);
};
const monthLabel = anchor.slice(0, 7);
const anchorYear = Number(anchor.slice(0, 4));
const anchorMonth = Number(anchor.slice(5, 7));
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.report')}</Text>
<Pressable
onPress={() => setMenuOpen(true)}
style={({ pressed }) => [styles.iconBtn, dynamicStyles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}
accessibilityRole="button"
accessibilityLabel={t('report.menu')}
>
<Ionicons name="ellipsis-horizontal" size={22} color={theme.colors.accent} />
</Pressable>
</View>
{/* Tab 选择切换栏(统一分段选择器) */}
<SegmentedControl
options={[
{ key: 'weekly', label: t('report.tabWeekly') },
{ key: 'monthly', label: t('report.tabMonthly') },
{ key: 'annual', label: t('report.tabAnnual') },
]}
value={period}
onChange={key => {
setPeriod(key as ReportPeriod);
setSelectedReportDate(null);
}}
/>
{/* 统一周期切换器 */}
<PeriodSwitcher label={periodLabel} onPrev={() => shift(-1)} onNext={() => shift(1)} />
<ScrollView
ref={scrollRef}
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
>
{/* 数据空值判断 */}
{rangeTxs.length === 0 ? (
<EmptyState icon="bar-chart-outline" title={t('report.title')} description={t('report.empty')} />
) : (
<>
{/* 周/月收支看板 */}
{period !== 'annual' && (
<>
<RangeStatsCard
period={period === 'weekly' ? 'weekly' : 'monthly'}
incomeText={fmtAmount(rangeStats.income)}
expenseText={fmtAmount(-rangeStats.expense)}
netText={fmtAmount(rangeStats.net)}
count={rangeStats.count}
/>
<CategoryPie transactions={rangeTxs} />
{/* 月 Tab 专属:日历 + 选中日期明细 + 净资产趋势 */}
{period === 'monthly' && (
<>
<CalendarView
transactions={transactions}
year={anchorYear}
month={anchorMonth}
onDayPress={setSelectedReportDate}
/>
{selectedReportDate && selectedReportDate.startsWith(monthLabel) && (
<Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}>
{selectedDayTxs.map(tx => (
<TransactionCard
key={tx.id}
transaction={tx}
onPress={() => router.push(`/transaction/${tx.id}`)}
/>
))}
{selectedDayTxs.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 8 }]}>
{t('calendar.noDayTx')}
</Text>
)}
</Card>
)}
<NetWorthChart transactions={transactions} dates={netWorthDates} />
</>
)}
</>
)}
{/* 年度报表看板 */}
{period === 'annual' && (
<AnnualReport transactions={transactions} year={anchorYear} />
)}
</>
)}
</ScrollView>
{/* ⋯ 菜单 */}
<BottomSheet visible={menuOpen} onClose={() => setMenuOpen(false)} title={t('report.menu')}>
<Pressable
style={({ pressed }) => [styles.menuRow, { opacity: pressed ? 0.6 : 1 }]}
onPress={() => { setMenuOpen(false); handleMonthlySummary(); }}
accessibilityRole="button"
accessibilityLabel={t('report.aiSummary')}
>
<Ionicons name="sparkles-outline" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{t('report.aiSummary')}</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.menuRow, { opacity: pressed ? 0.6 : 1 }]}
onPress={() => { setMenuOpen(false); handleExportImage(); }}
accessibilityRole="button"
accessibilityLabel={t('report.exportImage')}
>
<Ionicons name="share-outline" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{t('report.exportImage')}</Text>
</Pressable>
</BottomSheet>
{/* AI 总结弹窗 */}
<Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}>
<View style={commonStyles.modalOverlay}>
<View style={commonStyles.modalCard}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, marginBottom: 12 }]}>
{monthLabel} {t('report.aiSummary')}
</Text>
<ScrollView style={{ maxHeight: 400 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, lineHeight: 22 }]}>
{summaryText}
</Text>
</ScrollView>
<View style={{ marginTop: 16 }}>
<Button label={t('common.confirm')} onPress={() => setSummaryModal(false)} />
</View>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
iconBtn: {},
content: { padding: 16, paddingBottom: 96 },
menuRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14 },
});
+168
View File
@@ -0,0 +1,168 @@
import React, { useMemo } from 'react';
import { SectionList, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, type Href } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Touchable } from '../../components/ui/Touchable';
interface NavItem {
icon: keyof typeof Ionicons.glyphMap;
label: string;
path: Href;
}
interface NavSection {
title: string;
data: NavItem[];
}
export default function SettingsScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
// 使用 useMemo 缓存分组数据,避免每次渲染重新创建
const sections: NavSection[] = useMemo(() => [
{
title: t('settings.groupAccount'),
data: [
{ icon: 'wallet-outline', label: t('account.title'), path: '/account' },
{ icon: 'list', label: t('settings.categories'), path: '/category' },
{ icon: 'pricetags', label: t('settings.tags'), path: '/tag' },
{ icon: 'pie-chart', label: t('settings.budgets'), path: '/budget' },
{ icon: 'card-outline', label: t('settings.creditCards'), path: '/credit-card' as Href },
{ icon: 'text-outline', label: t('remark.title'), path: '/remark-template' as Href },
],
},
{
title: t('settings.groupAutomation'),
data: [
{ icon: 'git-branch-outline', label: t('tab.rules'), path: '/rules' },
{ icon: 'repeat-outline', label: t('settings.recurringTitle'), path: '/recurring' },
{ icon: 'flash-outline', label: t('automation.title'), path: '/automation' as Href },
{ icon: 'download-outline', label: t('settings.import'), path: '/import' as Href },
],
},
{
title: t('settings.groupData'),
data: [
{ icon: 'cloud-upload-outline', label: t('settings.syncBackup'), path: '/settings/sync' },
{ icon: 'pulse-outline', label: t('settings.diagnostics'), path: '/settings/diagnostics' as Href },
{ icon: 'document-text-outline', label: t('settings.appLogs'), path: '/settings/logs' as Href },
],
},
{
title: t('settings.groupPreferences'),
data: [
{ icon: 'phone-portrait-outline', label: t('settings.preferencesEntry'), path: '/settings/preferences' },
{ icon: 'cog-outline', label: t('settings.llmTitle'), path: '/settings/ai' },
{ icon: 'chatbubble-ellipses-outline', label: t('ai.chatTitle'), path: '/ai/chat' as Href },
],
},
], [t]);
const renderSectionHeader = ({ section }: { section: NavSection }) => (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontWeight: '600', letterSpacing: 0.3, marginBottom: 6, marginTop: theme.spacing.md }]}>
{section.title}
</Text>
);
const renderItem = ({ item }: { item: NavItem }) => {
const section = sections.find(s => s.data.includes(item));
const isLast = section ? section.data[section.data.length - 1] === item : false;
const isFirst = section ? section.data[0] === item : false;
return (
<Touchable
onPress={() => router.push(item.path)}
accessibilityRole="link"
accessibilityLabel={item.label}
style={[
styles.navRow,
{
borderBottomColor: theme.colors.divider,
borderBottomWidth: isLast ? 0 : StyleSheet.hairlineWidth,
backgroundColor: theme.colors.bgSecondary,
// 分组首行圆角顶 + 末行圆角底,配合 overflow hidden 形成圆角卡片组
borderTopLeftRadius: isFirst ? theme.radii.lg : 0,
borderTopRightRadius: isFirst ? theme.radii.lg : 0,
borderBottomLeftRadius: isLast ? theme.radii.lg : 0,
borderBottomRightRadius: isLast ? theme.radii.lg : 0,
},
]}
>
<View style={styles.navIcon}>
<Ionicons name={item.icon} size={20} color={theme.colors.accent} />
</View>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{item.label}
</Text>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
</Touchable>
);
};
const AboutFooter = () => (
<View style={{ marginTop: theme.spacing.md }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontWeight: '600', letterSpacing: 0.3, marginBottom: 6 }]}>
{t('settings.aboutTitle')}
</Text>
<View style={[styles.aboutCard, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, borderColor: theme.colors.border }]}>
<View style={styles.aboutRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{t('app.name')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{t('settings.aboutVersion')}
</Text>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, lineHeight: 18 }]}>
{t('app.tagline')}
</Text>
</View>
</View>
);
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.settings')}</Text>
</View>
<SectionList
sections={sections}
keyExtractor={(item) => item.label}
renderSectionHeader={renderSectionHeader}
renderItem={renderItem}
ListFooterComponent={AboutFooter}
stickySectionHeadersEnabled={false}
contentContainerStyle={styles.content}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { paddingHorizontal: 16, paddingBottom: 64 },
navRow: {
flexDirection: 'row',
alignItems: 'center',
// 行内自带左右 padding,图标与文字不再贴边
paddingHorizontal: 16,
paddingVertical: 14,
overflow: 'hidden',
},
navIcon: {
width: 32,
height: 32,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
},
aboutCard: { padding: 16, borderWidth: StyleSheet.hairlineWidth },
aboutRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
});
+232
View File
@@ -0,0 +1,232 @@
/**
* spec §7.2 + 线
* chip FilterSheet//
* // + /
* /settings/diagnostics
*/
import React, { useCallback, useMemo, useState } from 'react';
import { Pressable, SectionList, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { SearchBar } from '../../components/ui/SearchBar';
import { FilterSheet, type AdvancedFilterValues } from '../../components/form/FilterSheet';
import { SwipeableTransactionCard } from '../../components/transaction/SwipeableTransactionCard';
import { groupTransactionsByDate } from '../../components/transaction/transactionGroups';
import { useSearch, type SearchFilters } from '../../hooks/useSearch';
import { toDateString } from '../../domain/core/decimal';
import type { Transaction } from '../../domain/core/types';
import { EmptyState } from '../../components/ui/EmptyState';
import { useToast } from '../../components/ui/Toast';
type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';
const EMPTY_FILTERS: AdvancedFilterValues = { account: '', dateFrom: '', dateTo: '', amountMin: '', amountMax: '' };
export default function TransactionsScreen() {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const { showToast } = useToast();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const deleteTransaction = useLedgerStore(s => s.deleteTransaction);
const restoreTransaction = useLedgerStore(s => s.restoreTransaction);
const categories = useMetadataStore(s => s.categories);
const recentSearches = useSettingsStore(s => s.recentSearches);
const addRecentSearch = useSettingsStore(s => s.addRecentSearch);
const clearRecentSearches = useSettingsStore(s => s.clearRecentSearches);
const [keyword, setKeyword] = useState('');
const [direction, setDirection] = useState<DirectionFilter>('all');
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
const [advanced, setAdvanced] = useState<AdvancedFilterValues>(EMPTY_FILTERS);
// 性能说明:每次 ledger 变化时全量重排序 O(n log n)。
// 当前可接受:移动端账本通常 < 5000 笔,排序耗时 < 10ms。
// 若未来数据量增大,可考虑:
// 1. 在 ledgerStore 层维护已排序的 transactions 数组
// 2. 新增交易时使用二分插入而非全量重排
const allTransactions = useMemo(() => {
if (!ledger?.transactions) return [];
return ledger.transactions
.map((tx, idx) => ({ tx, idx }))
.sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
.map(item => item.tx);
}, [ledger]);
const filters: SearchFilters = useMemo(() => ({
keyword: keyword || undefined,
direction: direction === 'all' ? undefined : direction,
account: advanced.account || undefined,
dateFrom: advanced.dateFrom || undefined,
dateTo: advanced.dateTo || undefined,
amountMin: advanced.amountMin || undefined,
amountMax: advanced.amountMax || undefined,
}), [keyword, direction, advanced]);
const filtered = useSearch(allTransactions, filters);
const sections = useMemo(() =>
groupTransactionsByDate(filtered).map(g => ({ ...g, data: g.items })),
[filtered]);
const advancedActive = advanced.account !== '' || advanced.dateFrom !== '' || advanced.dateTo !== '' || advanced.amountMin !== '' || advanced.amountMax !== '';
const todayStr = toDateString(new Date());
const yesterdayStr = toDateString(new Date(Date.now() - 86400000));
const dateLabel = (date: string) =>
date === todayStr ? t('transactions.today') : date === yesterdayStr ? t('transactions.yesterday') : date;
const categoryIdFor = useCallback((tx: Transaction): string | undefined => {
const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
}, [categories]);
// 左滑:复制(预填今天的录入页)
const handleDuplicate = (tx: Transaction) => {
const draftJson = JSON.stringify({
date: todayStr,
payee: tx.payee,
narration: tx.narration,
tags: tx.tags,
// cost/price 不带:复制是新建交易,丢 cost 属可接受简化(含 cost 的交易可从编辑入口进高级模式)
postings: tx.postings.map(p => ({ account: p.account, amount: p.amount, currency: p.currency })),
});
useNumpadUiStore.getState().open({ draftJson });
};
// 左滑:删除(确认后从 main.bean 移除)
// 删除:非阻断 toast + 撤销(取代阻断式 Alert 确认)
const handleDelete = (tx: Transaction) => {
const name = tx.narration || tx.payee || tx.date.slice(0, 10);
deleteTransaction(tx.raw)
.then(() => {
showToast(t('transactions.deleted', { name }), 'success', {
label: t('common.undo'),
onPress: () => {
restoreTransaction(tx.raw).catch(e => showToast(String(e), 'error'));
},
});
})
.catch(e => showToast(String(e), 'error'));
};
const directionTabs: { key: DirectionFilter; label: string }[] = [
{ key: 'all', label: t('transactions.filterAll') },
{ key: 'expense', label: t('transactions.filterExpense') },
{ key: 'income', label: t('transactions.filterIncome') },
{ key: 'transfer', label: t('transactions.filterTransfer') },
];
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.transactions')}</Text>
</View>
<SearchBar value={keyword} onChangeText={setKeyword} placeholder={t('transactions.searchPlaceholder')} onSubmit={() => addRecentSearch(keyword)} />
{keyword.trim() === '' && recentSearches.length > 0 && (
<View style={[{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 8, paddingHorizontal: 16, marginBottom: 8 }]}>
<Ionicons name="time-outline" size={14} color={theme.colors.fgSecondary} />
{recentSearches.map(s => (
<Pressable key={s} onPress={() => setKeyword(s)} style={commonStyles.chip}>
<Text style={commonStyles.chipText}>{s}</Text>
</Pressable>
))}
<Pressable onPress={() => clearRecentSearches()} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('transactions.clearHistory')}>
<Ionicons name="close-circle-outline" size={16} color={theme.colors.fgSecondary} />
</Pressable>
</View>
)}
{/* 方向筛选 + 高级筛选入口 */}
<View style={styles.filterRow}>
{directionTabs.map(tab => (
<Pressable key={tab.key} onPress={() => setDirection(tab.key)}
style={[commonStyles.chip, direction === tab.key && commonStyles.chipActive]}>
<Text style={[commonStyles.chipText, direction === tab.key && commonStyles.chipTextActive]}>{tab.label}</Text>
</Pressable>
))}
<View style={styles.flex1} />
<Pressable
onPress={() => setFilterSheetOpen(true)}
style={[commonStyles.chip, advancedActive && commonStyles.chipActive, styles.filterBtn]}
accessibilityRole="button"
accessibilityLabel={t('transactions.filter')}
>
<Ionicons name="options-outline" size={14} color={advancedActive ? theme.colors.fgInverse : theme.colors.fgSecondary} />
<Text style={[commonStyles.chipText, advancedActive && commonStyles.chipTextActive]}>{t('transactions.filter')}</Text>
</Pressable>
</View>
<SectionList
sections={sections}
keyExtractor={(item, index) => `${item.id}-${index}`}
renderItem={({ item: tx }) => (
<SwipeableTransactionCard
transaction={tx}
categoryId={categoryIdFor(tx)}
showDate={false}
highlight={keyword}
onPress={() => router.push(`/transaction/${tx.id}`)}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
/>
)}
renderSectionHeader={({ section }) => (
<View style={styles.sectionHeader}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{dateLabel(section.date)}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
{t('transactions.daySummary', { income: `+${section.income}`, expense: `-${section.expense}` })}
</Text>
</View>
)}
ListHeaderComponent={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingBottom: 8 }]}>
{filtered.length === allTransactions.length
? t('transactions.countTotal', { count: filtered.length })
: t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })}
</Text>
}
ListEmptyComponent={
<EmptyState
icon={allTransactions.length === 0 ? 'receipt-outline' : 'search-outline'}
title={t('transactions.noMatchTitle')}
description={allTransactions.length === 0 ? t('transactions.noMatchEmpty') : t('transactions.noMatchFiltered')}
/>
}
contentContainerStyle={styles.content}
stickySectionHeadersEnabled={false}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
/>
<FilterSheet
visible={filterSheetOpen}
onClose={() => setFilterSheetOpen(false)}
values={advanced}
onChange={setAdvanced}
onReset={() => setAdvanced(EMPTY_FILTERS)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, paddingBottom: 8, alignItems: 'center' },
filterBtn: { flexDirection: 'row', alignItems: 'center', gap: 4 },
flex1: { flex: 1 },
content: { padding: 16, paddingTop: 8 },
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8 },
});
+121
View File
@@ -0,0 +1,121 @@
import React from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Share, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { buildErrorRecoveryData, crashReporter } from '../services/crash';
import * as FileSystem from 'expo-file-system/legacy';
interface ErrorBoundaryProps {
error: Error;
resetError: () => void;
}
export function ErrorScreen({ error, resetError }: ErrorBoundaryProps) {
const { theme } = useTheme();
const t = useT();
const data = buildErrorRecoveryData(error);
const clearCacheAndRestart = async () => {
try {
const settingsFile = FileSystem.documentDirectory + 'settings.json';
const metadataFile = FileSystem.documentDirectory + 'metadata.json';
const settingsInfo = await FileSystem.getInfoAsync(settingsFile);
if (settingsInfo.exists) await FileSystem.deleteAsync(settingsFile);
const metadataInfo = await FileSystem.getInfoAsync(metadataFile);
if (metadataInfo.exists) await FileSystem.deleteAsync(metadataFile);
} catch {
// 忽略删除错误
}
resetError();
};
const exportLogs = async () => {
try {
const logs = crashReporter.exportAsText();
if (!logs) {
Alert.alert(
t('error.noLogs') || '提示',
t('error.noLogsMsg') || '暂无崩溃日志记录'
);
return;
}
await Share.share({
message: logs,
title: 'Bean Mobile Crash Logs',
});
} catch (e) {
Alert.alert(
t('error.exportFail') || '导出失败',
e instanceof Error ? e.message : String(e)
);
}
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<ScrollView contentContainerStyle={styles.content}>
<View style={[styles.iconWrap, { backgroundColor: theme.colors.bgTertiary }]}>
<Ionicons name="warning" size={64} color={theme.colors.error} />
</View>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, marginTop: 16, textAlign: 'center' }]}>
{data.title}
</Text>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary, marginTop: 8, textAlign: 'center' }]}>
{data.message}
</Text>
{data.stack && (
<View style={[styles.stackBox, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
{/* monospace 有意保留:堆栈文本需要等宽对齐(P5 审计允许项) */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]} numberOfLines={10}>
{data.stack}
</Text>
</View>
)}
<View style={styles.actions}>
<Pressable
onPress={resetError}
style={({ pressed }) => [styles.btn, { backgroundColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
>
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>
{t('error.retry') || '重试'}
</Text>
</Pressable>
<Pressable
onPress={clearCacheAndRestart}
style={({ pressed }) => [styles.btn, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, borderWidth: 1, opacity: pressed ? 0.7 : 1 }]}
>
<Text style={{ color: theme.colors.fgPrimary }}>
{t('error.clearCache') || '清除缓存并重启'}
</Text>
</Pressable>
<Pressable
onPress={exportLogs}
style={({ pressed }) => [styles.btn, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, borderWidth: 1, opacity: pressed ? 0.7 : 1 }]}
>
<Text style={{ color: theme.colors.fgPrimary }}>
{t('error.exportLogs') || '导出日志'}
</Text>
</Pressable>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 24, textAlign: 'center' }]}>
{t('error.subTitle') || '如果问题持续出现,请导出日志并提交至 GitHub'}
</Text>
</ScrollView>
</SafeAreaView>
);
}
export default function ErrorBoundary({ error, resetError }: ErrorBoundaryProps) {
return <ErrorScreen error={error} resetError={resetError} />;
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
iconWrap: { width: 100, height: 100, borderRadius: 50, alignItems: 'center', justifyContent: 'center' },
stackBox: { width: '100%', marginTop: 16, padding: 12, borderRadius: 8, borderWidth: 1, maxHeight: 200 },
actions: { marginTop: 24, gap: 10, width: '100%' },
btn: { width: '100%', paddingVertical: 14, borderRadius: 12, alignItems: 'center' },
});
+440
View File
@@ -0,0 +1,440 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { StatusBar } from 'expo-status-bar';
import { Stack } from 'expo-router';
import { AppState, DeviceEventEmitter, Platform, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { ThemeProvider, useTheme } from '../theme';
import { useLedgerStore } from '../store/ledgerStore';
import { useImportStore } from '../store/importStore';
import { useSettingsStore } from '../store/settingsStore';
import { useMetadataStore } from '../store/metadataStore';
import { parseDecimal } from '../domain/core/decimal';
import { FileSystemBackend } from '../services/data/fileSystemBackend';
import { useNumpadUiStore } from '../store/numpadUiStore';
import { useT } from '../i18n';
import { LockScreen } from '../components/layout/LockScreen';
import { NumpadSheetHost } from '../components/form/NumpadSheetHost';
import { ToastProvider } from '../components/ui/Toast';
import { OnboardingScreen } from './_onboarding';
import { setupDeepLinking } from '../services/deepLink';
import { initPersistence, flushPersistence } from '../store/storePersistence';
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, loadProcessedTxKeys, ensureBillingListenerRegistered, removeBillingListener } from '../services/automation/automationPipeline';
import { getAccessibilityBridge } from '../services/automation/accessibilityBridge';
import { pushFloatingUiConfig } from '../services/automation/floatingUiConfig';
import { ensureOcrModels } from '../services/ocr/modelDownloader';
import { logger } from '../utils/logger';
import { sanitizeLogText } from '../utils/sanitize';
import { expoLogBackend } from '../services/logBackend';
let lastSignature = '';
let lastProcessedTime = 0;
/**
* UI P6 FloatingUiConfig
* / / colors + labels
* ThemeProvider null
*/
function FloatingUiConfigSyncer() {
const { theme } = useTheme();
const locale = useSettingsStore(s => s.locale);
const t = useT();
useEffect(() => {
// useT() 每次渲染返回新 bind,故依赖用 locale 而非 t
// locale 变化时 useT 内部已将 i18n.locale 指向新语言
void pushFloatingUiConfig(theme, t);
// eslint-disable-next-line react-hooks/exhaustive-deps -- t 每次渲染都是新引用,用 locale 代替
}, [theme, locale]);
return null;
}
/** 原生悬浮窗跳转 App 事件类型 */
interface NativeOpenAppEvent {
draftId?: string;
confirmed?: boolean;
account?: string;
category?: string;
amount?: string;
time?: string;
direction?: string;
merchant?: string;
narration?: string;
currency?: string;
}
/** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */
const SAMPLE_LEDGER = {
path: 'main.bean',
content: `option "operating_currency" "CNY"
`,
};
/** 内层布局:引导/锁屏/路由三态切换。 */
function AppShell() {
const { theme, isDark } = useTheme();
const t = useT();
const router = useRouter();
const loadLedger = useLedgerStore(s => s.loadLedger);
const setContext = useImportStore(s => s.setContext);
const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
const setOnboardingCompleted = useSettingsStore(s => s.setOnboardingCompleted);
const [phase, setPhase] = useState<'loading' | 'onboarding' | 'locked' | 'ready'>('loading');
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);
if (!info.exists) return;
const currentMtime = info.modificationTime;
const currentSize = info.size;
// 如果是第一次加载,或者文件时间/大小发生了改变,则重新读取并加载
if (
!lastMainFileRef.current ||
lastMainFileRef.current.modificationTime !== currentMtime ||
lastMainFileRef.current.size !== currentSize
) {
logger.info('layout', `检测到 main.bean 发生外部文件修改,正在重新加载... (mtime: ${currentMtime}, size: ${currentSize})`);
const content = await FileSystem.readAsStringAsync(mainPath);
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
await loadLedger([{ path: 'main.bean', content }], new FileSystemBackend());
setContext({ rules, categories });
lastMainFileRef.current = { modificationTime: currentMtime, size: currentSize };
logger.info('layout', 'main.bean 重新解析并加载成功');
}
} catch (e) {
logger.error('layout', 'checkAndReloadLedger 失败', e);
}
}, [loadLedger, setContext]);
useEffect(() => {
if (persistenceInitRef.current) return;
persistenceInitRef.current = true;
// 1. 初始化持久化并还原数据
initPersistence().then(async () => {
// 初始化磁盘日志持久化系统
logger.initFileBackend(expoLogBackend).catch(e => {
logger.warn('layout', '磁盘日志初始化失败', e);
});
// 加载已处理的交易键(跨会话去重)
await loadProcessedTxKeys();
// OCR 模型改为按需下载(用户触发 OCR 时才下载,不在启动时自动下载 ~30MB)
// 若已有模型则静默初始化原生引擎
const settings = useSettingsStore.getState();
if (settings.ocrModelDir) {
ensureOcrModels().catch((e) => { logger.warn('layout', '[OCR模型] 静默初始化失败', e); });
}
const mainPath = FileSystem.documentDirectory + 'main.bean';
// 架构说明:main.bean 作为主账本文件,包含所有 Beancount 指令(open/close/option/include 等),
// App 直接对其进行读写与解析。
// 2. 检查并读取本地真实主账本
FileSystem.getInfoAsync(mainPath).then(async (info) => {
let content = SAMPLE_LEDGER.content;
if (info.exists) {
content = await FileSystem.readAsStringAsync(mainPath);
lastMainFileRef.current = { modificationTime: info.modificationTime, size: info.size };
}
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
loadLedger([{ path: 'main.bean', content }], new FileSystemBackend()).then(() => {
setContext({ rules, categories });
const completed = useSettingsStore.getState().onboardingCompleted;
const locked = useSettingsStore.getState().appLockEnabled;
const floatingEnabled = useSettingsStore.getState().floatingBallEnabled;
// 开启/初始化时同步悬浮球开关至原生无障碍服务
const bridge = getAccessibilityBridge();
if (bridge) {
bridge.setFloatingBallEnabled(floatingEnabled).catch((e) => { logger.debug('layout', '[悬浮球] 同步开关失败', e); });
}
if (!completed) {
setPhase('onboarding');
} else if (locked) {
setPhase('locked');
} else {
setPhase('ready');
}
}).catch((e) => {
// 加载失败时仍进入引导流程(而非跳过),让用户有机会重新导入账本
logger.warn('layout', `Ledger load failed: ${e instanceof Error ? e.message : String(e)}`);
setPhase('onboarding');
});
});
});
}, [loadLedger, setContext]);
// 3. 注册深度链接监听(plan.md「1.5」),注入真实的 router 导航行为
useEffect(() => {
if (phase === 'ready') {
const cleanup = setupDeepLinking((action) => {
if (action.type === 'add-transaction') {
useNumpadUiStore.getState().open();
} else if (action.type === 'open-tab') {
if (action.tab === 'home') router.push('/(tabs)');
else if (action.tab === 'transactions') router.push('/(tabs)/transactions');
else if (action.tab === 'import') router.push('/import');
else if (action.tab === 'rules') router.push('/rules');
else if (action.tab === 'settings') router.push('/(tabs)/settings');
} else if (action.type === 'import-csv') {
router.push('/import');
} else if (action.type === 'ocr-camera' || action.type === 'ocr-image') {
useNumpadUiStore.getState().open({ autoOcr: true });
} else if (action.type === 'voice-input') {
router.push('/(tabs)/settings');
}
});
return cleanup;
}
}, [phase, router]);
// 4. 监听原生自动化事件(通知/短信/截图)→ automationStore
// 通知/短信事件是原始文本,需先经 parseNotification/parseSms 解析为 ImportedEvent
// 截图事件是原始 base64 图片,需先经 OcrProcessor 识别为 ImportedEvent
useEffect(() => {
if (phase !== 'ready' || Platform.OS !== 'android') return;
// 注册全局 billingConfirmed 监听器(浮窗确认入账回调)
ensureBillingListenerRegistered();
const subscriptions = [
DeviceEventEmitter.addListener('billingNotification', (event) => {
try {
const safeText = sanitizeLogText(event.text || '');
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}`, { raw: safeText });
const bill = parseNotification(event);
if (bill) {
logger.debug('layout', `通知解析成功,进入账单管道`);
handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`);
} else {
logger.debug('layout', `通知未匹配为账单: [${event.title}]`, { raw: safeText });
}
} catch (e) {
logger.error('layout', '[通知] 解析失败', e);
}
}),
DeviceEventEmitter.addListener('billingSms', (event) => {
try {
const safeBody = sanitizeLogText(event.body || '');
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}`, { raw: safeBody });
const smsEvent = {
sender: event.sender || event.address || '',
body: event.body,
timestamp: event.timestamp || Date.now(),
};
const bill = parseSms(smsEvent);
if (bill) {
logger.debug('layout', `短信解析成功,进入账单管道`);
handleIncomingBillEvent('sms', bill, event.body);
} else {
logger.debug('layout', `短信未匹配为账单`, { raw: safeBody });
}
} catch (e) {
logger.error('layout', '[短信] 解析失败', e);
}
}),
DeviceEventEmitter.addListener('billingScreenshot', async (event) => {
try {
logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
await processScreenshotEvent(event);
} catch (e) {
logger.error('layout', `[截图] OCR 处理失败 (包名: ${event.packageName}, 是否有Base64: ${Boolean(event.imageBase64)})`, e instanceof Error ? { message: e.message, stack: e.stack } : String(e));
}
}),
DeviceEventEmitter.addListener('billingOpenApp', async (event) => {
try {
const res = event as NativeOpenAppEvent;
logger.info('layout', `[悬浮窗跳转] 收到请求: 商户=${res.merchant ?? '无'}, 金额=${res.amount ?? '无'}, confirmed=${res.confirmed ?? false}`);
if (res.confirmed) {
// 已由原生悬浮窗确认入账,前端仅记录日志或提示
return;
}
if (!res.amount && !res.merchant) {
logger.warn('layout', '[悬浮窗跳转] 数据不完整,跳过', { draftId: res.draftId, amount: res.amount, merchant: res.merchant });
return;
}
const amt = res.amount ?? '0';
try {
parseDecimal(amt);
} catch {
if (amt.length > 0) {
logger.warn('layout', 'billingOpenApp 金额格式错误', amt);
} else {
logger.warn('layout', 'billingOpenApp 金额无效', amt);
}
return;
}
const direction = res.direction ?? 'expense';
const categoryAccount = res.category ?? 'Expenses:未分类';
const sourceAccount = res.account ?? 'Assets:未知';
const currency = res.currency ?? 'CNY';
const postings = [
{ account: sourceAccount, amount: direction === 'expense' ? `-${amt}` : amt, currency },
{ account: categoryAccount, amount: direction === 'expense' ? amt : `-${amt}`, currency }
];
const draft = {
date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0],
payee: res.merchant || undefined,
narration: res.narration || '',
postings
};
useNumpadUiStore.getState().open({ draftJson: JSON.stringify(draft) });
} catch (e) {
logger.error('layout', '[悬浮窗跳转] 处理失败', e);
}
}),
DeviceEventEmitter.addListener('billingPageRemembered', (event) => {
logger.debug('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`, { texts: (event.texts ?? []).map(sanitizeLogText) });
}),
DeviceEventEmitter.addListener('billingDebugNodes', async (event) => {
const { package: pkg, activity, texts, isManual } = event;
// 仅处理手动触发(悬浮球点击),自动监听已移除(微信 8.0.52+ 混淆节点文本,自动监听无意义)
if (!isManual) return;
const sigKey = `${pkg}|${activity}`;
logger.info('layout', `[无障碍识别] 手动触发 | 页面: ${sigKey} | 节点数: ${texts?.length ?? 0}`, { texts: (texts ?? []).map(sanitizeLogText) });
try {
const bridge = getAccessibilityBridge();
if (!texts || texts.length === 0) {
logger.info('layout', '[无障碍识别] 节点文本为空(可能是微信等混淆应用),降级触发 OCR 截图识别');
bridge?.triggerManualOcr();
return;
}
// 尝试直接文本解析(传递完整文本,非截断)
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
if (!success) {
logger.info('layout', '[无障碍识别] 直接文本解析未成功,降级触发 OCR 截图识别');
bridge?.triggerManualOcr();
}
} catch (e) {
logger.error('layout', `[无障碍识别] 执行失败 (页面: ${sigKey})`, e);
}
}),
];
return () => {
subscriptions.forEach(s => s.remove());
removeBillingListener();
};
}, [phase]);
// 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();
}
});
return () => sub.remove();
}, [phase, checkAndReloadLedger]);
// loading 状态
if (phase === 'loading') {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: theme.colors.fgSecondary }}>{t('app.name')}</Text>
</SafeAreaView>
);
}
// 引导流程
if (phase === 'onboarding') {
return (
<ThemeProvider>
<OnboardingScreen onComplete={() => {
setOnboardingCompleted(true);
setPhase(appLockEnabled ? 'locked' : 'ready');
}} />
</ThemeProvider>
);
}
// 锁屏
if (phase === 'locked') {
return <LockScreen onUnlock={() => setPhase('ready')} />;
}
// ready:主应用
return (
<View style={{ flex: 1, backgroundColor: theme.colors.bgPrimary }}>
<StatusBar style={isDark ? 'light' : 'dark'} />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="transaction/[id]" options={{ headerShown: false }} />
<Stack.Screen name="category/index" options={{ headerShown: false }} />
<Stack.Screen name="tag/index" options={{ headerShown: false }} />
<Stack.Screen name="budget/index" options={{ headerShown: false }} />
<Stack.Screen name="account/index" options={{ headerShown: false }} />
<Stack.Screen name="import/index" options={{ headerShown: false }} />
<Stack.Screen name="rules/index" options={{ headerShown: false }} />
<Stack.Screen name="settings/ai" options={{ headerShown: false }} />
<Stack.Screen name="settings/sync" options={{ headerShown: false }} />
<Stack.Screen name="settings/preferences" options={{ headerShown: false }} />
<Stack.Screen name="settings/diagnostics" options={{ headerShown: false }} />
<Stack.Screen name="recurring/index" options={{ headerShown: false }} />
<Stack.Screen name="ai/chat" options={{ headerShown: false }} />
<Stack.Screen name="remark-template/index" options={{ headerShown: false }} />
<Stack.Screen name="automation/index" options={{ headerShown: false }} />
<Stack.Screen name="credit-card/index" options={{ headerShown: false }} />
</Stack>
<NumpadSheetHost />
{/* 隐私遮罩(plan.md「0.4」):App 切后台时遮盖内容并拦截所有手势 */}
{privacyOverlay && (
<View
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: theme.colors.bgPrimary, zIndex: 99999 }}
pointerEvents="auto"
accessible={false}
/>
)}
</View>
);
}
/** 根布局:GestureHandlerRootView(左滑手势)包裹 ThemeProvider。 */
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider>
<ToastProvider>
<FloatingUiConfigSyncer />
<AppShell />
</ToastProvider>
</ThemeProvider>
</GestureHandlerRootView>
);
}
+267
View File
@@ -0,0 +1,267 @@
/**
* Onboardingplan.md0.7
*
* BeeCount Introduction 使
* 1. +
* 2. /
* 3. //
* 4.
* 5. OCR//
* 6.
*
* onboarding
*/
import React, { useState, useEffect } from 'react';
import { AppState, Platform, PermissionsAndroid, Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { useSettingsStore, type Locale, type ThemeMode } from '../store/settingsStore';
import { Button } from '../components/ui/Button';
import { getAccessibilityBridge } from '../services/automation/accessibilityBridge';
import { Card } from '../components/ui/Card';
export interface OnboardingStep {
key: string;
title: string;
description?: string;
}
interface OnboardingProps {
onComplete: () => void;
}
export function OnboardingScreen({ onComplete }: OnboardingProps) {
const { theme } = useTheme();
const t = useT();
const setLocale = useSettingsStore(s => s.setLocale);
const setThemeMode = useSettingsStore(s => s.setThemeMode);
const setAppLockEnabled = useSettingsStore(s => s.setAppLockEnabled);
const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
const [step, setStep] = useState(0);
const steps: OnboardingStep[] = [
{ key: 'welcome', title: t('app.name'), description: t('app.tagline') },
{ key: 'language', title: t('onboarding.language') },
{ key: 'theme', title: t('onboarding.theme') },
{ key: 'ledger', title: t('onboarding.ledger'), description: t('onboarding.ledgerDesc') },
{ key: 'permissions', title: t('onboarding.permissions'), description: t('onboarding.permissionsDesc') },
{ key: 'security', title: t('onboarding.security'), description: t('onboarding.securityDesc') },
];
const current = steps[step];
const isLast = step === steps.length - 1;
// ── 权限状态管理(P7 Task 2) ──
const [permStates, setPermStates] = useState<Record<string, boolean | null>>({
accessibility: null, notification: null, sms: null, storage: null, overlay: null,
});
const permsChecked = !Object.values(permStates).some(v => v === null);
const allGranted = permsChecked && Object.values(permStates).every(Boolean);
useEffect(() => {
if (Platform.OS !== 'android') return;
const checkAllPermissions = async () => {
if (current.key !== 'permissions') return;
const bridge = getAccessibilityBridge();
let a = false, n = false, o = false, s = false, st = false;
if (bridge) {
try { a = await bridge.isServiceRunning(); } catch { /* false */ }
try { n = await bridge.isNotificationListenerEnabled(); } catch { /* false */ }
try { o = await bridge.canDrawOverlays(); } catch { /* false */ }
}
try { s = await PermissionsAndroid.check('android.permission.RECEIVE_SMS') as boolean; } catch { /* false */ }
const storagePerm = parseInt(String(Platform.Version), 10) >= 33
? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
try { st = await PermissionsAndroid.check(storagePerm) as boolean; } catch { /* false */ }
setPermStates({ accessibility: a, notification: n, overlay: o, sms: s, storage: st });
};
checkAllPermissions();
// 从系统设置返回后重新检查
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') checkAllPermissions();
});
return () => sub.remove();
}, [current.key]);
const handlePermAction = async (key: string) => {
const storagePerm = parseInt(String(Platform.Version), 10) >= 33
? 'android.permission.READ_MEDIA_IMAGES'
: 'android.permission.READ_EXTERNAL_STORAGE';
switch (key) {
case 'accessibility':
await Linking.sendIntent('android.settings.ACCESSIBILITY_SETTINGS');
break;
case 'notification':
await Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS');
break;
case 'sms': {
const result = await PermissionsAndroid.request('android.permission.RECEIVE_SMS');
setPermStates(p => ({ ...p, sms: result === PermissionsAndroid.RESULTS.GRANTED }));
break;
}
case 'storage': {
const result = await PermissionsAndroid.request(storagePerm);
setPermStates(p => ({ ...p, storage: result === PermissionsAndroid.RESULTS.GRANTED }));
break;
}
case 'overlay':
await Linking.sendIntent('android.settings.action.MANAGE_OVERLAY_PERMISSION');
break;
}
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.iconWrap}>
<Ionicons name="book-outline" size={64} color={theme.colors.accent} />
</View>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, textAlign: 'center' }]}>
{current.title}
</Text>
{current.description && (
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 12 }]}>
{current.description}
</Text>
)}
{current.key === 'language' && (
<View style={styles.options}>
{(['zh', 'en'] as Locale[]).map(loc => (
<Pressable
key={loc}
onPress={() => setLocale(loc)}
style={[styles.option, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
>
<Text style={{ color: theme.colors.fgPrimary }}>{loc === 'zh' ? t('settings.langZh') : t('settings.langEn')}</Text>
</Pressable>
))}
</View>
)}
{current.key === 'theme' && (
<View style={styles.options}>
{(['light', 'dark', 'system'] as ThemeMode[]).map(mode => (
<Pressable
key={mode}
onPress={() => setThemeMode(mode)}
style={[styles.option, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
>
<Text style={{ color: theme.colors.fgPrimary }}>
{mode === 'light' ? t('settings.themeLight') : mode === 'dark' ? t('settings.themeDark') : t('settings.themeSystem')}
</Text>
</Pressable>
))}
</View>
)}
{current.key === 'permissions' && Platform.OS === 'android' && (
<View style={{ width: '100%', marginTop: 24 }}>
<Card>
{[
{ key: 'accessibility', icon: 'accessibility-outline' as const, label: t('onboarding.permAccessibility') },
{ key: 'notification', icon: 'notifications-outline' as const, label: t('onboarding.permNotification') },
{ key: 'sms', icon: 'chatbubble-outline' as const, label: t('onboarding.permSms') },
{ key: 'storage', icon: 'images-outline' as const, label: t('onboarding.permStorage') },
{ key: 'overlay', icon: 'tablet-landscape-outline' as const, label: t('onboarding.permOverlay') },
].map(item => {
const status = permStates[item.key];
const granted = status === true;
const statusColor = granted ? theme.colors.success : theme.colors.fgSecondary;
const statusText = status === null ? '...' : granted ? t('onboarding.permGranted') : t('onboarding.permNotGranted');
return (
<View key={item.key} style={styles.permRow}>
<View style={styles.permLeft}>
<Ionicons name={item.icon} size={20} color={granted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, marginLeft: 12 }]}>
{item.label}
</Text>
</View>
<View style={styles.permRight}>
<Text style={[theme.typography.caption, { color: statusColor, marginRight: 8 }]}>
{statusText}
</Text>
{!granted && !allGranted && (
<Button
label={item.key === 'sms' || item.key === 'storage' ? t('onboarding.permRequest') : t('onboarding.permOpenSettings')}
onPress={() => handlePermAction(item.key)}
variant="secondary"
/>
)}
</View>
</View>
);
})}
</Card>
{allGranted && (
<View style={styles.allGrantedWrap}>
<Ionicons name="checkmark-circle" size={48} color={theme.colors.success} />
<Text style={[theme.typography.body, { color: theme.colors.success, marginTop: 8 }]}>
{t('onboarding.permAllDone')}
</Text>
</View>
)}
</View>
)}
{current.key === 'security' && (
<Pressable
onPress={() => setAppLockEnabled(!appLockEnabled)}
style={[styles.option, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, marginTop: 16 }]}
>
<Text style={{ color: theme.colors.fgPrimary }}>
{appLockEnabled ? t('onboarding.lockEnabled') : t('onboarding.enableLock')}
</Text>
</Pressable>
)}
</ScrollView>
<View style={[styles.footer, { borderTopColor: theme.colors.border }]}>
<View style={styles.dots}>
{steps.map((_, i) => (
<View
key={i}
style={[styles.dot, { backgroundColor: i === step ? theme.colors.accent : theme.colors.border }]}
/>
))}
</View>
<View style={styles.actions}>
{step > 0 && (
<Button label={t('onboarding.prev')} onPress={() => setStep(step - 1)} variant="secondary" />
)}
{isLast ? (
<Button label={t('onboarding.start')} onPress={onComplete} />
) : (
<Button label={t('onboarding.next')} onPress={() => setStep(step + 1)} />
)}
</View>
</View>
</SafeAreaView>
);
}
/** 默认导出(expo-router 兼容性,消除「missing default export」警告)。 */
export default OnboardingScreen;
const styles = StyleSheet.create({
page: { flex: 1 },
content: { flex: 1, padding: 24, alignItems: 'center', justifyContent: 'center' },
iconWrap: { marginBottom: 24 },
options: { flexDirection: 'row', gap: 12, marginTop: 24, flexWrap: 'wrap', justifyContent: 'center' },
option: { paddingVertical: 12, paddingHorizontal: 24, borderRadius: 12, borderWidth: 1 },
footer: { padding: 16, borderTopWidth: 1 },
dots: { flexDirection: 'row', justifyContent: 'center', gap: 6, marginBottom: 12 },
dot: { width: 8, height: 8, borderRadius: 4 },
actions: { flexDirection: 'row', gap: 12, justifyContent: 'flex-end' },
// 权限清单(P7 Task 2
permRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
permLeft: { flexDirection: 'row', alignItems: 'center', flex: 1 },
permRight: { flexDirection: 'row', alignItems: 'center' },
allGrantedWrap: { alignItems: 'center', marginTop: 24 },
});
+264
View File
@@ -0,0 +1,264 @@
import React, { useState } from 'react';
import { Alert, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useLedgerStore } from '../../store/ledgerStore';
import { useT } from '../../i18n';
import { Card } from '../../components/ui/Card';
import { AccountCreateModal } from '../../components/account/AccountCreateModal';
import { FormModal } from '../../components/form/FormModal';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
import { toDateString } from '../../domain/core/decimal';
import { computeAccountBalances } from '../../domain/core/ledger';
import { SegmentedControl } from '../../components/ui/SegmentedControl';
import { EmptyState } from '../../components/ui/EmptyState';
import { useToast } from '../../components/ui/Toast';
type TabType = 'assets' | 'liabilities' | 'expenses' | 'income';
export default function AccountScreen() {
const { theme } = useTheme();
const t = useT();
const { showToast } = useToast();
const ledger = useLedgerStore(s => s.ledger);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
const autoCloseAccount = useLedgerStore(s => s.autoCloseAccount);
const adjustAccountBalance = useLedgerStore(s => s.adjustAccountBalance);
const [tab, setTab] = useState<TabType>('assets');
const [isAdding, setIsAdding] = useState(false);
const [adjustingAccount, setAdjustingAccount] = useState<string | null>(null);
const accounts = Array.from(ledger?.accounts.keys() || []).sort();
// 计算每个账户的余额(包含 balance 断言)
const balances = ledger ? computeAccountBalances(ledger) : new Map<string, string>();
// 根据当前选择的 Tab 过滤账户
const prefix =
tab === 'assets'
? 'Assets:'
: tab === 'liabilities'
? 'Liabilities:'
: tab === 'expenses'
? 'Expenses:'
: 'Income:';
const filteredAccounts = accounts.filter(a => a.startsWith(prefix));
const handleCloseAccount = (accountName: string) => {
Alert.alert(
t('account.confirmClose'),
t('account.confirmCloseDesc', { name: accountName }),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('account.btnConfirmClose'),
style: 'destructive',
onPress: async () => {
try {
await autoCloseAccount(accountName);
showToast(t('account.closeSuccessDesc'), 'success');
} catch (e) {
Alert.alert(t('account.closeFail'), e instanceof Error ? e.message : String(e));
}
},
},
]
);
};
const handleAddAccount = async (values: Record<string, string>) => {
const typeVal = values.type?.trim();
const nameVal = values.name?.trim();
if (!typeVal || !nameVal) {
Alert.alert(t('account.addFail'), t('account.addFailEmpty'));
return;
}
// 拼接成 Beancount 账户名并清洗特殊字符
const rawAccount = `${typeVal}:${nameVal}`;
let sanitized = rawAccount.replace(//g, ':');
sanitized = sanitized
.split(':')
.map((seg, idx) => {
if (idx === 0) return seg; // 保留顶级前缀如 Assets
// 将中英文括号和中括号转换为 - 连字符
let cleaned = seg.replace(/[()()[\]]/g, '-');
cleaned = cleaned.replace(/-+/g, '-');
cleaned = cleaned.replace(/^-|-$/g, '');
cleaned = cleaned.replace(/[^\w\u4e00-\u9fa5-]/g, '');
return cleaned;
})
.join(':');
const rootType = sanitized.split(':')[0];
const validRoots = ['Assets', 'Liabilities', 'Income', 'Expenses', 'Equity'];
if (!validRoots.includes(rootType)) {
Alert.alert(t('account.addFail'), t('account.invalidRootAccount'));
return;
}
try {
await autoOpenAccounts([sanitized]);
setIsAdding(false);
showToast(t('account.openSuccessDesc', { name: sanitized }), 'success');
} catch (e) {
Alert.alert(t('account.openFail'), e instanceof Error ? e.message : String(e));
}
};
const handleConfirmAdjust = async (values: Record<string, string>) => {
if (!adjustingAccount) return;
const balanceVal = values.balance?.trim();
const dateVal = values.date?.trim() || toDateString(new Date());
if (!balanceVal) {
Alert.alert(t('account.adjustFail'), t('account.adjustFailEmpty'));
return;
}
try {
await adjustAccountBalance(adjustingAccount, balanceVal, dateVal);
setAdjustingAccount(null);
showToast(t('account.adjustSuccessDesc', { name: adjustingAccount, balance: balanceVal, currency: 'CNY' }), 'success');
} catch (e) {
Alert.alert(t('account.adjustFail'), e instanceof Error ? e.message : String(e));
}
};
const tabs: { key: TabType; label: string }[] = [
{ key: 'assets', label: t('account.tabAssets') },
{ key: 'liabilities', label: t('account.tabLiabilities') },
{ key: 'expenses', label: t('account.tabExpenses') },
{ key: 'income', label: t('account.tabIncome') },
];
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<ScreenHeader title={t('account.title')} />
{/* Tab 选项卡(统一分段选择器) */}
<SegmentedControl
options={tabs}
value={tab}
onChange={key => setTab(key as TabType)}
/>
{/* 账户列表 */}
<FlatList
data={filteredAccounts}
keyExtractor={account => account}
renderItem={({ item: account }) => {
const shortName = account.slice(prefix.length);
const rootType = account.split(':')[0] as 'Assets' | 'Liabilities' | 'Expenses' | 'Income' | 'Equity';
const localizedRoot = t(`account.rootLabels.${rootType}`);
const balance = balances.get(account) ?? '0.00';
const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY';
return (
<Card title={shortName}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('account.fullName')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{account} {localizedRoot ? `(${localizedRoot})` : ''}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('account.currentBalance')}
</Text>
<Text
style={[
theme.typography.body,
{ color: balance.startsWith('-') ? theme.colors.error : theme.colors.info, fontWeight: '700' },
]}
>
{balance} {currency}
</Text>
</View>
<View style={styles.cardActions}>
<Pressable
onPress={() => setAdjustingAccount(account)}
style={[styles.actionBtn, { borderColor: theme.colors.border, borderRadius: theme.radii.sm, marginRight: 8 }]}
>
<Ionicons name="create-outline" size={14} color={theme.colors.accent} />
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
{t('account.adjustBalance')}
</Text>
</Pressable>
<Pressable
onPress={() => handleCloseAccount(account)}
style={[styles.closeBtn, { borderColor: theme.colors.border, borderRadius: theme.radii.sm }]}
>
<Ionicons name="close-circle-outline" size={14} color={theme.colors.error} />
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
{t('account.closeAccount')}
</Text>
</Pressable>
</View>
</Card>
);
}}
ListHeaderComponent={
<Pressable onPress={() => setIsAdding(true)} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>
{t('account.addNew')}
</Text>
</Pressable>
}
ListEmptyComponent={
<EmptyState icon="wallet-outline" title={t('account.empty')} />
}
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
/>
{/* 新开户对话框 */}
<AccountCreateModal
visible={isAdding}
defaultType={tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income'}
onConfirm={handleAddAccount}
onCancel={() => setIsAdding(false)}
/>
{/* 调整余额对话框 */}
<FormModal
visible={adjustingAccount !== null}
title={t('account.adjustModalTitle')}
fields={[
{
key: 'balance',
label: t('account.fieldBalance'),
placeholder: t('account.fieldBalancePlaceholder'),
defaultValue: adjustingAccount ? (balances.get(adjustingAccount) ?? '0.00') : '0.00',
},
{
key: 'date',
label: t('account.fieldDate'),
placeholder: 'YYYY-MM-DD',
defaultValue: toDateString(new Date()),
},
]}
onConfirm={handleConfirmAdjust}
onCancel={() => setAdjustingAccount(null)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16 },
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' },
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, paddingHorizontal: 8, paddingVertical: 4 },
closeBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, paddingHorizontal: 8, paddingVertical: 4 },
});
+204
View File
@@ -0,0 +1,204 @@
/**
* AI plan.md8.1 AI
*
*
* - "昨天星巴克花了35"
* - AI
* - new.tsx
*
* 使 chatAssistant.processChatMessage + BaseOpenAIProviderOpenAI
* AI
*/
import React, { useState, useCallback, useRef } from 'react';
import { FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider } from '../../domain/ai';
import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
import { SkeletonText } from '../../components/ui/Skeleton';
interface UiMessage {
role: 'user' | 'assistant';
content: string;
billCards?: ChatResponse['billCards'];
}
/** 构造 AiProvider(从 settingsStore)。 */
function buildProvider(get: ReturnType<typeof useSettingsStore.getState>): AiProvider | null {
if (!get.aiEnabled || !get.aiApiKey) return null;
const config: AiProviderConfig = {
id: get.aiProviderId,
name: get.aiProviderId,
apiKey: get.aiApiKey,
baseUrl: get.aiBaseUrl || 'https://api.openai.com/v1',
model: get.aiModel || 'gpt-4o-mini',
};
// BaseOpenAIProvider 是 abstract 但 chat 方法已实现,创建匿名子类
return new (class extends BaseOpenAIProvider {})(config);
}
export default function AIChatScreen() {
const { theme } = useTheme();
const t = useT();
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<UiMessage[]>([
{ role: 'assistant', content: t('ai.welcome') },
]);
const conversationRef = useRef<ChatConversation>(createConversation());
const listRef = useRef<FlatList<UiMessage>>(null);
const sendMessage = useCallback(async () => {
const text = input.trim();
if (!text || loading) return;
// 添加用户消息
const userMsg: UiMessage = { role: 'user', content: text };
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
try {
const provider = buildProvider(useSettingsStore.getState());
if (!provider) {
setMessages(prev => [...prev, { role: 'assistant', content: t('ai.notConfigured') }]);
return;
}
const response = await processChatMessage(text, provider, conversationRef.current);
conversationRef.current = appendMessage(conversationRef.current, { role: 'user', content: text });
if (response.type === 'bill_card' && response.billCards && response.billCards.length > 0) {
const assistantMsg: UiMessage = {
role: 'assistant',
content: t('ai.billDetected'),
billCards: response.billCards,
};
setMessages(prev => [...prev, assistantMsg]);
conversationRef.current = appendMessage(conversationRef.current, { role: 'assistant', content: response.text || '' });
} else if (response.type === 'text' && response.text) {
setMessages(prev => [...prev, { role: 'assistant', content: response.text! }]);
conversationRef.current = appendMessage(conversationRef.current, { role: 'assistant', content: response.text });
} else if (response.type === 'error') {
setMessages(prev => [...prev, { role: 'assistant', content: response.error || t('ai.error') }]);
} else {
setMessages(prev => [...prev, { role: 'assistant', content: response.text || t('ai.error') }]);
}
} catch (e) {
setMessages(prev => [...prev, { role: 'assistant', content: `${t('ai.error')}: ${e instanceof Error ? e.message : String(e)}` }]);
} finally {
setLoading(false);
setTimeout(() => listRef.current?.scrollToEnd(), 100);
}
}, [input, loading, t]);
const renderMessage = ({ item }: { item: UiMessage }) => {
const isUser = item.role === 'user';
return (
<View style={[styles.msgRow, { justifyContent: isUser ? 'flex-end' : 'flex-start' }]}>
<View style={[
styles.bubble,
{
backgroundColor: isUser ? theme.colors.accent : theme.colors.bgTertiary,
borderColor: theme.colors.border,
borderRadius: theme.radii.md,
},
]}>
<Text style={[theme.typography.bodySmall, { color: isUser ? theme.colors.fgInverse : theme.colors.fgPrimary }]}>
{item.content}
</Text>
{/* 账单卡片 */}
{item.billCards && item.billCards.map((card, i) => {
// 方向符号 + 金额上色,与全局方向色约定一致(TransactionCardincome 绿 / expense 红 / transfer 蓝)
const dirColor = card.type === 'income' ? theme.colors.financial.income
: card.type === 'transfer' ? theme.colors.financial.transfer
: theme.colors.financial.expense;
const dirSign = card.type === 'income' ? '' : card.type === 'transfer' ? '⇄' : '';
return (
<Pressable
key={i}
onPress={() => useNumpadUiStore.getState().open({ autoOcr: true })}
style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent, borderRadius: theme.radii.sm }]}
>
<Text style={[theme.typography.bodySmall, { color: dirColor, fontWeight: '700' }]}>
{dirSign} {card.amount} {card.currency}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{card.counterparty} · {card.narration}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>
<Text style={[theme.typography.caption, { color: theme.colors.accent }]}>
{t('ai.tapToRecord')}
</Text>
<Ionicons name="arrow-forward" size={12} color={theme.colors.accent} />
</View>
</Pressable>
);
})}
</View>
</View>
);
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<ScreenHeader title={t('ai.chatTitle')} />
<FlatList
ref={listRef}
data={messages}
keyExtractor={(item, index) => `${index}`}
renderItem={renderMessage}
contentContainerStyle={{ padding: 16, gap: theme.spacing.md }}
onContentSizeChange={() => listRef.current?.scrollToEnd()}
/>
{loading && (
<View style={{ paddingHorizontal: 16, paddingBottom: 4 }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: theme.spacing.xs }]}>
{t('ai.thinking')}
</Text>
<SkeletonText lines={2} lineHeight={12} />
</View>
)}
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View style={[styles.inputRow, { backgroundColor: theme.colors.bgSecondary, borderTopColor: theme.colors.border }]}>
<TextInput
value={input}
onChangeText={setInput}
placeholder={t('ai.inputPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={[styles.input, { color: theme.colors.fgPrimary, fontSize: theme.typography.body.fontSize }]}
multiline
/>
<Pressable
onPress={sendMessage}
disabled={!input.trim() || loading}
style={({ pressed }) => [
styles.sendBtn,
{ backgroundColor: theme.colors.accent, opacity: (!input.trim() || loading) ? 0.4 : pressed ? 0.7 : 1 },
]}
>
<Ionicons name="send" size={18} color={theme.colors.fgInverse} />
</Pressable>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
msgRow: { flexDirection: 'row', maxWidth: '100%' },
bubble: { maxWidth: '85%', padding: 12, borderWidth: 1 },
billCard: { marginTop: 8, padding: 10, borderWidth: 1 },
inputRow: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingVertical: 8, borderTopWidth: 1, gap: 8 },
input: { flex: 1, maxHeight: 100, paddingVertical: 8 },
sendBtn: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
});
+648
View File
@@ -0,0 +1,648 @@
/**
* plan.md3.x +3.6
*
*
* - 5 ///OCR/
* -
* - "处理全部" BillPipeline 稿
* - /稿 main.bean
* - ScreenshotMonitor
* -
* - /
* -
* - OCR
* - OCR
* -
* - App
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Alert, AppState, Linking, NativeModules, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useRouter } from 'expo-router';
import { useSettingsStore } from '../../store/settingsStore';
import { Card } from '../../components/ui/Card';
import { Button } from '../../components/ui/Button';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
import {
getAccessibilityBridge,
getPackageLabel,
type PageSignature,
} from '../../services/automation/accessibilityBridge';
import { ensureOcrModels, downloadOcrModels } from '../../services/ocr/modelDownloader';
import { logger } from '../../utils/logger';
export default function AutomationScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const floatingBallEnabled = useSettingsStore(s => s.floatingBallEnabled);
const setFloatingBallEnabled = useSettingsStore(s => s.setFloatingBallEnabled);
// 自动记账层级
const layer1RuleEnabled = useSettingsStore(s => s.layer1RuleEnabled);
const setLayer1RuleEnabled = useSettingsStore(s => s.setLayer1RuleEnabled);
const layer2OcrEnabled = useSettingsStore(s => s.layer2OcrEnabled);
const setLayer2OcrEnabled = useSettingsStore(s => s.setLayer2OcrEnabled);
const layer3AiEnabled = useSettingsStore(s => s.layer3AiEnabled);
const setLayer3AiEnabled = useSettingsStore(s => s.setLayer3AiEnabled);
const ocrModelVersion = useSettingsStore(s => s.ocrModelVersion);
const aiEnabled = useSettingsStore(s => s.aiEnabled);
const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
const dedupEnabled = useSettingsStore(s => s.dedupEnabled);
const setDedupEnabled = useSettingsStore(s => s.setDedupEnabled);
const transferRecognitionEnabled = useSettingsStore(s => s.transferRecognitionEnabled);
const setTransferRecognitionEnabled = useSettingsStore(s => s.setTransferRecognitionEnabled);
// 无障碍服务状态
const [serviceRunning, setServiceRunning] = useState(false);
const [pageSignatures, setPageSignatures] = useState<PageSignature[]>([]);
const [paymentPackages, setPaymentPackages] = useState<{ package: string; label: string }[]>([]);
const [screenshotActive, setScreenshotActive] = useState(false);
const [topApp, setTopApp] = useState<{ package: string; activity: string } | null>(null);
// 权限状态
const [notifEnabled, setNotifEnabled] = useState<boolean | null>(null);
const [smsGranted, setSmsGranted] = useState<boolean | null>(null);
const [storageGranted, setStorageGranted] = useState<boolean | null>(null);
const [overlayGranted, setOverlayGranted] = useState<boolean | null>(null);
// OCR 模型安装/下载状态
const [modelStatus, setModelStatus] = useState<'idle' | 'installing' | 'verifying' | 'done' | 'error'>('idle');
const [modelProgress, setModelProgress] = useState(0);
const handleInstallModel = async () => {
setModelStatus('installing');
try {
await ensureOcrModels((p) => setModelProgress(p));
setModelStatus('done');
} catch (e) {
setModelStatus('error');
Alert.alert(t('common.error'), String(e));
}
};
const handleRedownloadModel = async () => {
setModelStatus('installing');
setModelProgress(0);
try {
await downloadOcrModels(undefined, (p) => setModelProgress(p));
setModelStatus('done');
} catch (e) {
setModelStatus('error');
Alert.alert(t('automation.ocrModelDownloadFail'), String(e));
}
};
const handleL2Toggle = (val: boolean) => {
if (val && !ocrModelVersion) {
Alert.alert(t('automation.ocrModelTitle'), t('automation.ocrModelNotInstalled'), [
{ text: t('common.cancel'), onPress: () => setLayer2OcrEnabled(false) },
{ text: t('automation.ocrModelInstall'), onPress: handleInstallModel },
]);
return;
}
setLayer2OcrEnabled(val);
};
const scrollRef = useRef<ScrollView>(null);
const handleL3Toggle = (val: boolean) => {
if (val && (!aiEnabled || !aiApiKey)) {
Alert.alert(t('automation.layer3Ai'), t('automation.layer3AiDisabled'), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.confirm'), onPress: () => router.push('/settings/ai') },
]);
return;
}
setLayer3AiEnabled(val);
};
// 刷新无障碍状态
const refreshAccessibilityState = useCallback(async () => {
const bridge = getAccessibilityBridge();
if (!bridge) return;
try {
const [running, sigs, pkgs, top] = await Promise.all([
bridge.isServiceRunning().catch(() => false),
bridge.getPageSignatures().catch(() => [] as PageSignature[]),
bridge.getPaymentPackages().catch(() => [] as string[]),
bridge.getTopApp().catch(() => ({ package: '', activity: '' })),
]);
setServiceRunning(running);
setPageSignatures(sigs);
setPaymentPackages(pkgs.map(pkg => ({ package: pkg, label: getPackageLabel(pkg) })));
setTopApp(top && top.package ? top : null);
} catch (e) {
logger.debug('automation', '[刷新无障碍状态] 失败', e);
}
}, []);
useEffect(() => {
refreshAccessibilityState();
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
refreshAccessibilityState();
}
});
return () => {
subscription.remove();
};
}, [refreshAccessibilityState]);
// 检查权限状态(挂载 + 从系统设置返回前台时重新检查)
useEffect(() => {
if (Platform.OS !== 'android') return;
const checkPermissions = () => {
const bridge = getAccessibilityBridge();
if (bridge && typeof bridge.isNotificationListenerEnabled === 'function') {
bridge.isNotificationListenerEnabled().then(setNotifEnabled).catch(() => setNotifEnabled(false));
}
if (bridge && typeof bridge.canDrawOverlays === 'function') {
bridge.canDrawOverlays().then(setOverlayGranted).catch(() => setOverlayGranted(false));
}
const smsPerm = 'android.permission.RECEIVE_SMS';
PermissionsAndroid.check(smsPerm).then(async (granted) => {
if (granted) { setSmsGranted(true); return; }
try {
const result = await PermissionsAndroid.request(smsPerm);
setSmsGranted(result === PermissionsAndroid.RESULTS.GRANTED);
} catch { setSmsGranted(false); }
}).catch(() => setSmsGranted(false));
const storagePerm = Number(Platform.Version) >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
PermissionsAndroid.check(storagePerm).then(g => setStorageGranted(g as boolean)).catch(() => setStorageGranted(false));
};
checkPermissions();
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') checkPermissions();
});
return () => sub.remove();
}, []);
const handleOpenNotificationSettings = () => {
if (Platform.OS !== 'android') return;
Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS').catch((e) => { logger.debug('automation', '[打开通知设置] 失败', e); });
};
const handleRequestSms = async () => {
const result = await PermissionsAndroid.request('android.permission.RECEIVE_SMS');
setSmsGranted(result === PermissionsAndroid.RESULTS.GRANTED);
};
const handleOpenOverlaySettings = () => {
Linking.sendIntent('android.settings.action.MANAGE_OVERLAY_PERMISSION').catch((e) => { logger.debug('automation', '[打开悬浮窗设置] 失败', e); });
};
const handleRequestStorage = async () => {
const perm = Number(Platform.Version) >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
const result = await PermissionsAndroid.request(perm);
setStorageGranted(result === PermissionsAndroid.RESULTS.GRANTED);
};
const handleScreenshotToggle = () => {
if (Platform.OS !== 'android') {
Alert.alert('Android only');
return;
}
const module = (NativeModules as { ScreenshotMonitor?: { start: () => void; stop: () => void } }).ScreenshotMonitor;
if (!module) {
Alert.alert(t('automation.screenshotUnavailable'));
return;
}
if (screenshotActive) {
module.stop();
setScreenshotActive(false);
} else {
module.start();
setScreenshotActive(true);
}
};
const handleOpenAccessibilitySettings = () => {
if (Platform.OS !== 'android') return;
// 打开系统无障碍设置页面 (Intent Action)
Linking.sendIntent('android.settings.ACCESSIBILITY_SETTINGS').catch(() => {
Alert.alert(t('automation.openSettingsFail'));
});
};
const handleRememberPage = async () => {
const bridge = getAccessibilityBridge();
if (!bridge) {
Alert.alert(t('automation.bridgeUnavailable'));
return;
}
Alert.alert(
'准备记录目标页面',
'点击“开始”后,请在 8 秒内切换到支付宝/微信的账单详情页,系统将在倒计时结束后自动记录该页面的签名。',
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: '开始 (8s 倒计时)',
onPress: () => {
setTimeout(async () => {
try {
const result = await bridge.rememberCurrentPage();
Alert.alert(
t('automation.rememberPageSuccess'),
t('automation.rememberPageDesc', { pkg: getPackageLabel(result.package), activity: result.activity }),
);
refreshAccessibilityState();
} catch (e) {
Alert.alert(t('automation.rememberPageFail'), String((e as Error)?.message ?? e));
}
}, 8000);
},
},
],
);
};
const handleManualOcr = async () => {
const bridge = getAccessibilityBridge();
if (!bridge) {
Alert.alert(t('automation.bridgeUnavailable'));
return;
}
Alert.alert(
'准备手动识别',
'点击“开始”后,请在 8 秒内切换到你想识别的账单详情页,系统将在 8 秒后自动截图并识别该页面。',
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: '开始 (8s 倒计时)',
onPress: () => {
setTimeout(async () => {
try {
await bridge.triggerManualOcr();
} catch (e) {
Alert.alert(t('automation.manualOcrFail'), String((e as Error)?.message ?? e));
}
}, 8000);
},
},
],
);
};
const handleRemoveSignature = async (sig: string) => {
const bridge = getAccessibilityBridge();
if (!bridge) return;
try {
await bridge.removePageSignature(sig);
refreshAccessibilityState();
} catch (e) {
logger.warn('automation', `[删除页面签名] 失败: ${sig}`, e);
}
};
const handleClearSignatures = () => {
Alert.alert(
t('automation.clearPagesTitle'),
t('automation.clearPagesConfirm'),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.confirm'),
style: 'destructive',
onPress: async () => {
const bridge = getAccessibilityBridge();
if (!bridge) return;
try {
await bridge.clearPageSignatures();
refreshAccessibilityState();
} catch (e) {
logger.warn('automation', '[清空页面签名] 失败', e);
}
},
},
],
);
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<ScreenHeader title={t('automation.title')} />
<ScrollView ref={scrollRef} contentContainerStyle={styles.content}>
<View style={{ gap: 12, marginBottom: 12 }}>
{/* 无障碍服务状态与控制 */}
{Platform.OS === 'android' && (
<Card title={t('automation.accessibilityTitle')}>
{/* 服务状态 */}
<View style={[styles.statusRow, { marginBottom: 8 }]}>
<View style={[styles.statusDot, { backgroundColor: serviceRunning ? theme.colors.success : theme.colors.fgSecondary }]} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{serviceRunning ? t('automation.serviceRunning') : t('automation.serviceStopped')}
</Text>
</View>
{topApp && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
{t('automation.currentApp')}: {getPackageLabel(topApp.package)}
</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 (e) { logger.debug('automation', '[悬浮球] 同步开关失败', e); }
}
}}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* 操作按钮 */}
<View style={{ gap: 8 }}>
{!serviceRunning && (
<Button label={t('automation.openSettings')} onPress={handleOpenAccessibilitySettings} variant="secondary" />
)}
<View style={{ flexDirection: 'row', gap: 8 }}>
<View style={{ flex: 1 }}>
<Button
label={t('automation.rememberPage')}
onPress={handleRememberPage}
variant="secondary"
/>
</View>
<View style={{ flex: 1 }}>
<Button
label={t('automation.manualOcr')}
onPress={handleManualOcr}
/>
</View>
</View>
</View>
{/* 支付 App 白名单 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 12, marginBottom: 4 }]}>
{t('automation.whitelistTitle')}
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4 }}>
{paymentPackages.length === 0 ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.whitelistEmpty')}
</Text>
) : (
paymentPackages.map(pkg => (
<View key={pkg.package} style={[styles.chip, { backgroundColor: theme.colors.divider }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary }]}>
{pkg.label}
</Text>
</View>
))
)}
</View>
{/* 已记住的页面 */}
{pageSignatures.length > 0 && (
<View style={{ marginTop: 12 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.rememberedPages', { count: pageSignatures.length })}
</Text>
<Pressable onPress={handleClearSignatures}>
<Text style={[theme.typography.caption, { color: theme.colors.accent }]}>
{t('automation.clearAll')}
</Text>
</Pressable>
</View>
{pageSignatures.map(sig => (
<View key={sig.signature} style={styles.sigRow}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1 }]}>
{getPackageLabel(sig.package)} · {sig.activity.split('.').pop() || sig.activity}
</Text>
<Pressable onPress={() => handleRemoveSignature(sig.signature)}>
<Ionicons name="close-circle" size={18} color={theme.colors.fgSecondary} />
</Pressable>
</View>
))}
</View>
)}
{/* 截图监控开关 */}
<View style={{ marginTop: 12 }}>
<Button
label={screenshotActive ? t('automation.screenshotStop') : t('automation.screenshotStart')}
onPress={handleScreenshotToggle}
variant={screenshotActive ? 'primary' : 'secondary'}
/>
</View>
</Card>
)}
{/* --- Card A: 自动记账层级 --- */}
<Card title={t('automation.autoBookkeeping')}>
{/* L1 规则匹配 */}
<View style={[styles.layerRow, { marginBottom: 8 }]}>
<Ionicons name="flash-outline" size={20} color={layer1RuleEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('automation.layer1Rule')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.layer1RuleDesc')}
</Text>
</View>
<Switch
value={layer1RuleEnabled}
onValueChange={setLayer1RuleEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* L2 OCR 识别 */}
<View style={[styles.layerRow, { marginBottom: 8 }]}>
<Ionicons name="scan-outline" size={20} color={layer2OcrEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('automation.layer2Ocr')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.layer2OcrDesc')}
</Text>
</View>
<Switch
value={layer2OcrEnabled}
onValueChange={handleL2Toggle}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* L3 AI 视觉 */}
<View style={styles.layerRow}>
<Ionicons name="sparkles-outline" size={20} color={layer3AiEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('automation.layer3Ai')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.layer3AiDesc')}
</Text>
</View>
<Switch
value={layer3AiEnabled}
onValueChange={handleL3Toggle}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* --- Card B: OCR 模型 --- */}
<Card title={t('automation.ocrModelTitle')}>
{/* 状态行 */}
<View style={styles.layerRow}>
<Ionicons
name="hardware-chip-outline"
size={20}
color={ocrModelVersion ? theme.colors.success : theme.colors.fgSecondary}
/>
<Text style={[theme.typography.body, { flex: 1, marginLeft: 10, color: ocrModelVersion ? theme.colors.success : theme.colors.fgSecondary }]}>
{ocrModelVersion
? t('automation.ocrModelInstalled', { version: ocrModelVersion })
: t('automation.ocrModelNotInstalled')}
</Text>
</View>
{/* 下载进度条 */}
{modelStatus === 'installing' && (
<View style={{ marginTop: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<ActivityIndicator size="small" color={theme.colors.accent} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, flex: 1 }]}>
{modelProgress > 0
? t('automation.ocrModelDownloading', { progress: String(modelProgress) })
: t('automation.ocrModelCopying')}
</Text>
</View>
{modelProgress > 0 && (
<View style={[styles.progressBar, { backgroundColor: theme.colors.progressBg, marginTop: 6 }]}>
<View style={[styles.progressFill, { backgroundColor: theme.colors.accent, width: `${modelProgress}%` }]} />
</View>
)}
</View>
)}
{/* 操作按钮行 */}
{modelStatus !== 'installing' && (
<View style={[styles.layerRow, { marginTop: 8 }]}>
{!ocrModelVersion || modelStatus === 'error' ? (
<Button label={t('automation.ocrModelDownload')} onPress={handleInstallModel} variant="secondary" />
) : (
<Button label={t('automation.ocrModelRedownload')} onPress={handleRedownloadModel} variant="secondary" />
)}
</View>
)}
</Card>
{/* --- Card C: 管道设置 --- */}
<Card title={t('settings.algorithmConfig')}>
<View style={[styles.switchRow, { marginBottom: 8 }]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.dedupLabel')}
</Text>
<Switch
value={dedupEnabled}
onValueChange={setDedupEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.transferLabel')}
</Text>
<Switch
value={transferRecognitionEnabled}
onValueChange={setTransferRecognitionEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* 其他权限 */}
{Platform.OS === 'android' && (
<Card title={t('automation.otherPermissions')}>
{/* 通知监听 */}
<View style={styles.permRow}>
<Ionicons name="notifications-outline" size={20} color={notifEnabled ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.notificationTitle')}</Text>
<Text style={{ color: notifEnabled ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{notifEnabled ? t('automation.notificationEnabled') : t('automation.notificationDisabled')}
</Text>
{!notifEnabled && <Button label={t('automation.notificationOpenSettings')} onPress={handleOpenNotificationSettings} variant="secondary" />}
</View>
{/* 短信 */}
<View style={[styles.permRow, { marginTop: 8 }]}>
<Ionicons name="chatbubble-outline" size={20} color={smsGranted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.smsPermissionTitle')}</Text>
<Text style={{ color: smsGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{t('automation.smsPermGranted')}
</Text>
{!smsGranted && <Button label={t('automation.smsPermRequest')} onPress={handleRequestSms} variant="secondary" />}
</View>
{/* 存储 */}
<View style={[styles.permRow, { marginTop: 8 }]}>
<Ionicons name="images-outline" size={20} color={storageGranted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.storagePermTitle')}</Text>
<Text style={{ color: storageGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{t('automation.storagePermGranted')}
</Text>
{!storageGranted && <Button label={t('automation.storagePermRequest')} onPress={handleRequestStorage} variant="secondary" />}
</View>
{/* 悬浮窗 */}
<View style={[styles.permRow, { marginTop: 8 }]}>
<Ionicons name="tablet-landscape-outline" size={20} color={overlayGranted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.overlayPermTitle')}</Text>
<Text style={{ color: overlayGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{overlayGranted ? t('automation.overlayPermGranted') : t('automation.overlayPermNotGranted')}
</Text>
{!overlayGranted && <Button label={t('automation.overlayPermOpenSettings')} onPress={handleOpenOverlaySettings} variant="secondary" />}
</View>
</Card>
)}
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16, paddingBottom: 64 },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
statusDot: { width: 8, height: 8, borderRadius: 4 },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
layerRow: { flexDirection: 'row', alignItems: 'center' },
chip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 },
progressBar: { height: 4, borderRadius: 2, overflow: 'hidden' },
progressFill: { height: '100%', borderRadius: 2 },
sigRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 4 },
permRow: { flexDirection: 'row', alignItems: 'center' },
});
+121
View File
@@ -0,0 +1,121 @@
/**
* plan.md1.1 budget/index+ 1
*
* () + //
* calculateBudgetProgress ,//
* P5 ManagementScreen //
*/
import React from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n';
import { Card } from '../../components/ui/Card';
import { ManagementScreen } from '../../components/layout/ManagementScreen';
import { calculateBudgetProgress } from '../../domain/finance/budgets';
import { toDateString } from '../../domain/core/decimal';
import type { Budget } from '../../domain/finance/budgets';
export default function BudgetScreen() {
const { theme } = useTheme();
const t = useT();
const budgets = useMetadataStore(s => s.budgets);
const addBudget = useMetadataStore(s => s.addBudget);
const updateBudget = useMetadataStore(s => s.updateBudget);
const removeBudget = useMetadataStore(s => s.removeBudget);
const ledger = useLedgerStore(s => s.ledger);
const transactions = ledger?.transactions ?? [];
// 用本地时区格式化,避免 toISOString 的 UTC 偏移导致负时区日期错位
const today = toDateString(new Date());
const periodLabel = (p: string) => p === 'monthly' ? t('budget.periodMonthly') : p === 'weekly' ? t('budget.periodWeekly') : t('budget.periodYearly');
return (
<ManagementScreen<Budget>
title={t('budget.title')}
items={budgets}
keyExtractor={budget => budget.id}
addLabel={t('budget.add')}
emptyText={t('budget.empty')}
renderItem={(budget, { openEdit, confirmDelete }) => {
const progress = calculateBudgetProgress(budget, transactions, today);
return (
<Pressable onPress={openEdit} onLongPress={confirmDelete}>
<Card title={budget.name}>
<View style={styles.budgetRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('budget.yuanPer', { amount: budget.amount, period: periodLabel(budget.period) })}
</Text>
<Text style={[theme.typography.caption, {
color: progress.overBudget ? theme.colors.error : theme.colors.fgSecondary,
}]}>
{t('budget.used', { spent: progress.spent, pct: progress.percentage.toFixed(0) })}
</Text>
</View>
{/* 进度条 */}
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary }]}>
<View style={[
styles.bar,
{
width: `${Math.min(progress.percentage, 100)}%`,
backgroundColor: progress.overBudget ? theme.colors.error : theme.colors.accent,
},
]} />
</View>
{budget.categoryAccount && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
{budget.categoryAccount}
</Text>
)}
</Card>
</Pressable>
);
}}
formTitle={editing => (editing ? t('budget.editTitle') : t('budget.add'))}
formFields={editing => [
{ key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder'), defaultValue: editing?.name },
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), defaultValue: editing?.amount, keyboardType: 'decimal-pad' },
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly', defaultValue: editing?.period },
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder'), defaultValue: editing?.categoryAccount ?? '' },
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01', defaultValue: editing?.startDate },
]}
onSubmit={(values, editing) => {
const name = values.name?.trim() || t('budget.unnamed');
const amount = values.amount?.trim() || '0';
const period = (values.period as Budget['period']) || 'monthly';
const categoryAccount = values.categoryAccount?.trim() || undefined;
const startDate = values.startDate?.trim() || today;
const amtNum = parseFloat(amount);
if (isNaN(amtNum) || amtNum <= 0) {
Alert.alert(t('budget.invalidAmountTitle'), t('budget.invalidAmountDesc'));
return false;
}
if (editing) {
updateBudget(editing.id, { name, amount, period, categoryAccount, startDate });
} else {
addBudget({ id: generateId('bud'), name, amount, period, categoryAccount, startDate });
}
return true;
}}
onDelete={budget => removeBudget(budget.id)}
deleteConfirmText={budget => t('budget.deleteConfirm', { name: budget.name })}
deleteConfirmTitle={t('budget.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
}
/>
);
}
const styles = StyleSheet.create({
budgetRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
barWrap: { height: 8, borderRadius: 4, overflow: 'hidden', marginTop: 8 },
bar: { height: '100%', borderRadius: 4 },
});
+102
View File
@@ -0,0 +1,102 @@
/**
* plan.md1.1 category/index+ 1
*
* / chips + //
* metadataStorelinkedAccount Beancount
* P5 ManagementScreen headerContent / chips
*/
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme, createCommonStyles } from '../../theme';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n';
import { Card } from '../../components/ui/Card';
import { ManagementScreen } from '../../components/layout/ManagementScreen';
import { Touchable } from '../../components/ui/Touchable';
import type { Category } from '../../domain/taxonomy/categories';
export default function CategoryScreen() {
const { theme } = useTheme();
const t = useT();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const categories = useMetadataStore(s => s.categories);
const addCategory = useMetadataStore(s => s.addCategory);
const updateCategory = useMetadataStore(s => s.updateCategory);
const removeCategory = useMetadataStore(s => s.removeCategory);
/** 当前展示的分类类型(支出/收入),新增分支按此落库。 */
const [catType, setCatType] = useState<'expense' | 'income'>('expense');
return (
<ManagementScreen<Category>
title={t('category.title')}
items={categories.filter(c => c.type === catType)}
keyExtractor={cat => cat.id}
addLabel={catType === 'income' ? t('category.addIncome') : t('category.addExpense')}
headerContent={
<View style={styles.chipRow}>
{(['expense', 'income'] as const).map(tp => (
<Touchable
key={tp}
onPress={() => setCatType(tp)}
style={[commonStyles.chip, catType === tp && commonStyles.chipActive]}
>
<Text style={[commonStyles.chipText, catType === tp && commonStyles.chipTextActive]}>
{tp === 'expense' ? t('category.expense') : t('category.income')}
</Text>
</Touchable>
))}
</View>
}
renderItem={(cat, { openEdit, confirmDelete }) => (
<Card title={cat.name} onPress={openEdit} onLongPress={confirmDelete}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
{cat.keywords.length > 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{t('category.keywordsLabel')}: {cat.keywords.join(', ')}
</Text>
)}
</Card>
)}
formTitle={editing => (editing
? t('category.editTitle')
: catType === 'income' ? t('category.addIncome') : t('category.addExpense'))}
formFields={editing => [
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder'), defaultValue: editing?.name },
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder'), defaultValue: editing?.linkedAccount },
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder'), defaultValue: editing?.keywords.join(', ') },
]}
onSubmit={(values, editing) => {
if (editing) {
updateCategory(editing.id, {
name: values.name || editing.name,
linkedAccount: values.linkedAccount || editing.linkedAccount,
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
} else {
addCategory({
id: generateId('cat'),
name: values.name || t('common.untitled'),
type: catType,
linkedAccount: values.linkedAccount || (catType === 'income' ? 'Income:未分类' : 'Expenses:未分类'),
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
}
return true;
}}
onDelete={cat => removeCategory(cat.id)}
deleteConfirmText={cat => t('category.deleteConfirm', { name: cat.name })}
deleteConfirmTitle={t('category.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
}
/>
);
}
const styles = StyleSheet.create({
chipRow: { flexDirection: 'row', gap: 8 },
});
+235
View File
@@ -0,0 +1,235 @@
/**
* plan.md1.1 credit-card/index+ 1
*
* (/////) + //
* linkedAccount Beancount Liabilities
* P5 ManagementScreen renderItem
*/
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useLedgerStore } from '../../store/ledgerStore';
import { useT } from '../../i18n';
import { Card } from '../../components/ui/Card';
import { ManagementScreen } from '../../components/layout/ManagementScreen';
import { AccountCreateModal } from '../../components/account/AccountCreateModal';
import { Touchable } from '../../components/ui/Touchable';
import type { CreditCard } from '../../domain/finance/creditCards';
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/finance/creditCards';
export default function CreditCardScreen() {
const { theme } = useTheme();
const t = useT();
const creditCards = useMetadataStore(s => s.creditCards);
const addCreditCard = useMetadataStore(s => s.addCreditCard);
const updateCreditCard = useMetadataStore(s => s.updateCreditCard);
const removeCreditCard = useMetadataStore(s => s.removeCreditCard);
const ledger = useLedgerStore(s => s.ledger);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger?.transactions]);
const [isQuickAddingAccount, setIsQuickAddingAccount] = useState(false);
/** 过滤出所有以 Liabilities 开头的信用负债账户。 */
const liabilityAccounts = useMemo(() => {
if (!ledger?.accounts) return [];
const accountNames = Array.from(ledger.accounts.keys());
return accountNames.filter((name: string) => name.startsWith('Liabilities'));
}, [ledger?.accounts]);
const liabilityOptions = useMemo(() => {
return liabilityAccounts.map((name: string) => ({
label: name.replace(/^Liabilities:/, ''),
value: name,
}));
}, [liabilityAccounts]);
/** 预计算所有账户余额 Map(避免在渲染循环中重复遍历)。 */
const accountBalances = useMemo(() => {
const map = new Map<string, string>();
for (const tx of transactions) {
for (const p of tx.postings) {
if (p.account && p.amount) {
const prev = parseFloat(map.get(p.account) ?? '0');
map.set(p.account, (prev + parseFloat(p.amount)).toFixed(2));
}
}
}
return map;
}, [transactions]);
const getAccountBalance = (account: string): string => accountBalances.get(account) ?? '0.00';
const handleQuickAddAccount = (values: Record<string, string>) => {
const type = values.type || 'Liabilities';
const name = values.name?.trim() || 'CreditCard';
const fullAccountName = `${type}:${name}`;
autoOpenAccounts([fullAccountName]);
setIsQuickAddingAccount(false);
};
return (
<>
<ManagementScreen<CreditCard>
title={t('creditCard.title')}
items={creditCards}
keyExtractor={card => card.id}
addLabel={t('creditCard.add')}
emptyText={t('creditCard.empty')}
renderItem={(card, { openEdit, confirmDelete }) => {
const period = calculateBillingPeriod(card, new Date());
const statementAmount = calculateStatementAmount(transactions, card, period);
const currentBalance = getAccountBalance(card.linkedAccount);
const availableCredit = calculateAvailableCredit(card, currentBalance);
return (
<Card title={card.name} onPress={openEdit} onLongPress={confirmDelete}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBillingDay')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.billingDay} {t('creditCard.daySuffix')}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelPaymentDay')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.paymentDay} {t('creditCard.daySuffix')}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelLimit')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.creditLimit} {card.currency}</Text>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>{card.linkedAccount}</Text>
{/* 账单周期与应还 */}
<View style={[styles.billingBox, { borderColor: theme.colors.divider }]}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.billingPeriod')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{period.periodStart} ~ {period.periodEnd}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.dueDate')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.error }]}>{period.dueDate}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.statementAmount')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{statementAmount} {card.currency}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.availableCredit')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent }]}>{availableCredit} {card.currency}</Text>
</View>
</View>
</Card>
);
}}
formTitle={editing => (editing ? t('creditCard.editTitle') : t('creditCard.add'))}
onValuesChange={(changedKey: string, newValue: string, currentValues: Record<string, string>) => {
if (changedKey === 'linkedAccount' && newValue) {
const shortName = newValue.includes(':') ? newValue.slice(newValue.lastIndexOf(':') + 1) : newValue;
const updates: Record<string, string> = {};
if (shortName) {
updates.name = shortName;
if (shortName.includes('招商') || shortName.toUpperCase().includes('CMB')) updates.bankName = '招商银行';
else if (shortName.includes('支付宝') || shortName.includes('花呗')) updates.bankName = '支付宝';
else if (shortName.includes('微信') || shortName.includes('微粒贷')) updates.bankName = '微信';
else if (shortName.includes('建设') || shortName.toUpperCase().includes('CCB')) updates.bankName = '建设银行';
else if (shortName.includes('工商') || shortName.toUpperCase().includes('ICBC')) updates.bankName = '工商银行';
else if (shortName.includes('中国银行') || shortName.toUpperCase().includes('BOC')) updates.bankName = '中国银行';
else if (shortName.includes('农业') || shortName.toUpperCase().includes('ABC')) updates.bankName = '农业银行';
else if (shortName.includes('交通') || shortName.toUpperCase().includes('BOCOM')) updates.bankName = '交通银行';
else if (!currentValues.bankName) updates.bankName = shortName;
}
return updates;
}
}}
formFields={editing => [
// Row 1: linkedAccount (1.5) + name (1.0)
liabilityOptions.length > 0
? {
key: 'linkedAccount',
label: t('creditCard.fieldLinkedAccount'),
type: 'dropdown',
options: liabilityOptions,
placeholder: '请选择关联信用账户',
defaultValue: editing?.linkedAccount ?? '',
flex: 1.5,
row: 1,
}
: {
key: 'linkedAccount',
label: t('creditCard.fieldLinkedAccount'),
placeholder: 'Liabilities:CreditCard:CMB',
defaultValue: editing?.linkedAccount ?? '',
flex: 1.5,
row: 1,
},
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: editing?.name, flex: 1.0, row: 1 },
// Row 2: bankName (1.0) + lastFour (1.0) + currency (1.0)
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: '招商银行', defaultValue: editing?.bankName, flex: 1.0, row: 2 },
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: editing?.lastFour, keyboardType: 'numeric', flex: 1.0, row: 2 },
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: editing?.currency ?? 'CNY', flex: 1.0, row: 2 },
// Row 3: billingDay (1.0) + paymentDay (1.0) + creditLimit (1.2)
{ key: 'billingDay', label: t('creditCard.fieldBillingDay'), placeholder: '5', defaultValue: editing ? String(editing.billingDay) : '5', keyboardType: 'numeric', flex: 1.0, row: 3 },
{ key: 'paymentDay', label: t('creditCard.fieldPaymentDay'), placeholder: '25', defaultValue: editing ? String(editing.paymentDay) : '25', keyboardType: 'numeric', flex: 1.0, row: 3 },
{ key: 'creditLimit', label: t('creditCard.fieldLimit'), placeholder: '10000', defaultValue: editing?.creditLimit ?? '10000', keyboardType: 'decimal-pad', flex: 1.2, row: 3 },
]}
formExtra={() => (
<Touchable
onPress={() => setIsQuickAddingAccount(true)}
style={styles.quickAddBtn}
>
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontWeight: '600' }]}>
</Text>
</Touchable>
)}
onSubmit={(values, editing) => {
const card: Omit<CreditCard, 'id'> = {
name: values.name?.trim() || t('creditCard.unnamed'),
lastFour: values.lastFour?.trim() || '0000',
bankName: values.bankName?.trim() || 'UNKNOWN',
billingDay: parseInt(values.billingDay, 10) || 1,
paymentDay: parseInt(values.paymentDay, 10) || 1,
creditLimit: values.creditLimit?.trim() || '0',
currency: values.currency?.trim() || 'CNY',
linkedAccount: values.linkedAccount?.trim() || 'Liabilities:CreditCard',
};
if (editing) {
updateCreditCard(editing.id, card);
} else {
addCreditCard({ ...card, id: generateId('cc') });
}
return true;
}}
onDelete={card => removeCreditCard(card.id)}
deleteConfirmText={card => t('creditCard.deleteConfirm', { name: card.name })}
deleteConfirmTitle={t('creditCard.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
}
/>
{/* 快捷新建负债账户弹窗 */}
<AccountCreateModal
visible={isQuickAddingAccount}
defaultType="Liabilities"
onConfirm={handleQuickAddAccount}
onCancel={() => setIsQuickAddingAccount(false)}
/>
</>
);
}
const styles = StyleSheet.create({
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
quickAddBtn: { paddingVertical: 6, alignItems: 'center', marginTop: 4 },
});
+465
View File
@@ -0,0 +1,465 @@
import React, { useState } from 'react';
import type { DuplicateDetail, DedupConfidence } from '../../domain/transaction/dedup';
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
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';
// 实例化纯 JS GBK 解码器(Hermes 原生 TextDecoder 仅支持 UTF-8,需强迫 text-encoding-gbk 返回其纯 JS 实现)
const GbkTextDecoder = (() => {
const g = globalThis as Record<string, unknown>;
const origDecoder = g.TextDecoder;
const origEncoder = g.TextEncoder;
try {
g.TextDecoder = undefined;
g.TextEncoder = undefined;
const dec = require('text-encoding-gbk').TextDecoder;
return dec;
} catch {
return origDecoder;
} finally {
g.TextDecoder = origDecoder;
g.TextEncoder = origEncoder;
}
})();
import { useLedgerStore } from '../../store/ledgerStore';
import { useImportStore } from '../../store/importStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/ui/Card';
import { Button } from '../../components/ui/Button';
import { DedupBanner } from '../../components/transaction/DedupBanner';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
import { classifyWithCategories } from '../../domain/rules/rules';
import { validateTransaction } from '../../domain/core/ledger';
import type { ImportedEvent } from '../../domain/core/types';
// 纯 JS 实现 Base64 解码为字节数组
function base64ToBytes(base64: string): Uint8Array {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const lookup = new Uint8Array(256);
for (let i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
let bufferLength = base64.length * 0.75;
if (base64[base64.length - 1] === '=') {
bufferLength--;
if (base64[base64.length - 2] === '=') {
bufferLength--;
}
}
const bytes = new Uint8Array(bufferLength);
let p = 0;
for (let i = 0; i < base64.length; i += 4) {
const base64code1 = lookup[base64.charCodeAt(i)];
const base64code2 = lookup[base64.charCodeAt(i + 1)];
const base64code3 = lookup[base64.charCodeAt(i + 2)];
const base64code4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (base64code1 << 2) | (base64code2 >> 4);
if (p < bufferLength) bytes[p++] = ((base64code2 & 15) << 4) | (base64code3 >> 2);
if (p < bufferLength) bytes[p++] = ((base64code3 & 3) << 6) | (base64code4 & 63);
}
return bytes;
}
export default function ImportScreen() {
const { theme } = useTheme();
const t = useT();
const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
const events = useImportStore(s => s.events);
const pendingDrafts = useImportStore(s => s.pendingDrafts);
const lastResult = useImportStore(s => s.lastResult);
const importCsv = useImportStore(s => s.importCsv);
const processEvents = useImportStore(s => s.process);
const confirmDraft = useImportStore(s => s.confirmDraft);
const confirmDrafts = useImportStore(s => s.confirmDrafts);
const [status, setStatus] = useState('');
// 疑似重复账单交互状态
const [manualDuplicates, setManualDuplicates] = useState<ImportedEvent[]>([]);
const [showDuplicates, setShowDuplicates] = useState(false);
const handleSelectFile = async () => {
try {
const res = await DocumentPicker.getDocumentAsync({
type: '*/*', // 允许所有类型,防部分手机系统限制
copyToCacheDirectory: true,
});
if (res.canceled) return;
const file = res.assets[0];
const isExcel = file.name.toLowerCase().endsWith('.xlsx') || file.name.toLowerCase().endsWith('.xls');
// 1. 读取为 Base64 以保证字节完整
const base64 = await FileSystem.readAsStringAsync(file.uri, {
encoding: FileSystem.EncodingType.Base64,
});
let content = '';
if (isExcel) {
// 2a. Excel 格式:直接使用 xlsx 库解析并转换为内存中的 CSV 字符串
const workbook = XLSX.read(base64, { type: 'base64' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
content = XLSX.utils.sheet_to_csv(worksheet);
} else {
// 2b. 解码为 Uint8Array 进行 CSV 文本处理
const bytes = base64ToBytes(base64);
// 3. 编码自适应解码:优先尝试 UTF-8,若出错则回退至中文 GBK
try {
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
content = utf8Decoder.decode(bytes);
} catch {
// UTF-8 校验失败,回退到国标 GBK 解码(利用 text-encoding-gbk 保证 Hermes 兼容)
try {
const gbkDecoder = new GbkTextDecoder('gbk');
content = gbkDecoder.decode(bytes);
} catch (err) {
throw new Error(t('importFlow.decodeFail', { error: err instanceof Error ? err.message : String(err) }));
}
}
}
// 4. 自适应解析通道:根据文件名或内容关键字区分微信/支付宝
const isWeChat = file.name.includes('微信') || file.name.toLowerCase().includes('wechat') || content.includes('微信支付');
const adapter = isWeChat ? 'wechat-csv-v1' : 'alipay-csv-v1';
// 5. 导入数据并清空历史状态
importCsv(content, adapter);
setManualDuplicates([]);
setStatus(t('importFlow.importSuccess', {
format: isExcel ? t('importFlow.formatExcel') : t('importFlow.formatCsv'),
name: file.name,
}));
// 6. 自动执行 Pipeline,用户无需手动查找运行按钮
if (ledger) {
setStatus(t('importFlow.pipelineRunning'));
processEvents(ledger, ledger.transactions || []).then(result => {
setManualDuplicates(result.duplicates);
setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length }));
}).catch(e => {
setStatus(t('importFlow.pipelineFail', { error: String(e) }));
});
}
} catch (e) {
setStatus(t('importFlow.pickFail', { error: e instanceof Error ? e.message : String(e) }));
Alert.alert(t('importFlow.importFailTitle'), e instanceof Error ? e.message : String(e));
}
};
const runPipeline = () => {
if (!ledger) return;
processEvents(ledger, ledger.transactions || []).then(result => {
setManualDuplicates(result.duplicates);
setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount }));
}).catch(e => setStatus(String(e)));
};
const onConfirm = async (index: number) => {
if (!ledger) return;
const item = pendingDrafts[index];
if (!item) return;
try {
// 自动开户:收集所有草稿中的账户,检查哪些未 open
const currentLedger = useLedgerStore.getState().ledger!;
const unopened = item.draft.postings
.map(p => p.account)
.filter(acc => !currentLedger.accounts.has(acc));
if (unopened.length > 0) {
await autoOpenAccounts(unopened);
}
await addTransaction(item.draft);
confirmDraft(index);
setStatus(t('importFlow.confirmed'));
} catch (e) {
const errStr = e instanceof Error ? e.message : String(e);
setStatus(t('importFlow.commitFail', { error: errStr }));
Alert.alert(t('importFlow.commitFailTitle'), errStr);
}
};
const onConfirmAll = async () => {
const currentLedger = useLedgerStore.getState().ledger;
if (!currentLedger || pendingDrafts.length === 0) return;
setStatus(t('importFlow.batchPreparing'));
// 1. 收集所有草稿中引用的账户,静默自动开户
const allAccounts = new Set<string>();
for (const item of pendingDrafts) {
for (const posting of item.draft.postings) {
allAccounts.add(posting.account);
}
}
const unopened = Array.from(allAccounts).filter(acc => !currentLedger.accounts.has(acc));
if (unopened.length > 0) {
try {
await autoOpenAccounts(unopened);
} catch (err) {
setStatus(t('importFlow.autoOpenFail', { error: err instanceof Error ? err.message : String(err) }));
Alert.alert(t('importFlow.autoOpenFailTitle'), err instanceof Error ? err.message : String(err));
return;
}
}
// 2. 重新获取 ledger(开户后已更新),再次校验
const updatedLedger = useLedgerStore.getState().ledger!;
const validIndices: number[] = [];
const validDrafts: typeof pendingDrafts = [];
const failedPayees: string[] = [];
for (let i = 0; i < pendingDrafts.length; i++) {
const item = pendingDrafts[i];
const validation = validateTransaction(item.draft, updatedLedger);
if (validation.valid) {
validIndices.push(i);
validDrafts.push(item);
} else {
failedPayees.push(`${item.draft.payee || item.draft.narration || t('common.untitled')}: ${validation.errors.join('; ')}`);
}
}
// 3. 执行真正的批量入账
if (validDrafts.length > 0) {
await commitBatch(validIndices, validDrafts, failedPayees);
} else if (failedPayees.length > 0) {
Alert.alert(t('importFlow.commitFailTitle'), t('importFlow.batchNoValid', { reasons: failedPayees.join('\n') }));
}
};
const commitBatch = async (indices: number[], drafts: typeof pendingDrafts, failedPayees: string[]) => {
try {
// 使用 ledgerStore 的 appendTransactionsBatch(在互斥锁内完成读取+拼接+写入)
const appendTransactionsBatch = useLedgerStore.getState().appendTransactionsBatch;
await appendTransactionsBatch(drafts.map(d => d.draft));
// 从待确认中批量移除
confirmDrafts(indices);
const successCount = drafts.length;
const failCount = failedPayees.length;
if (failCount === 0) {
setStatus(t('importFlow.batchAllSuccess', { count: successCount }));
Alert.alert(t('importFlow.confirmed'), t('importFlow.batchAllSuccess', { count: successCount }));
} else {
setStatus(t('importFlow.batchPartial', { success: successCount, fail: failCount }));
Alert.alert(
t('importFlow.batchResultTitle'),
t('importFlow.batchResultBody', { success: successCount, fail: failCount, reasons: failedPayees.join('\n') })
);
}
} catch (e) {
setStatus(t('importFlow.batchFail', { error: e instanceof Error ? e.message : String(e) }));
Alert.alert(t('importFlow.batchErrorTitle'), e instanceof Error ? e.message : String(e));
}
};
const handleAcceptDuplicate = async (event: ImportedEvent) => {
if (!ledger) return;
try {
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const classification = classifyWithCategories(event, rules, categories, ledger);
await addTransaction(classification.draft, undefined, true);
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
setStatus(t('importFlow.forcedImport'));
Alert.alert('记账成功', `已成功将「${event.counterparty || event.memo || '交易'}」单独记入账本`);
} catch (err) {
setStatus(String(err));
Alert.alert('记账失败', String(err));
}
};
const handleLinkDuplicate = async (event: ImportedEvent) => {
if (!ledger) return;
try {
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const classification = classifyWithCategories(event, rules, categories, ledger);
// 寻找对应的历史匹配交易
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === event.id);
const matchedTx = detail?.matchedWith?.rawTransaction;
const linkTag = matchedTx?.links?.[0] || 'lnk-' + Math.random().toString(36).slice(2, 8);
// 为新草稿添加链接
const newDraft = {
...classification.draft,
links: Array.from(new Set([...(classification.draft.links || []), linkTag])),
};
await addTransaction(newDraft, undefined, true);
// 如果有历史匹配交易且它还没有此 linkTag,同步写回历史交易 raw
if (matchedTx && !matchedTx.links.includes(linkTag)) {
const currentRaw = matchedTx.raw;
const firstLineEnd = currentRaw.indexOf('\n');
const header = firstLineEnd !== -1 ? currentRaw.slice(0, firstLineEnd) : currentRaw;
const rest = firstLineEnd !== -1 ? currentRaw.slice(firstLineEnd) : '';
const updatedHeader = `${header} ^${linkTag}`;
const updatedRaw = `${updatedHeader}${rest}`;
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
if (mobileBean.includes(currentRaw)) {
const newContent = mobileBean.replace(currentRaw, updatedRaw);
await replaceMobileBean(newContent);
}
}
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
setStatus(`已成功关联交易 (标签 ^${linkTag})`);
Alert.alert('关联成功', `已成功将「${event.counterparty || event.memo || '交易'}」与历史账单关联(关联标签 ^${linkTag}`);
} catch (err) {
setStatus(String(err));
Alert.alert('关联失败', String(err));
}
};
const handleRejectDuplicate = (event: ImportedEvent) => {
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
setStatus(t('importFlow.ignored'));
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<ScreenHeader title={t('tab.import')} />
<FlatList
data={pendingDrafts}
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>
<Text style={[theme.typography.bodySmall, styles.mono, { color: theme.colors.fgSecondary }]}>
{item.draft.postings.map(p => `${p.account} ${p.amount} ${p.currency ?? ''}`).join('\n')}
</Text>
{item.categoryFallbackReason && (
<Text style={[theme.typography.caption, { color: theme.colors.warning, marginTop: 4 }]}>
{t('importFlow.fallbackWarn', { reason: item.categoryFallbackReason })}
</Text>
)}
<View style={{ marginTop: 8 }}>
<Button label={t('importFlow.confirm')} onPress={() => onConfirm(index)} />
</View>
</Card>
)}
ListHeaderComponent={
<View style={{ gap: 12, marginBottom: 12 }}>
<Card title={t('importFlow.title')}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 12 }]}>
{t('importFlow.hint')}
</Text>
<View style={styles.buttonCol}>
<Button label={t('importFlow.selectFile')} onPress={handleSelectFile} />
</View>
{events.length > 0 && (
<View style={{ marginTop: 12, gap: 8 }}>
<Button label={t('importFlow.processButton', { count: events.length })} onPress={runPipeline} variant="secondary" />
{pendingDrafts.length > 0 && (
<Button label={t('importFlow.confirmAll', { count: pendingDrafts.length })} onPress={onConfirmAll} variant="primary" />
)}
</View>
)}
{lastResult && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
{t('importFlow.result', { drafts: lastResult.drafts.length, duplicates: lastResult.duplicates.length, transfers: lastResult.transferCount })}
</Text>
)}
</Card>
{/* 疑似重复账单展示面板 */}
{manualDuplicates.length > 0 && (
<Card title={t('importFlow.duplicatesTitle', { count: manualDuplicates.length })}>
<Pressable
onPress={() => setShowDuplicates(!showDuplicates)}
style={styles.collapseHeader}
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>
{showDuplicates ? t('importFlow.collapseList') : t('importFlow.expandDuplicates')}
</Text>
<Ionicons name={showDuplicates ? 'chevron-up' : 'chevron-down'} size={16} color={theme.colors.accent} />
</Pressable>
{showDuplicates && (
<View style={styles.duplicatesList}>
{manualDuplicates.map((item) => {
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === item.id);
const matchedTx = detail?.matchedWith?.rawTransaction;
const reason = detail?.reason || t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') });
const result = {
isDuplicate: true,
confidence: (detail?.confidence ?? 'medium') as DedupConfidence,
reason,
};
return (
<Card
key={item.id}
title={`${item.occurredAt} · ${item.counterparty || t('importFlow.unknownSource')}`}
onPress={() => handleLinkDuplicate(item)}
>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{item.memo || t('importFlow.noDesc')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })}
</Text>
{matchedTx && (
<View style={{ backgroundColor: `${theme.colors.accent}12`, padding: 8, borderRadius: 8, marginVertical: 4 }}>
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontWeight: '700' }]}>
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, marginTop: 2 }]}>
{matchedTx.date} {matchedTx.payee ? matchedTx.payee + ' - ' : ''}{matchedTx.narration} ({matchedTx.postings[0]?.amount} {matchedTx.postings[0]?.currency})
</Text>
</View>
)}
<DedupBanner
result={result}
onAccept={() => handleAcceptDuplicate(item)}
onReject={() => handleRejectDuplicate(item)}
onLink={() => handleLinkDuplicate(item)}
/>
</Card>
);
})}
</View>
)}
</Card>
)}
</View>
}
ListFooterComponent={
status ? <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 20 }]}>{status}</Text> : null
}
contentContainerStyle={styles.content}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16, paddingBottom: 64 },
buttonCol: { gap: 8, marginTop: 4 },
mono: { fontVariant: ['tabular-nums'], lineHeight: 20, marginTop: 8 },
collapseHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
duplicatesList: { gap: 12, marginTop: 8 },
});
+155
View File
@@ -0,0 +1,155 @@
/**
*
*
* (///) + //
* P5 ManagementScreen /
* handlers.openEdit/confirmDelete
*/
import React from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/ui/Card';
import { ManagementScreen } from '../../components/layout/ManagementScreen';
import { toDateString } from '../../domain/core/decimal';
import type { RecurringTransaction } from '../../domain/finance/recurring';
export default function RecurringScreen() {
const { theme } = useTheme();
const t = useT();
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const addRecurring = useMetadataStore(s => s.addRecurringTransaction);
const updateRecurring = useMetadataStore(s => s.updateRecurringTransaction);
const removeRecurring = useMetadataStore(s => s.removeRecurringTransaction);
// 用本地时区格式化,避免 toISOString 的 UTC 偏移导致负时区日期错位
const today = toDateString(new Date());
return (
<ManagementScreen<RecurringTransaction>
title={t('recurring.title')}
items={recurringTransactions}
keyExtractor={item => item.id}
addLabel={t('recurring.add')}
emptyText={t('recurring.empty')}
renderItem={(item, { openEdit, confirmDelete }) => {
const amount = item.draft.postings[1]?.amount || '0.00';
const fromAcc = item.draft.postings[0]?.account || '';
const toAcc = item.draft.postings[1]?.account || '';
return (
<Card title={item.name}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{item.frequency === 'monthly' ? t('recurring.freqMonthly') : item.frequency === 'weekly' ? t('recurring.freqWeekly') : item.frequency === 'yearly' ? t('recurring.freqYearly') : t('recurring.freqDaily')}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.nextDue')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{item.nextDueDate}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.flow')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{fromAcc.split(':').pop()} {toAcc.split(':').pop()}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.perAmount')}</Text>
<Text style={[theme.typography.body, { color: theme.colors.financial.expense, fontWeight: '700' }]}>{amount} CNY</Text>
</View>
{/* 显式编辑/删除按钮(本页不用长按手势,交互更显式) */}
<View style={styles.cardActions}>
<Pressable
onPress={openEdit}
style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]}
>
<Ionicons name="create-outline" size={14} color={theme.colors.accent} />
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
{t('recurring.edit')}
</Text>
</Pressable>
<Pressable
onPress={confirmDelete}
style={[styles.actionBtn, { borderColor: theme.colors.border }]}
>
<Ionicons name="trash-outline" size={14} color={theme.colors.error} />
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
{t('recurring.delete')}
</Text>
</Pressable>
</View>
</Card>
);
}}
formTitle={editing => (editing ? t('recurring.editTitle') : t('recurring.addTitle'))}
formFields={editing => [
{ key: 'name', label: t('recurring.fieldName'), placeholder: t('recurring.fieldNamePlaceholder'), defaultValue: editing?.name },
{ key: 'fromAccount', label: t('recurring.fieldFromAccount'), placeholder: '例如: Assets:支付宝余额', defaultValue: editing?.draft.postings[0]?.account?.replace(/^-/, '') || 'Assets:支付宝余额' },
{ key: 'toAccount', label: t('recurring.fieldToAccount'), placeholder: '例如: Expenses:Shopping', defaultValue: editing?.draft.postings[1]?.account || 'Expenses:Shopping' },
{ key: 'amount', label: t('recurring.fieldAmount'), placeholder: '例如: 6.00', defaultValue: editing?.draft.postings[1]?.amount },
{ key: 'frequency', label: t('recurring.fieldFrequency'), placeholder: 'monthly', defaultValue: editing?.frequency || 'monthly' },
{ key: 'nextDueDate', label: t('recurring.fieldNextDue'), placeholder: 'YYYY-MM-DD', defaultValue: editing?.nextDueDate || today },
]}
onSubmit={(values, editing) => {
const nameVal = values.name?.trim();
const fromAccount = values.fromAccount?.trim();
const toAccount = values.toAccount?.trim();
const amountVal = values.amount?.trim();
const freq = (values.frequency?.trim() || 'monthly') as RecurringTransaction['frequency'];
const dateVal = values.nextDueDate?.trim() || today;
if (!nameVal || !fromAccount || !toAccount || !amountVal) {
Alert.alert(t('recurring.inputError'), t('recurring.inputErrorDesc'));
return false;
}
const draft = {
date: dateVal,
narration: nameVal,
postings: [
{ account: fromAccount, amount: `-${amountVal}`, currency: 'CNY' },
{ account: toAccount, amount: amountVal, currency: 'CNY' },
],
};
if (editing) {
updateRecurring(editing.id, {
name: nameVal,
draft,
frequency: freq,
nextDueDate: dateVal,
});
Alert.alert(t('recurring.editSuccess'), t('recurring.editSuccessDesc', { name: nameVal }));
} else {
addRecurring({
id: 'rec_' + generateId(),
name: nameVal,
draft,
frequency: freq,
interval: 1,
nextDueDate: dateVal,
enabled: true,
});
Alert.alert(t('recurring.addSuccess'), t('recurring.addSuccessDesc', { name: nameVal }));
}
// 弹窗关闭由模板接管(返回 true)
return true;
}}
onDelete={item => removeRecurring(item.id)}
deleteConfirmText={item => t('recurring.deleteConfirm', { name: item.name })}
deleteConfirmTitle={t('recurring.deleteTitle')}
/>
);
}
const styles = StyleSheet.create({
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' },
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
});
+70
View File
@@ -0,0 +1,70 @@
/**
*
* ${placeholder}
* P5 ManagementScreen //
*/
import React from 'react';
import { Pressable, Text } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/ui/Card';
import { ManagementScreen } from '../../components/layout/ManagementScreen';
interface RemarkTemplate {
id: string;
name: string;
template: string;
}
export default function RemarkTemplateScreen() {
const { theme } = useTheme();
const t = useT();
const templates = useMetadataStore(s => s.remarkTemplates);
const addTemplate = useMetadataStore(s => s.addRemarkTemplate);
const updateTemplate = useMetadataStore(s => s.updateRemarkTemplate);
const removeTemplate = useMetadataStore(s => s.removeRemarkTemplate);
return (
<ManagementScreen<RemarkTemplate>
title={t('remark.title')}
items={templates}
keyExtractor={tpl => tpl.id}
addLabel={t('remark.add')}
emptyText={t('remark.empty')}
renderItem={(tpl, { openEdit, confirmDelete }) => (
<Pressable onPress={openEdit} onLongPress={confirmDelete}>
<Card title={tpl.name}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
{tpl.template}
</Text>
</Card>
</Pressable>
)}
formTitle={editing => (editing ? t('remark.editTitle') : t('remark.add'))}
formFields={editing => [
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮', defaultValue: editing?.name },
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}', defaultValue: editing?.template },
]}
onSubmit={(values, editing) => {
const name = values.name?.trim() || t('common.untitled');
const template = values.template?.trim() || '';
if (editing) {
updateTemplate(editing.id, { name, template });
} else {
addTemplate({ id: generateId('tpl'), name, template });
}
return true;
}}
onDelete={tpl => removeTemplate(tpl.id)}
deleteConfirmText={tpl => t('remark.deleteConfirm', { name: tpl.name })}
deleteConfirmTitle={t('remark.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
}
/>
);
}
+105
View File
@@ -0,0 +1,105 @@
/**
* plan.md1.1 rules+
*
* ( ) + //
* BillPipeline ( AutoAccounting RuleGenerator)
* P5 ManagementScreen //
*/
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/ui/Card';
import { ManagementScreen } from '../../components/layout/ManagementScreen';
import type { Rule } from '../../domain/core/types';
export default function RulesScreen() {
const { theme } = useTheme();
const t = useT();
const rules = useMetadataStore(s => s.rules);
const addRule = useMetadataStore(s => s.addRule);
const updateRule = useMetadataStore(s => s.updateRule);
const removeRule = useMetadataStore(s => s.removeRule);
const formatCondition = (rule: Rule): string => {
const parts: string[] = [];
if (rule.counterpartyContains) parts.push(t('rules.condCounterparty', { val: rule.counterpartyContains }));
if (rule.memoContains) parts.push(t('rules.condMemo', { val: rule.memoContains }));
return parts.length > 0 ? parts.join(' · ') : t('rules.condNone');
};
return (
<ManagementScreen<Rule>
title={t('tab.rules')}
items={[...rules].sort((a, b) => b.priority - a.priority)}
keyExtractor={rule => rule.id}
addLabel={t('rules.add')}
emptyText={t('rules.empty')}
renderItem={(rule, { openEdit, confirmDelete }) => (
<Card>
<Pressable
onPress={openEdit}
onLongPress={confirmDelete}
style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}
>
<View style={styles.ruleHeader}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}>{rule.narration || rule.id}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.accent }]}>P{rule.priority}</Text>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{formatCondition(rule)}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}>
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
</Text>
</View>
</Pressable>
</Card>
)}
formTitle={editing => (editing ? t('rules.editTitle') : t('rules.add'))}
formFields={editing => [
{ key: 'priority', label: t('rules.fieldPriority'), placeholder: '100', defaultValue: editing ? String(editing.priority) : '', keyboardType: 'numeric' },
{ key: 'counterpartyContains', label: t('rules.fieldCounterparty'), placeholder: '咖啡', defaultValue: editing?.counterpartyContains ?? '' },
{ key: 'memoContains', label: t('rules.fieldMemo'), placeholder: '', defaultValue: editing?.memoContains ?? '' },
{ key: 'sourceAccount', label: t('rules.fieldSourceAccount'), placeholder: 'Assets:支付宝余额', defaultValue: editing?.sourceAccount ?? '' },
{ key: 'categoryAccount', label: t('rules.fieldCategoryAccount'), placeholder: 'Expenses:餐饮', defaultValue: editing?.categoryAccount ?? '' },
{ key: 'narration', label: t('rules.fieldNarration'), placeholder: '咖啡', defaultValue: editing?.narration ?? '' },
{ key: 'tags', label: t('rules.fieldTags'), placeholder: 'food', defaultValue: editing?.tags?.join(', ') ?? '' },
]}
onSubmit={(values, editing) => {
const rule: Omit<Rule, 'id' | 'hits'> = {
priority: parseInt(values.priority, 10) || 0,
counterpartyContains: values.counterpartyContains?.trim() || undefined,
memoContains: values.memoContains?.trim() || undefined,
sourceAccount: values.sourceAccount?.trim() || 'Assets:Unknown',
categoryAccount: values.categoryAccount?.trim() || 'Expenses:未分类',
narration: values.narration?.trim() || undefined,
tags: values.tags ? values.tags.split(/[,\s]+/).filter(Boolean) : [],
};
if (editing) {
updateRule(editing.id, rule);
} else {
addRule({ ...rule, id: generateId('rule'), hits: 0 });
}
return true;
}}
onDelete={rule => removeRule(rule.id)}
deleteConfirmText={rule => t('rules.deleteConfirm', { name: rule.id })}
deleteConfirmTitle={t('rules.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
}
/>
);
}
const styles = StyleSheet.create({
ruleHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
});
+158
View File
@@ -0,0 +1,158 @@
/**
* LLM / AI P8
*
*
* - AI openai / gemini / deepseek
* - API Key
* - Base URL
* -
* - AI
*/
import React, { useState } from 'react';
import { KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTheme } from '../../theme';
import { createCommonStyles } from '../../theme/commonStyles';
import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { Card } from '../../components/ui/Card';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
const PROVIDERS: { key: string; label: string }[] = [
{ key: 'openai', label: 'OpenAI' },
{ key: 'gemini', label: 'Gemini' },
{ key: 'deepseek', label: 'DeepSeek' },
];
const PROVIDER_DEFAULT_URLS: Record<string, string> = {
openai: 'https://api.openai.com/v1',
gemini: 'https://generativelanguage.googleapis.com/v1beta',
deepseek: 'https://api.deepseek.com/v1',
};
const PROVIDER_DEFAULT_MODELS: Record<string, string> = {
openai: 'gpt-4o-mini',
gemini: 'gemini-2.0-flash',
deepseek: 'deepseek-chat',
};
export default function AiSettingsScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT();
const aiEnabled = useSettingsStore(s => s.aiEnabled);
const aiProviderId = useSettingsStore(s => s.aiProviderId);
const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || '';
const aiModel = useSettingsStore(s => s.aiModel) || '';
const updateAiConfig = useSettingsStore(s => s.updateAiConfig);
const [localKey, setLocalKey] = useState(aiApiKey);
const [localUrl, setLocalUrl] = useState(aiBaseUrl);
const [localModel, setLocalModel] = useState(aiModel);
const inputStyle = {
backgroundColor: theme.colors.bgTertiary,
color: theme.colors.fgPrimary,
borderRadius: theme.radii.sm,
padding: 12,
fontSize: 14,
marginTop: 6,
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<ScreenHeader title={t('settings.llmTitle')} />
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]} keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive">
{/* AI 总开关 */}
<Card>
<View style={styles.row}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.enableAiHint')}
</Text>
<Switch
value={aiEnabled}
onValueChange={val => updateAiConfig({ aiEnabled: val })}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* 服务商选择 */}
<Card title={t('automation.aiProviderLabel')}>
<View style={styles.chipRow}>
{PROVIDERS.map(p => (
<Pressable
key={p.key}
onPress={() => updateAiConfig({ aiProviderId: p.key as 'openai' | 'gemini' | 'deepseek' })}
style={[
commonStyles.chip,
aiProviderId === p.key && commonStyles.chipActive,
]}
>
<Text style={[commonStyles.chipText, aiProviderId === p.key && commonStyles.chipTextActive]}>
{p.label}
</Text>
</Pressable>
))}
</View>
</Card>
{/* API Key */}
<Card title={t('settings.aiFieldApiKey')}>
<TextInput
style={inputStyle}
value={localKey}
onChangeText={setLocalKey}
onBlur={() => updateAiConfig({ aiApiKey: localKey })}
placeholder="sk-..."
placeholderTextColor={theme.colors.fgSecondary}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
/>
</Card>
{/* Base URL */}
<Card title={t('settings.aiFieldBaseUrl')}>
<TextInput
style={inputStyle}
value={localUrl}
onChangeText={setLocalUrl}
onBlur={() => updateAiConfig({ aiBaseUrl: localUrl })}
placeholder={PROVIDER_DEFAULT_URLS[aiProviderId] || PROVIDER_DEFAULT_URLS.openai}
placeholderTextColor={theme.colors.fgSecondary}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</Card>
{/* 模型 */}
<Card title={t('settings.aiFieldModel')}>
<TextInput
style={inputStyle}
value={localModel}
onChangeText={setLocalModel}
onBlur={() => updateAiConfig({ aiModel: localModel })}
placeholder={PROVIDER_DEFAULT_MODELS[aiProviderId] || PROVIDER_DEFAULT_MODELS.openai}
placeholderTextColor={theme.colors.fgSecondary}
autoCapitalize="none"
autoCorrect={false}
/>
</Card>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16, paddingBottom: 64 },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
chipRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' },
});
+42
View File
@@ -0,0 +1,42 @@
/** 解析诊断(自交易页迁入,spec §7.2/§7.4 数据组)。 */
import React from 'react';
import { ScrollView, StyleSheet, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLedgerStore } from '../../store/ledgerStore';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/ui/Card';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
export default function DiagnosticsScreen() {
const { theme } = useTheme();
const t = useT();
const ledger = useLedgerStore(s => s.ledger);
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<ScreenHeader title={t('settings.diagnostics')} />
<ScrollView contentContainerStyle={styles.content}>
<Card title={t('diagnostics.title')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.syntaxErrors', { count: ledger?.diagnostics.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.unsupported', { count: ledger?.unsupported.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.balanceAssertions', { count: ledger?.balances.length ?? 0 })}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
{t('diagnostics.hint')}
</Text>
</Card>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16 },
});
+461
View File
@@ -0,0 +1,461 @@
/**
* -> ->
*
*
* 1. +
* 2. 4 DEBUG/INFO/WARN/ERROR/Tag
* 3. JSON data Stack Trace
* 4. /
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
AppState,
FlatList,
Modal,
Pressable,
ScrollView,
Share,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
import { useT } from '../../i18n';
import { useTheme } from '../../theme';
import { LogEntry, LogFileInfo, LogLevel, LEVEL_LABELS, logger } from '../../utils/logger';
export default function LogsScreen() {
const { theme } = useTheme();
const t = useT();
const [selectedFileDate, setSelectedFileDate] = useState<string | null>(null); // null 表示“今天(实时)”
const [logFiles, setLogFiles] = useState<LogFileInfo[]>([]);
const [fileLogs, setFileLogs] = useState<LogEntry[]>([]);
const [liveLogs, setLiveLogs] = useState<LogEntry[]>([]);
const [levelFilter, setLevelFilter] = useState<LogLevel | 'ALL'>('ALL');
const [dateModalOpen, setDateModalOpen] = useState(false);
const selectedFile = useMemo(() => {
return logFiles.find(f => f.date === selectedFileDate);
}, [logFiles, selectedFileDate]);
const [searchQuery, setSearchQuery] = useState<string>('');
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
// 刷新日志文件列表
const refreshFileList = useCallback(async () => {
const files = await logger.getLogFiles();
setLogFiles(files);
}, []);
// 刷新实时内存日志与历史日志
const refreshLogs = useCallback(async () => {
// 强制刷新日志写入队列
await logger.flushQueue();
await refreshFileList();
if (selectedFileDate === null) {
const todayLogs = await logger.getTodayLogs();
setLiveLogs(todayLogs);
} else {
const loaded = await logger.loadLogsFromFile(selectedFileDate);
setFileLogs(loaded);
}
}, [selectedFileDate, refreshFileList]);
useEffect(() => {
void refreshLogs();
}, [refreshLogs]);
// 自动刷新:「今天(实时)」视图每 3 秒轮询;切回前台时立即刷新
const refreshRef = useRef(refreshLogs);
refreshRef.current = refreshLogs;
useEffect(() => {
if (selectedFileDate !== null) return; // 历史文件不轮询
const timer = setInterval(() => { void refreshRef.current(); }, 3000);
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') void refreshRef.current();
});
return () => { clearInterval(timer); sub.remove(); };
}, [selectedFileDate]);
// 当前激活显示的原始日志列表
const rawLogs = selectedFileDate === null ? liveLogs : fileLogs;
// 过滤后的日志列表(按时间倒序排列:最新的日志在前面)
const filteredLogs = useMemo(() => {
const list = rawLogs.filter(e => {
// 级别过滤
if (levelFilter !== 'ALL' && e.level !== levelFilter) {
return false;
}
// 搜索过滤
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase();
const msgMatch = e.message.toLowerCase().includes(q);
const tagMatch = e.tag.toLowerCase().includes(q);
const dataMatch = e.data ? JSON.stringify(e.data).toLowerCase().includes(q) : false;
if (!msgMatch && !tagMatch && !dataMatch) {
return false;
}
}
return true;
});
// 倒序排列(最新在前)
return list.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}, [rawLogs, levelFilter, searchQuery]);
// 统计各级别数量
const counts = useMemo(() => {
const res = { ALL: rawLogs.length, [LogLevel.DEBUG]: 0, [LogLevel.INFO]: 0, [LogLevel.WARN]: 0, [LogLevel.ERROR]: 0 };
for (const item of rawLogs) {
if (res[item.level] !== undefined) {
res[item.level]++;
}
}
return res;
}, [rawLogs]);
// 导出日志文件/文本
const handleExport = async () => {
try {
const exportText = logger.exportAsFormattedText(filteredLogs);
if (!exportText || filteredLogs.length === 0) {
Alert.alert(t('common.error'), t('logs.empty'));
return;
}
if (await Sharing.isAvailableAsync()) {
const fileDateStr = selectedFileDate ?? new Date().toISOString().slice(0, 10);
const tempPath = `${FileSystem.cacheDirectory}app-log-${fileDateStr}-${Date.now()}.txt`;
await FileSystem.writeAsStringAsync(tempPath, exportText);
await Sharing.shareAsync(tempPath, {
mimeType: 'text/plain',
dialogTitle: t('logs.export'),
});
} else {
await Share.share({
message: exportText,
title: t('logs.title'),
});
}
} catch (e) {
Alert.alert(t('error.exportFail'), e instanceof Error ? e.message : String(e));
}
};
// 清空所有日志
const handleClear = () => {
Alert.alert(
t('logs.clearConfirmTitle'),
t('logs.clearConfirmDesc'),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('logs.clear'),
style: 'destructive',
onPress: async () => {
await logger.clearAllLogs();
setSelectedFileDate(null);
await refreshLogs();
Alert.alert(t('common.confirm'), t('logs.clearSuccess'));
},
},
],
);
};
// 获取级别对应的色彩
const getLevelStyle = (level: LogLevel) => {
switch (level) {
case LogLevel.DEBUG:
return { bg: theme.colors.bgTertiary, fg: theme.colors.fgSecondary };
case LogLevel.INFO:
return { bg: theme.colors.accent + '22', fg: theme.colors.accent };
case LogLevel.WARN:
return { bg: '#E6A23C22', fg: '#E6A23C' };
case LogLevel.ERROR:
return { bg: theme.colors.error + '22', fg: theme.colors.error };
}
};
// 格式化时间戳 (HH:mm:ss.SSS)
const formatTime = (isoString: string) => {
try {
const d = new Date(isoString);
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
const s = String(d.getSeconds()).padStart(2, '0');
const ms = String(d.getMilliseconds()).padStart(3, '0');
return `${h}:${m}:${s}.${ms}`;
} catch {
return isoString.slice(11, 23);
}
};
const renderHeaderRight = (
<View style={styles.headerRight}>
<Pressable onPress={handleExport} hitSlop={8} style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="share-outline" size={22} color={theme.colors.accent} />
</Pressable>
<Pressable onPress={handleClear} hitSlop={8} style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="trash-outline" size={22} color={theme.colors.error} />
</Pressable>
</View>
);
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<ScreenHeader title={t('logs.title')} right={renderHeaderRight} />
{/* 日志日期 Dropdown 触发按钮 */}
<View style={[styles.fileSelectorBar, { borderBottomColor: theme.colors.divider }]}>
<Pressable
onPress={() => setDateModalOpen(true)}
style={[
styles.dropdownTriggerBtn,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
},
]}
>
<Ionicons name="calendar-outline" size={16} color={theme.colors.accent} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '600', flex: 1, marginLeft: 8 }]}>
{selectedFileDate === null
? t('logs.todayLive')
: `${selectedFileDate} (${Math.round((selectedFile?.size ?? 0) / 1024)}KB)`}
</Text>
<Ionicons name="chevron-down" size={16} color={theme.colors.fgSecondary} />
</Pressable>
</View>
{/* 日志日期 Dropdown 弹出菜单 Modal */}
<Modal
visible={dateModalOpen}
transparent
animationType="fade"
onRequestClose={() => setDateModalOpen(false)}
>
<Pressable
style={[styles.modalOverlay, { backgroundColor: theme.colors.overlay }]}
onPress={() => setDateModalOpen(false)}
>
<Pressable
style={[
styles.dropdownMenu,
theme.shadows.lg,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
},
]}
onPress={e => e.stopPropagation()}
>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, fontWeight: '600' }]}>
</Text>
<ScrollView style={{ maxHeight: 300 }}>
{/* 今天(实时) */}
<Pressable
onPress={() => {
setSelectedFileDate(null);
setDateModalOpen(false);
}}
style={[
styles.menuItem,
{
backgroundColor: selectedFileDate === null ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
borderColor: selectedFileDate === null ? theme.colors.accent : theme.colors.border,
},
]}
>
<Text style={[theme.typography.bodySmall, { color: selectedFileDate === null ? theme.colors.accent : theme.colors.fgPrimary, fontWeight: '600' }]}>
{t('logs.todayLive')}
</Text>
{selectedFileDate === null && (
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} />
)}
</Pressable>
{/* 历史日志文件 */}
{logFiles.map(file => {
const isSelected = selectedFileDate === file.date;
return (
<Pressable
key={file.name}
onPress={() => {
setSelectedFileDate(file.date);
setDateModalOpen(false);
}}
style={[
styles.menuItem,
{
backgroundColor: isSelected ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
},
]}
>
<Text style={[theme.typography.bodySmall, { color: isSelected ? theme.colors.accent : theme.colors.fgPrimary, fontWeight: '600' }]}>
{file.date} ({Math.round(file.size / 1024)}KB)
</Text>
{isSelected && (
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} />
)}
</Pressable>
);
})}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
{/* 搜索框 */}
<View style={styles.searchContainer}>
<View style={[styles.searchBox, { backgroundColor: theme.colors.bgSecondary, borderColor: theme.colors.border }]}>
<Ionicons name="search-outline" size={16} color={theme.colors.fgSecondary} />
<TextInput
style={[theme.typography.bodySmall, styles.searchInput, { color: theme.colors.fgPrimary }]}
placeholder={t('logs.searchPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
value={searchQuery}
onChangeText={setSearchQuery}
/>
{searchQuery ? (
<Pressable onPress={() => setSearchQuery('')} hitSlop={6}>
<Ionicons name="close-circle" size={18} color={theme.colors.fgSecondary} />
</Pressable>
) : null}
</View>
</View>
{/* 级别 Chip 筛选 */}
<View style={styles.levelFilterRow}>
{(['ALL', LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR] as const).map(lvl => {
const isSelected = levelFilter === lvl;
const label = lvl === 'ALL' ? t('logs.filterLevelAll', { count: counts.ALL }) : `${LEVEL_LABELS[lvl]} (${counts[lvl]})`;
return (
<Pressable
key={String(lvl)}
onPress={() => setLevelFilter(lvl)}
style={[
styles.levelChip,
{
backgroundColor: isSelected ? theme.colors.accent : theme.colors.bgTertiary,
},
]}
>
<Text
style={[
theme.typography.caption,
{ color: isSelected ? theme.colors.fgInverse : theme.colors.fgSecondary, fontWeight: isSelected ? '700' : '400' },
]}
>
{label}
</Text>
</Pressable>
);
})}
</View>
{/* 日志列表 */}
<FlatList
data={filteredLogs}
keyExtractor={(_, index) => String(index)}
contentContainerStyle={[styles.listContent, { gap: theme.spacing.md }]}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="document-text-outline" size={48} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary, marginTop: 12 }]}>
{t('logs.empty')}
</Text>
</View>
}
renderItem={({ item, index }) => {
const style = getLevelStyle(item.level);
const isExpanded = expandedIndex === index;
const hasData = item.data !== undefined && item.data !== null;
return (
<Pressable
onPress={() => setExpandedIndex(isExpanded ? null : index)}
style={[
styles.logCard,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
},
]}
>
<View style={styles.cardHeader}>
<View style={[styles.levelBadge, { backgroundColor: style.bg }]}>
<Text style={[theme.typography.caption, { color: style.fg, fontWeight: '700', fontSize: 10 }]}>
{LEVEL_LABELS[item.level]}
</Text>
</View>
<Text style={[theme.typography.caption, styles.tagText, { color: theme.colors.accent }]}>
[{item.tag}]
</Text>
<Text style={[theme.typography.caption, styles.timeText, { color: theme.colors.fgSecondary }]}>
{formatTime(item.timestamp)}
</Text>
</View>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, marginTop: 6, lineHeight: 18 }]}>
{item.message}
</Text>
{hasData && (
<View style={styles.dataFooter}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{isExpanded ? t('logs.collapseData') : t('logs.expandData')}
</Text>
<Ionicons name={isExpanded ? 'chevron-up' : 'chevron-down'} size={14} color={theme.colors.fgSecondary} />
</View>
)}
{isExpanded && hasData && (
<View style={[styles.dataBox, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, fontFamily: 'monospace' }]}>
{typeof item.data === 'object' ? JSON.stringify(item.data, null, 2) : String(item.data)}
</Text>
</View>
)}
</Pressable>
);
}}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 16, marginRight: 8 },
fileSelectorBar: { paddingHorizontal: 16, paddingVertical: 8, borderBottomWidth: 1 },
dropdownTriggerBtn: { flexDirection: 'row', alignItems: 'center', borderRadius: 8, borderWidth: 1, paddingHorizontal: 12, minHeight: 38 },
modalOverlay: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
dropdownMenu: { width: '100%', maxWidth: 360, borderRadius: 16, borderWidth: 1, padding: 16 },
menuItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 12, paddingVertical: 10, borderRadius: 8, borderWidth: 1, marginBottom: 6 },
searchContainer: { paddingHorizontal: 16, paddingTop: 10 },
searchBox: { flexDirection: 'row', alignItems: 'center', borderRadius: 8, borderWidth: 1, paddingHorizontal: 10, minHeight: 36, gap: 8 },
searchInput: { flex: 1, paddingVertical: 0 },
levelFilterRow: { flexDirection: 'row', paddingHorizontal: 16, paddingVertical: 10, gap: 6, flexWrap: 'wrap' },
levelChip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 12 },
listContent: { padding: 16, paddingBottom: 40 },
logCard: { borderRadius: 8, borderWidth: 1, padding: 12 },
cardHeader: { flexDirection: 'row', alignItems: 'center', gap: 8 },
levelBadge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
tagText: { fontWeight: '600' },
timeText: { marginLeft: 'auto' },
dataFooter: { flexDirection: 'row', alignItems: 'center', marginTop: 8, gap: 4 },
dataBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
emptyContainer: { alignItems: 'center', justifyContent: 'center', paddingTop: 60 },
});
+242
View File
@@ -0,0 +1,242 @@
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { TimePicker } from '../../components/ui/TimePicker';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useSettingsStore, type Locale, type ThemeMode } from '../../store/settingsStore';
import { Card } from '../../components/ui/Card';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
export default function PreferencesScreen() {
const { theme } = useTheme();
const t = useT();
// Settings State
const themeMode = useSettingsStore(s => s.themeMode);
const setThemeMode = useSettingsStore(s => s.setThemeMode);
const locale = useSettingsStore(s => s.locale);
const setLocale = useSettingsStore(s => s.setLocale);
const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
const setAppLockEnabled = useSettingsStore(s => s.setAppLockEnabled);
const reminderEnabled = useSettingsStore(s => s.reminderEnabled);
const reminderHour = useSettingsStore(s => s.reminderHour);
const reminderMinute = useSettingsStore(s => s.reminderMinute);
const updateReminderConfig = useSettingsStore(s => s.updateReminderConfig);
const [timePickerOpen, setTimePickerOpen] = useState(false);
const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
const setNumpadGlobalEntry = useSettingsStore(s => s.setNumpadGlobalEntry);
const THEME_OPTIONS: { mode: ThemeMode; labelKey: string }[] = [
{ mode: 'light', labelKey: 'settings.themeLight' },
{ mode: 'dark', labelKey: 'settings.themeDark' },
{ mode: 'system', labelKey: 'settings.themeSystem' },
];
const LOCALE_OPTIONS: { locale: Locale; labelKey: string }[] = [
{ locale: 'zh', labelKey: 'settings.langZh' },
{ locale: 'en', labelKey: 'settings.langEn' },
];
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<ScreenHeader title={t('settings.preferencesTitle')} />
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 外观设置 */}
<Card title={t('settings.appearance')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
{t('settings.themeMode')}
</Text>
<View style={styles.optionRow}>
{THEME_OPTIONS.map(opt => {
const active = themeMode === opt.mode;
return (
<Pressable
key={opt.mode}
onPress={() => setThemeMode(opt.mode)}
style={[
styles.option,
{
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : theme.colors.border,
borderRadius: theme.radii.sm,
},
]}
>
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontWeight: active ? '700' : '400' }}>
{t(opt.labelKey)}
</Text>
</Pressable>
);
})}
</View>
</Card>
{/* 语言偏好 */}
<Card title={t('settings.language')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
{t('settings.selectLang')}
</Text>
<View style={styles.optionRow}>
{LOCALE_OPTIONS.map(opt => {
const active = locale === opt.locale;
return (
<Pressable
key={opt.locale}
onPress={() => setLocale(opt.locale)}
style={[
styles.option,
{
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : theme.colors.border,
borderRadius: theme.radii.sm,
},
]}
>
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontWeight: active ? '700' : '400' }}>
{t(opt.labelKey)}
</Text>
</Pressable>
);
})}
</View>
</Card>
{/* 记账 */}
<Card title={t('settings.entryTitle')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.numpadEntry')}
</Text>
<Switch
value={numpadGlobalEntry}
onValueChange={setNumpadGlobalEntry}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 6 }]}>
{t('settings.numpadEntryDesc')}
</Text>
</Card>
{/* 安全设置 */}
<Card title={t('settings.security')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.appLock')}
</Text>
<Switch
value={appLockEnabled}
onValueChange={async (val) => {
if (val) {
// 开启:需要设置 PIN
const { hasPinSet } = await import('../../components/layout/LockScreen');
const hasPin = await hasPinSet();
if (!hasPin) {
// Alert.prompt 仅 iOS 可用;Android 直接开启(用户首次锁屏时设置)
// 这里简化处理:直接开启,用户在锁屏界面首次输入时即设置 PIN
// 完整版应弹出一个 Modal 含 TextInput(留后续优化)
Alert.alert(
t('settings.setPinDesc'),
undefined,
[
{ text: t('common.cancel'), style: 'cancel', onPress: () => {} },
{
text: t('common.confirm'),
onPress: async () => {
// 设置一个默认空 PIN,用户可在锁屏界面修改
// 真实实现应弹出 PIN 输入 Modal
setAppLockEnabled(true);
},
},
],
);
} else {
setAppLockEnabled(true);
}
} else {
// 关闭:清除 PIN
const { clearPin } = await import('../../components/layout/LockScreen');
await clearPin();
setAppLockEnabled(false);
}
}}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* 每日记账提醒 */}
<Card title={t('settings.reminderTitle')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.reminderToggle')}
</Text>
<Switch
value={reminderEnabled}
onValueChange={async (val) => {
updateReminderConfig({ reminderEnabled: val });
try {
const { RealNotificationScheduler, setupDailyReminder, cancelAllReminders } = await import('../../services/reminder');
const scheduler = new RealNotificationScheduler();
if (val) {
await setupDailyReminder(scheduler, reminderHour, reminderMinute);
} else {
await cancelAllReminders(scheduler);
}
} catch (e) {
Alert.alert(String(e));
}
}}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{reminderEnabled && (
<View style={[styles.switchRow, { marginTop: 12 }]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.reminderTime')}
</Text>
<Pressable
onPress={() => setTimePickerOpen(true)}
accessibilityRole="button"
accessibilityLabel={t('settings.reminderTime')}
style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}
>
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
{String(reminderHour).padStart(2, '0')}:{String(reminderMinute).padStart(2, '0')}
</Text>
<Ionicons name="chevron-forward" size={16} color={theme.colors.accent} />
</Pressable>
</View>
)}
</Card>
</ScrollView>
<TimePicker
visible={timePickerOpen}
hour={reminderHour}
minute={reminderMinute}
title={t('settings.reminderTime')}
onCancel={() => setTimePickerOpen(false)}
onConfirm={async (h, m) => {
setTimePickerOpen(false);
updateReminderConfig({ reminderHour: h, reminderMinute: m });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), h, m);
}}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16 },
optionRow: { flexDirection: 'row', gap: 8 },
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1 },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
});
+537
View File
@@ -0,0 +1,537 @@
import React, { useState } from 'react';
import { ScrollView, StyleSheet, Text, View, Alert, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useSettingsStore, type PersistableSettings } from '../../store/settingsStore';
import { useLedgerStore } from '../../store/ledgerStore';
import { useImportStore } from '../../store/importStore';
import { useMetadataStore, type PersistableMetadata } from '../../store/metadataStore';
import { Card } from '../../components/ui/Card';
import { Button } from '../../components/ui/Button';
import { FormModal, type FormField } from '../../components/form/FormModal';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
import { syncWithWebDAV, WebDAVClient, type FetchImpl } from '../../services/sync/webdavSync';
import { syncWithGit, ExpoGitBackend } from '../../services/sync/gitSync';
import { syncWithICloud, MockICloudFileSystem } from '../../services/sync/icloudSync';
import { createBackupBundle, serializeBundle, deserializeBundle, restoreFiles } from '../../services/data/backup';
import type { ExcelWorkbook } from '../../services/data/exportToExcel';
import { runMaintenance, ExpoMaintenanceFs, ExpoMaintenanceDb } from '../../services/data/maintenance';
export default function SyncSettingsScreen() {
const { theme } = useTheme();
const t = useT();
// Sync Configuration
const webdavUrl = useSettingsStore(s => s.webdavUrl) || '';
const webdavUsername = useSettingsStore(s => s.webdavUsername) || '';
const webdavPassword = useSettingsStore(s => s.webdavPassword) || '';
const webdavRemotePath = useSettingsStore(s => s.webdavRemotePath) || 'main.bean';
const gitRemoteUrl = useSettingsStore(s => s.gitRemoteUrl) || '';
const gitBranch = useSettingsStore(s => s.gitBranch) || 'main';
const gitUsername = useSettingsStore(s => s.gitUsername) || '';
const gitPassword = useSettingsStore(s => s.gitPassword) || '';
const updateSyncConfig = useSettingsStore(s => s.updateSyncConfig);
// Ledger state
const ledger = useLedgerStore(s => s.ledger);
const mobileBean = useLedgerStore(s => s.mobileBean);
const replaceMobileBean = useLedgerStore(s => s.replaceMobileBean);
// Modal Visibility
const [webdavModalVisible, setWebdavModalVisible] = useState(false);
const [gitModalVisible, setGitModalVisible] = useState(false);
// WebDAV credentials form fields
const webdavFields: FormField[] = [
{ key: 'url', label: t('sync.webdavUrl'), placeholder: 'https://dav.example.com/beancount/', defaultValue: webdavUrl },
{ key: 'username', label: t('sync.username'), placeholder: 'username', defaultValue: webdavUsername },
{ key: 'password', label: t('sync.password'), placeholder: 'password', defaultValue: webdavPassword },
{ key: 'remotePath', label: t('sync.remotePath'), placeholder: 'main.bean', defaultValue: webdavRemotePath },
];
// Git credentials form fields
const gitFields: FormField[] = [
{ key: 'remoteUrl', label: 'Git Remote URL', placeholder: 'https://github.com/user/ledger.git', defaultValue: gitRemoteUrl },
{ key: 'branch', label: t('sync.branch'), placeholder: 'main', defaultValue: gitBranch },
{ key: 'username', label: t('sync.username'), placeholder: 'gituser', defaultValue: gitUsername },
{ key: 'password', label: t('sync.passwordToken'), placeholder: 'token', defaultValue: gitPassword },
];
const saveWebDAV = (values: Record<string, string>) => {
updateSyncConfig({
webdavUrl: values.url,
webdavUsername: values.username,
webdavPassword: values.password,
webdavRemotePath: values.remotePath,
});
setWebdavModalVisible(false);
};
const saveGit = (values: Record<string, string>) => {
updateSyncConfig({
gitRemoteUrl: values.remoteUrl,
gitBranch: values.branch,
gitUsername: values.username,
gitPassword: values.password,
});
setGitModalVisible(false);
};
// === WebDAV 同步(真实) ===
const handleWebDAVSync = async () => {
if (!webdavUrl) {
Alert.alert(t('sync.hint'), t('sync.webdavNotConfigured'));
return;
}
try {
const client = new WebDAVClient({
url: webdavUrl,
username: webdavUsername,
password: webdavPassword,
remotePath: webdavRemotePath,
backupKeepCount: 3,
}, fetch as unknown as FetchImpl);
const result = await syncWithWebDAV(client, mobileBean, new Date().toISOString());
if (result.action === 'pulled' || result.action === 'conflict') {
await replaceMobileBean(result.content);
}
Alert.alert(t('sync.syncSuccess'), t('sync.webdvResult', { action: result.action }));
} catch (e) {
Alert.alert(t('sync.syncFail', { error: e instanceof Error ? e.message : String(e) }));
}
};
// === Git 同步(真实 — ExpoGitBackend 基于 HTTP 单文件同步) ===
const handleGitSync = async () => {
if (!gitRemoteUrl) {
Alert.alert(t('sync.hint'), t('sync.gitNotConfigured'));
return;
}
try {
const backend = new ExpoGitBackend(gitRemoteUrl, gitUsername, gitPassword, fetch);
const result = await syncWithGit(backend, {
remoteUrl: gitRemoteUrl,
branch: gitBranch,
autoSync: false,
syncIntervalMin: 30,
}, mobileBean);
if (result.action === 'pulled' || result.action === 'merged' || result.action === 'conflict') {
await replaceMobileBean(result.content);
}
const msgKey = result.conflicts && result.conflicts.length > 0
? t('sync.gitConflict', { count: result.conflicts.length })
: t('sync.gitResult', { action: result.action });
Alert.alert(t('sync.syncSuccess'), msgKey);
} catch (e) {
Alert.alert(t('sync.syncFail', { error: e instanceof Error ? e.message : String(e) }));
}
};
// === iCloud 同步(演示模式 — Mock 后端,Android 上隐藏) ===
const handleICloudSync = async () => {
Alert.alert(
t('sync.demoMode'),
t('sync.iCloudDemoDesc'),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('sync.runDemo'),
onPress: async () => {
try {
const fs = new MockICloudFileSystem();
fs.available = true;
fs.setRemoteFile('main.bean', `${t('sync.iCloudDemoContent')}\n`, new Date().toISOString());
const result = await syncWithICloud(fs, mobileBean, new Date().toISOString());
if (result.action === 'pulled' || result.action === 'conflict') {
await replaceMobileBean(result.content);
}
Alert.alert(t('sync.demoSyncSuccess'), t('sync.iCloudResult', { action: result.action }));
} catch (e) {
Alert.alert(t('sync.syncFail', { error: e instanceof Error ? e.message : String(e) }));
}
},
},
],
);
};
// === 本地备份(真实写文件 + 分享) ===
const handleBackup = async () => {
try {
const files = ledger?.files ?? [];
// 收集设置(剔除敏感字段和函数)
const settingsState = useSettingsStore.getState();
const settings: Record<string, unknown> = {};
for (const [k, v] of Object.entries(settingsState)) {
if (typeof v !== 'function' && !['webdavPassword', 'gitPassword', 'appLockPin'].includes(k)) {
settings[k] = v;
}
}
// 收集元数据(分类/规则/预算/标签/信用卡/周期记账)
const metaState = useMetadataStore.getState();
const metadata: Record<string, unknown> = {
categories: metaState.categories,
tags: metaState.tags,
budgets: metaState.budgets,
creditCards: metaState.creditCards,
rules: metaState.rules,
recurringTransactions: metaState.recurringTransactions,
};
const bundle = createBackupBundle(files, mobileBean, settings, metadata);
const json = serializeBundle(bundle);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
// Android 特殊处理:使用 SAF (存储访问框架) 直接让用户选择文件夹并保存
if (Platform.OS === 'android') {
try {
const permissions = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (permissions.granted) {
const fileUri = await FileSystem.StorageAccessFramework.createFileAsync(
permissions.directoryUri,
`backup-${timestamp}`,
'application/json',
);
await FileSystem.writeAsStringAsync(fileUri, json);
Alert.alert(t('sync.backupSuccess'), t('sync.backupSuccessFolder'));
return;
}
} catch (safError) {
// 如果 SAF 失败,降级到系统分享
console.warn('SAF backup failed, falling back to sharing:', safError);
}
}
const backupPath = FileSystem.documentDirectory + `backup-${timestamp}.json`;
await FileSystem.writeAsStringAsync(backupPath, json);
// 尝试分享(用户可保存到云盘/发送)
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(backupPath, {
mimeType: 'application/json',
dialogTitle: t('sync.backupShareTitle'),
});
}
Alert.alert(t('sync.backupSuccess'), t('sync.backupSuccessDesc', { path: backupPath }));
} catch (e) {
Alert.alert(t('sync.backupFail'), String(e));
}
};
// === 从备份文件恢复(真实) ===
const handleRestore = async () => {
try {
const res = await DocumentPicker.getDocumentAsync({
type: 'application/json',
copyToCacheDirectory: true,
});
if (res.canceled) return;
const file = res.assets[0];
const content = await FileSystem.readAsStringAsync(file.uri);
const bundle = deserializeBundle(content);
const restoredFiles = restoreFiles(bundle);
// 用恢复的 content 替换当前内容
const restoredMainBean = restoredFiles.find(f => f.path === 'main.bean')?.content || '';
await replaceMobileBean(restoredMainBean);
// 恢复设置(v2
if (bundle.settings) {
useSettingsStore.getState().hydrate(bundle.settings as Partial<PersistableSettings>);
// 同步持久化到文件
const settingsPath = FileSystem.documentDirectory + 'settings.json';
await FileSystem.writeAsStringAsync(settingsPath, JSON.stringify(bundle.settings, null, 2));
}
// 恢复元数据(v2
if (bundle.metadata) {
useMetadataStore.getState().hydrate(bundle.metadata as Partial<PersistableMetadata>);
// 同步持久化到文件
const metadataPath = FileSystem.documentDirectory + 'metadata.json';
await FileSystem.writeAsStringAsync(metadataPath, JSON.stringify(bundle.metadata, null, 2));
}
const restoredParts = [t('sync.restorePartsTx')];
if (bundle.settings) restoredParts.push(t('sync.restorePartsSettings'));
if (bundle.metadata) restoredParts.push(t('sync.restorePartsMeta'));
Alert.alert(
t('sync.restoreSuccess'),
`${t('sync.restoreDesc', { name: file.name })}${restoredParts.join('、')}`,
);
} catch (e) {
Alert.alert(t('sync.restoreFail'), e instanceof Error ? e.message : String(e));
}
};
// === 加载本地 .bean 账本(真实) ===
const handleLoadLocalLedger = async () => {
try {
const res = await DocumentPicker.getDocumentAsync({
type: '*/*',
copyToCacheDirectory: true,
});
if (res.canceled) return;
const file = res.assets[0];
const content = await FileSystem.readAsStringAsync(file.uri);
// 持久化到 documentDirectory,下次启动自动加载
const mainPath = FileSystem.documentDirectory + 'main.bean';
await FileSystem.writeAsStringAsync(mainPath, content);
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const loadLedger = useLedgerStore.getState().loadLedger;
const setContext = useImportStore.getState().setContext;
const { FileSystemBackend } = await import('../../services/data/fileSystemBackend');
await loadLedger([{ path: 'main.bean', content }], new FileSystemBackend());
setContext({ rules, categories });
Alert.alert(t('sync.loadSuccess'), t('sync.loadDesc', { name: file.name }));
} catch (e) {
Alert.alert(t('sync.loadFail'), e instanceof Error ? e.message : String(e));
}
};
// === 维护清理(真实 — 扫描并清理 documentDirectory 下的孤儿文件) ===
const handleMaintenance = async () => {
Alert.alert(
t('sync.maintenanceTitle'),
t('sync.maintenanceConfirm'),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.confirm'),
onPress: async () => {
try {
const baseDir = FileSystem.documentDirectory ?? '';
const cacheDir = FileSystem.cacheDirectory ?? '';
const fs = new ExpoMaintenanceFs();
const db = new ExpoMaintenanceDb(mobileBean);
const report = await runMaintenance(
fs, db, new Set(),
{
attachments: `${baseDir}attachments/`,
thumbnails: `${baseDir}thumbnails/`,
cache: cacheDir,
},
);
Alert.alert(
t('sync.syncSuccess'),
t('sync.maintenanceResult', {
count: report.cleanedCount,
size: report.totalOrphanSize,
}),
);
} catch (e) {
Alert.alert(t('sync.optimizeFail'), String(e));
}
},
},
],
);
};
// === 导出 Excel ===
const handleExportExcel = async () => {
try {
const XLSX = (await import('xlsx')).default;
const { exportToExcel } = await import('../../services/data/exportToExcel');
const { transactions } = ledger!;
const workbook = XLSX.utils.book_new();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const exportPath = FileSystem.documentDirectory + `transactions-${timestamp}.xlsx`;
let base64Content = '';
// 用 xlsx 库创建 workbook wrapper
const wbWrapper = {
addSheet: (name: string, rows: Record<string, unknown>[]) => {
const ws = XLSX.utils.json_to_sheet(rows);
XLSX.utils.book_append_sheet(workbook, ws, name);
},
write: (path: string) => {
base64Content = XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' });
return FileSystem.writeAsStringAsync(path, base64Content, { encoding: FileSystem.EncodingType.Base64 });
},
};
await exportToExcel(transactions, wbWrapper as unknown as ExcelWorkbook, exportPath);
// Android 特殊处理:使用 SAF 直接保存到本地公开目录
if (Platform.OS === 'android') {
try {
const permissions = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (permissions.granted) {
const fileUri = await FileSystem.StorageAccessFramework.createFileAsync(
permissions.directoryUri,
`transactions-${timestamp}`,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
await FileSystem.writeAsStringAsync(fileUri, base64Content, { encoding: FileSystem.EncodingType.Base64 });
Alert.alert(t('sync.exportSuccess'), 'Excel 账单已成功保存到所选文件夹!');
return;
}
} catch (safError) {
console.warn('SAF Excel export failed, falling back to sharing:', safError);
}
}
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(exportPath, { mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}
Alert.alert(t('sync.exportSuccess'));
} catch (e) {
Alert.alert(t('sync.exportFail'), String(e));
}
};
// === 导出规则 ===
const handleExportRules = async () => {
try {
const { exportRules, MockRuleFileSync } = await import('../../services/ruleSync');
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const exportPath = FileSystem.documentDirectory + `rules-${timestamp}.json`;
const fs = new MockRuleFileSync();
// MockRuleFileSync 是内存的,需要真实写入
await exportRules(rules, categories, fs, exportPath);
// 获取 Mock 写入的内容,用真实 FileSystem 写
const content = await fs.read(exportPath);
await FileSystem.writeAsStringAsync(exportPath, content);
// Android 特殊处理:使用 SAF 直接保存到本地公开目录
if (Platform.OS === 'android') {
try {
const permissions = await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
if (permissions.granted) {
const fileUri = await FileSystem.StorageAccessFramework.createFileAsync(
permissions.directoryUri,
`rules-${timestamp}`,
'application/json',
);
await FileSystem.writeAsStringAsync(fileUri, content);
Alert.alert(t('sync.exportSuccess'), '匹配规则已成功保存到所选文件夹!');
return;
}
} catch (safError) {
console.warn('SAF Rules export failed, falling back to sharing:', safError);
}
}
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(exportPath, { mimeType: 'application/json' });
}
Alert.alert(t('sync.exportSuccess'));
} catch (e) {
Alert.alert(t('sync.exportFail'), String(e));
}
};
// === 导入规则 ===
const handleImportRules = async () => {
try {
const res = await DocumentPicker.getDocumentAsync({ type: 'application/json', copyToCacheDirectory: true });
if (res.canceled) return;
const file = res.assets[0];
const content = await FileSystem.readAsStringAsync(file.uri);
const { parseRuleBundle, mergeRules } = await import('../../services/ruleSync');
const bundle = parseRuleBundle(content);
const existingRules = useMetadataStore.getState().rules;
const merged = mergeRules(existingRules, bundle.rules);
// 用合并后的规则更新 store(需要逐条添加新规则)
const newRules = merged.filter(r => !existingRules.some(e => e.id === r.id));
for (const rule of newRules) {
useMetadataStore.getState().addRule(rule);
}
Alert.alert(t('sync.importRulesSuccess', { count: newRules.length }));
} catch (e) {
Alert.alert(t('sync.exportFail'), e instanceof Error ? e.message : String(e));
}
};
const isAndroid = Platform.OS === 'android';
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<ScreenHeader title={t('sync.title')} />
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 云同步配置与云端操作 */}
<Card title={t('sync.cloudTitle')}>
<View style={styles.syncBtnRow}>
<View style={{ flex: 1 }}>
<Button label={t('sync.configWebdav')} onPress={() => setWebdavModalVisible(true)} variant="secondary" />
</View>
<View style={{ flex: 1 }}>
<Button label={t('sync.configGit')} onPress={() => setGitModalVisible(true)} variant="secondary" />
</View>
</View>
<View style={{ marginTop: 8, gap: 8 }}>
<Button label={t('sync.webdavSyncNow')} onPress={handleWebDAVSync} />
<Button label={t('sync.gitSyncNow')} onPress={handleGitSync} variant="secondary" />
{!isAndroid && (
<Button label={t('sync.iCloudSync')} onPress={handleICloudSync} variant="secondary" />
)}
</View>
{isAndroid && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
{t('sync.iCloudUnavailable')}
</Text>
)}
</Card>
{/* 本地备份与维护 */}
<Card title={t('sync.backupTitle')}>
<View style={styles.buttonRow}>
<View style={{ flex: 1 }}>
<Button label={t('sync.backupNow')} onPress={handleBackup} />
</View>
<View style={{ flex: 1 }}>
<Button label={t('sync.restoreNow')} onPress={handleRestore} variant="secondary" />
</View>
</View>
<View style={{ gap: 8, marginTop: 8 }}>
<Button label={t('sync.loadLocalLedger')} onPress={handleLoadLocalLedger} />
<Button label={t('sync.maintenance')} onPress={handleMaintenance} variant="secondary" />
</View>
</Card>
{/* 数据导出与分享 */}
<Card title={t('sync.exportTitle')}>
<View style={{ gap: 8 }}>
<Button label={t('sync.exportExcel')} onPress={handleExportExcel} />
<Button label={t('sync.exportRules')} onPress={handleExportRules} variant="secondary" />
<Button label={t('sync.importRules')} onPress={handleImportRules} variant="secondary" />
</View>
</Card>
</ScrollView>
{/* WebDAV 凭据配置 */}
<FormModal
visible={webdavModalVisible}
title={t('sync.webdavConfigTitle')}
fields={webdavFields}
onConfirm={saveWebDAV}
onCancel={() => setWebdavModalVisible(false)}
/>
{/* Git 凭据配置 */}
<FormModal
visible={gitModalVisible}
title={t('sync.gitConfigTitle')}
fields={gitFields}
onConfirm={saveGit}
onCancel={() => setGitModalVisible(false)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16 },
syncBtnRow: { flexDirection: 'row', gap: 8 },
buttonRow: { flexDirection: 'row', gap: 8 },
});
+92
View File
@@ -0,0 +1,92 @@
/**
* ManagementScreen P2
*
* () + //
* .bean #tag , [\w-]
*/
import React, { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { TAG_COLORS } from '../../theme/palette';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n';
import { ManagementScreen } from '../../components/layout/ManagementScreen';
import { isValidTagName } from '../../domain/taxonomy/tags';
import type { Tag } from '../../domain/taxonomy/tags';
export default function TagScreen() {
const { theme } = useTheme();
const t = useT();
const tags = useMetadataStore(s => s.tags);
const addTag = useMetadataStore(s => s.addTag);
const updateTag = useMetadataStore(s => s.updateTag);
const removeTag = useMetadataStore(s => s.removeTag);
const [selectedColor, setSelectedColor] = useState<string>(TAG_COLORS[0]);
return (
<ManagementScreen<Tag>
title={t('tag.title')}
items={tags}
keyExtractor={tag => tag.id}
addLabel={t('tag.add')}
emptyText={t('tag.empty')}
listStyle={styles.chipGrid}
onOpenForm={editing => setSelectedColor(editing?.color ?? TAG_COLORS[0])}
renderItem={(tag, { openEdit, confirmDelete }) => (
<Pressable
onPress={openEdit}
onLongPress={confirmDelete}
style={[styles.chip, { backgroundColor: tag.color }]}
>
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
</Pressable>
)}
formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))}
formFields={editing => [
{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name },
]}
formExtra={() => (
<View style={styles.colorRow}>
{TAG_COLORS.map(c => (
<Pressable
key={c}
onPress={() => setSelectedColor(c)}
style={[styles.colorDot, {
backgroundColor: c,
borderWidth: selectedColor === c ? 3 : 0,
borderColor: theme.colors.fgPrimary,
}]}
/>
))}
</View>
)}
onSubmit={(values, editing) => {
const name = values.name?.trim() ?? '';
if (!isValidTagName(name)) {
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
return false;
}
if (editing) updateTag(editing.id, { name, color: selectedColor });
else addTag({ id: generateId('tag'), name, color: selectedColor });
return true;
}}
onDelete={tag => removeTag(tag.id)}
deleteConfirmText={tag => t('tag.deleteConfirm', { name: tag.name })}
deleteConfirmTitle={t('tag.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
}
/>
);
}
const styles = StyleSheet.create({
chipGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
colorDot: { width: 36, height: 36, borderRadius: 18 },
});
+555
View File
@@ -0,0 +1,555 @@
/**
* plan.md1.1 transaction/[id]
*
* ledger.transactions id ,:
* - / / / flag
* - postings( / / / cost / price)
* - / /
* - .bean
*/
import React, { useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Modal, TextInput, FlatList, KeyboardAvoidingView, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../../theme';
import { Card } from '../../components/ui/Card';
import { useLedgerStore } from '../../store/ledgerStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { hash } from '../../domain/core/ledger';
import { useT } from '../../i18n';
import { TransactionCard } from '../../components/transaction/TransactionCard';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
import { logger } from '../../utils/logger';
export default function TransactionDetailScreen() {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const ledger = useLedgerStore(s => s.ledger);
const tx = useMemo(
() => ledger?.transactions.find(txn => txn.id === id) ?? null,
[ledger, id],
);
const [linkModalVisible, setLinkModalVisible] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const isEditable = tx ? tx.source.endsWith('main.bean') : false;
// 找出所有共享同一个链接标签的关联交易
const relatedTransactions = useMemo(() => {
if (!ledger || !tx || !tx.links || tx.links.length === 0) return [];
return ledger.transactions.filter(
t => t.id !== id && t.links.some(l => tx.links.includes(l))
);
}, [ledger, id, tx]);
// 筛选可以用来关联的其他账单
const linkableTransactions = useMemo(() => {
if (!ledger) return [];
const list = ledger.transactions
.filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l)))
.filter(t => {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (
t.narration?.toLowerCase().includes(q) ||
t.payee?.toLowerCase().includes(q) ||
t.postings.some(p => p.account.toLowerCase().includes(q))
);
})
.sort((a, b) => b.date.localeCompare(a.date)); // 显式按交易日期【由新到旧 (最新 ➔ 最旧)】严格倒序排列
// 有搜索条件时展示最多 100 条匹配结果,无搜索词时默认展示最新 50 条
return searchQuery ? list.slice(0, 100) : list.slice(0, 50);
}, [ledger, id, tx?.links, searchQuery]);
const handleLinkTransaction = async (targetTx: typeof tx) => {
if (!tx || !targetTx) return;
let linkTag = tx.links[0] || targetTx.links[0] || '';
try {
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
// 取出已有链接标签,若没有则生成一个随机哈希标签
if (!linkTag) {
linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8);
}
const addLinkToRaw = (raw: string, tag: string) => {
const lines = raw.split('\n');
const line = lines[0];
const commentIndex = line.indexOf(';');
if (line.includes(`^${tag}`)) {
return raw;
}
if (commentIndex !== -1) {
const beforeComment = line.substring(0, commentIndex).trimEnd();
const commentPart = line.substring(commentIndex);
lines[0] = `${beforeComment} ^${tag} ${commentPart}`;
} else {
lines[0] = line.trimEnd() + ` ^${tag}`;
}
return lines.join('\n');
};
const newCurrentRaw = addLinkToRaw(tx.raw, linkTag);
const newTargetRaw = addLinkToRaw(targetTx.raw, linkTag);
let newContent = mobileBean;
newContent = newContent.replaceAll(tx.raw, newCurrentRaw);
newContent = newContent.replaceAll(targetTx.raw, newTargetRaw);
const formatTxSummary = (t: typeof tx) => {
if (!t) return '';
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
};
await replaceMobileBean(newContent);
logger.info('ledgerStore', `关联交易成功: 标签 ^${linkTag}\n【交易 1】: ${formatTxSummary(tx)}\n【交易 2】: ${formatTxSummary(targetTx)}`, {
linkTag,
transaction1: {
id: tx.id,
date: tx.date,
payee: tx.payee,
narration: tx.narration,
postings: tx.postings,
},
transaction2: {
id: targetTx.id,
date: targetTx.date,
payee: targetTx.payee,
narration: targetTx.narration,
postings: targetTx.postings,
},
});
setLinkModalVisible(false);
// 解决 race condition:直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏
const newId = hash(`main.bean:${newCurrentRaw}`);
if (newId && newId !== id) {
router.replace(`/transaction/${newId}`);
}
Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag }));
} catch (e) {
logger.error('ledgerStore', `关联交易失败 (Tag: ^${linkTag})`, e);
Alert.alert(t('transaction.linkFail'), String(e));
}
};
const handleUnlink = async (linkTag: string) => {
if (!tx) return;
try {
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
// 找出所有携带此 linkTag 且属于 main.bean 的同伴交易并同步清除,避免悬空标签
const companionTxs = (ledger?.transactions ?? []).filter(
t => t.links.includes(linkTag) && t.source.endsWith('main.bean')
);
let newContent = mobileBean;
let newCurrentRaw = tx.raw;
for (const companion of companionTxs) {
const lines = companion.raw.split('\n');
lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), '');
const newRaw = lines.join('\n');
newContent = newContent.replaceAll(companion.raw, newRaw);
if (companion.id === tx.id) {
newCurrentRaw = newRaw;
}
}
const formatTxSummary = (t: typeof tx) => {
if (!t) return '';
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
};
await replaceMobileBean(newContent);
const unlinkedListStr = companionTxs.map(c => formatTxSummary(c)).join('\n ');
logger.info('ledgerStore', `解除交易关联成功: 标签 ^${linkTag} (同步解绑 ${companionTxs.length} 笔相关交易)\n 解绑交易列表:\n ${unlinkedListStr}`, {
linkTag,
unlinkedCount: companionTxs.length,
unlinkedTransactions: companionTxs.map(c => ({
id: c.id,
date: c.date,
payee: c.payee,
narration: c.narration,
postings: c.postings,
})),
});
// 解决 race condition:直接使用 hash() 算法计算新 ID
const newId = hash(`main.bean:${newCurrentRaw}`);
if (newId && newId !== id) {
router.replace(`/transaction/${newId}`);
}
Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag }));
} catch (e) {
logger.error('ledgerStore', `解除交易关联失败 (Tag: ^${linkTag})`, e);
Alert.alert(t('transaction.unlinkFail'), String(e));
}
};
if (!tx) {
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<ScreenHeader title={t('common.notFound')} />
<View style={styles.content}>
<Card title={t('transaction.notFoundTitle')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{t('transaction.notFoundDesc', { id })}
</Text>
</Card>
</View>
</SafeAreaView>
);
}
const isExpense = tx.postings.some(p => p.account.startsWith('Expenses'));
const isIncome = tx.postings.some(p => p.account.startsWith('Income'));
const directionLabel = isExpense ? t('transaction.directionExpense') : isIncome ? t('transaction.directionIncome') : t('transaction.directionTransfer');
const directionColor = isExpense ? theme.colors.financial.expense
: isIncome ? theme.colors.financial.income
: theme.colors.financial.transfer;
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<ScreenHeader title={t('transaction.detailTitle')} />
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 摘要卡片 */}
<Card title={t('transaction.summary')}>
<View style={styles.summaryRow}>
<View style={{ flex: 1 }}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{tx.narration || tx.payee || t('transaction.noSummary')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
{tx.date.slice(0, 10)}{tx.payee ? ` · ${tx.payee}` : ''}
</Text>
</View>
<View style={[styles.directionBadge, { backgroundColor: directionColor }]}>
<Text style={{ color: theme.colors.fgInverse, fontSize: 12, fontWeight: '700' }}>{directionLabel}</Text>
</View>
</View>
</Card>
{/* 资金流向 */}
<Card title={`${t('transaction.postings')} (${tx.postings.length})`}>
<View style={{ gap: 8 }}>
{tx.postings.map((p, i) => {
const parts = p.account.split(':');
const rootType = parts[0];
const accountShortName = parts.slice(1).join(':') || p.account;
let categoryTag = t('account.title');
let iconName: keyof typeof Ionicons.glyphMap = 'swap-horizontal-outline';
let tagBg = `${theme.colors.accent}15`;
let tagColor = theme.colors.accent;
if (rootType === 'Assets') {
categoryTag = t('account.rootTypes.Assets');
iconName = 'wallet-outline';
tagBg = `${theme.colors.financial.income}15`;
tagColor = theme.colors.financial.income;
} else if (rootType === 'Expenses') {
categoryTag = t('account.rootTypes.Expenses');
iconName = 'cart-outline';
tagBg = `${theme.colors.financial.expense}15`;
tagColor = theme.colors.financial.expense;
} else if (rootType === 'Income') {
categoryTag = t('account.rootTypes.Income');
iconName = 'cash-outline';
tagBg = `${theme.colors.financial.income}15`;
tagColor = theme.colors.financial.income;
} else if (rootType === 'Liabilities') {
categoryTag = t('account.rootTypes.Liabilities');
iconName = 'card-outline';
tagBg = `${theme.colors.warning}15`;
tagColor = theme.colors.warning;
} else if (rootType === 'Equity') {
categoryTag = t('account.rootTypes.Equity');
iconName = 'options-outline';
}
const amountVal = parseFloat(p.amount ?? '0');
const isNegative = amountVal < 0;
return (
<View
key={i}
style={[
styles.flowCardItem,
{
backgroundColor: theme.colors.bgTertiary,
borderRadius: theme.radii.lg,
},
]}
>
<View style={[styles.flowIconBox, { backgroundColor: tagBg }]}>
<Ionicons name={iconName} size={18} color={tagColor} />
</View>
<View style={{ flex: 1, marginLeft: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View style={[styles.miniTypeTag, { backgroundColor: `${theme.colors.fgSecondary}15` }]}>
<Text style={{ fontSize: 10, color: theme.colors.fgSecondary, fontWeight: '600' }}>
{categoryTag}
</Text>
</View>
</View>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700', marginTop: 2 }]}>
{accountShortName}
</Text>
{p.metadata && Object.entries(p.metadata).map(([k, v]) => (
<Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{k}: {v}
</Text>
))}
</View>
<View style={{ alignItems: 'flex-end', justifyContent: 'center' }}>
{p.amount && (
<Text
style={[
theme.typography.body,
{
color: isNegative ? theme.colors.financial.expense : theme.colors.fgPrimary,
fontWeight: '700',
},
]}
>
{p.amount} {p.currency ?? ''}
</Text>
)}
{p.cost && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
: {p.cost}
</Text>
)}
{p.price && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
: @ {p.price}
</Text>
)}
</View>
</View>
);
})}
</View>
</Card>
{/* 标签 */}
{tx.tags.length > 0 && (
<Card title={t('transaction.tags')}>
<View style={styles.tagRow}>
{tx.tags.map(tag => (
<View key={tag} style={[styles.tag, { backgroundColor: theme.colors.accentLight }]}>
<Text style={[theme.typography.caption, { color: theme.colors.accentDark }]}>#{tag}</Text>
</View>
))}
</View>
</Card>
)}
{/* 链接 */}
{(tx.links.length > 0 || isEditable) && (
<Card title={t('transaction.links')}>
<View style={styles.tagRow}>
{tx.links.map(link => (
<View key={link} style={[styles.tagContainer, { backgroundColor: theme.colors.accentLight }]}>
<Text style={[theme.typography.caption, { color: theme.colors.accentDark, fontWeight: '700' }]}>^{link}</Text>
{isEditable && (
<Pressable onPress={() => handleUnlink(link)} style={{ marginLeft: 6 }}>
<Ionicons name="close-circle" size={14} color={theme.colors.accentDark} />
</Pressable>
)}
</View>
))}
</View>
{isEditable && (
<Pressable
onPress={() => setLinkModalVisible(true)}
style={[styles.linkBtn, { borderColor: theme.colors.accent, marginTop: tx.links.length > 0 ? 12 : 0 }]}
>
<Ionicons name="link-outline" size={16} color={theme.colors.accent} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, marginLeft: 6, fontWeight: '700' }]}>
{t('transaction.linkTransaction')}
</Text>
</Pressable>
)}
{!isEditable && tx.links.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('transaction.readOnlyLinks')}
</Text>
)}
</Card>
)}
{/* 关联的交易列表 */}
{relatedTransactions.length > 0 && (
<Card title={t('transaction.relatedTransactions')}>
{relatedTransactions.map(item => (
<TransactionCard
key={item.id}
transaction={item}
onPress={(target) => router.push(`/transaction/${target.id}`)}
/>
))}
</Card>
)}
{/* 元数据 / 附加信息 */}
{tx.metadata && Object.keys(tx.metadata).length > 0 && (
<Card
title={`${t('transaction.metadata')} (${Object.keys(tx.metadata).length})`}
collapsible
defaultCollapsed
>
{Object.entries(tx.metadata).map(([k, v]) => (
<View key={k} style={styles.metaRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{k}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{v}</Text>
</View>
))}
</Card>
)}
{/* 原始 .bean */}
<Card
title={t('transaction.rawBean')}
collapsible
defaultCollapsed
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'], lineHeight: 20 }]}>
{tx.raw}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
{t('transaction.source')}: {tx.source}
</Text>
</Card>
{/* 编辑/删除按钮(所有本地 main.bean 交易均可操作) */}
{isEditable && (
<View style={styles.editActions}>
<Pressable
onPress={() => useNumpadUiStore.getState().open({ editId: id })}
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
>
<Ionicons name="create-outline" size={16} color={theme.colors.accent} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, marginLeft: 4, fontWeight: '700' }]}>
{t('transaction.edit')}
</Text>
</Pressable>
<Pressable
onPress={() => {
Alert.alert(t('transaction.deleteTitle'), t('transaction.deleteConfirm'), [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'), style: 'destructive',
onPress: async () => {
try {
await useLedgerStore.getState().deleteTransaction(tx.raw);
Alert.alert(t('transaction.deleteSuccess'));
router.back();
} catch (e) {
Alert.alert('Error', String(e));
}
},
},
]);
}}
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.error, opacity: pressed ? 0.7 : 1 }]}
>
<Ionicons name="trash-outline" size={16} color={theme.colors.error} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.error, marginLeft: 4, fontWeight: '700' }]}>
{t('transaction.delete')}
</Text>
</Pressable>
</View>
)}
</ScrollView>
{/* 关联交易筛选模态框 */}
<Modal
visible={linkModalVisible}
animationType="slide"
transparent={false}
onRequestClose={() => setLinkModalVisible(false)}
>
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<View style={styles.header}>
<Pressable onPress={() => setLinkModalVisible(false)}>
<Ionicons name="close" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('transaction.linkMobileTx')}
</Text>
</View>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
<View style={styles.searchBarContainer}>
<TextInput
style={[commonStyles.input, { minHeight: 40 }]}
placeholder={t('transaction.searchTxPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
value={searchQuery}
onChangeText={setSearchQuery}
/>
</View>
<FlatList
data={linkableTransactions}
keyExtractor={(item) => item.id}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => (
<View style={{ paddingHorizontal: 16, paddingVertical: 4 }}>
<TransactionCard
transaction={item}
onPress={() => handleLinkTransaction(item)}
/>
</View>
)}
ListEmptyComponent={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}>
{t('transaction.noLinkableTx')}
</Text>
}
/>
</KeyboardAvoidingView>
</SafeAreaView>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16 },
summaryRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
directionBadge: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: 12 },
tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
tag: { paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
tagContainer: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
linkBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 8 },
searchBarContainer: { paddingHorizontal: 16, paddingBottom: 8 },
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 },
flowCardItem: { flexDirection: 'row', alignItems: 'center', padding: 10 },
flowIconBox: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
miniTypeTag: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
});
@@ -0,0 +1,60 @@
import React, { useMemo } from 'react';
import { useT } from '../../i18n';
import { FormModal, type FormField } from '../form/FormModal';
export interface AccountCreateModalProps {
visible: boolean;
defaultType?: string;
onConfirm: (values: Record<string, string>) => void;
onCancel: () => void;
}
/**
*
* 3 Dropdown (1.2) + TextInput (2.0) + TextInput (0.9)
*/
export function AccountCreateModal({ visible, defaultType = 'Assets', onConfirm, onCancel }: AccountCreateModalProps) {
const t = useT();
const fields: FormField[] = useMemo(() => [
// Row 1: 账户类型 Dropdown (flex: 1) + 币种 TextInput (flex: 1)
{
key: 'type',
label: t('account.fieldType'),
type: 'dropdown',
options: [
{ label: t('account.rootTypes.Assets'), value: 'Assets' },
{ label: t('account.rootTypes.Liabilities'), value: 'Liabilities' },
{ label: t('account.rootTypes.Expenses'), value: 'Expenses' },
{ label: t('account.rootTypes.Income'), value: 'Income' },
{ label: t('account.rootTypes.Equity'), value: 'Equity' },
],
defaultValue: defaultType,
flex: 1,
},
{
key: 'currency',
label: t('account.fieldCurrency'),
placeholder: 'CNY',
defaultValue: 'CNY',
flex: 1,
},
// Row 2: 账户名称 TextInput (全宽大输入框)
{
key: 'name',
label: t('account.fieldName'),
placeholder: t('account.fieldNamePlaceholder'),
defaultValue: '',
},
], [t, defaultType]);
return (
<FormModal
visible={visible}
title={t('account.addModalTitle')}
fields={fields}
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
}
+57
View File
@@ -0,0 +1,57 @@
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { calculateAccountTotalBalance, type AccountNode } from '../../domain/taxonomy/accountTree';
interface AccountTreeProps {
nodes: AccountNode[];
/** 最大展示深度(默认全部)。 */
maxDepth?: number;
}
/** 账户树展示(主题化,缩进显示层级 + 余额)。 */
export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
return (
<View style={{ gap: 4 }}>
{nodes.map(node => (
<AccountTreeNode key={node.fullName} node={node} depth={0} maxDepth={maxDepth} />
))}
</View>
);
}
const AccountTreeNode = React.memo(function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) {
const { theme } = useTheme();
const t = useT();
const total = useMemo(() => calculateAccountTotalBalance(node), [node]);
const isLeaf = node.children.length === 0;
let displayName = node.name;
if (depth === 0) {
const localized = t(`account.rootTypes.${node.name as 'Assets' | 'Liabilities' | 'Income' | 'Expenses' | 'Equity'}`);
if (localized && !localized.startsWith('account.')) {
displayName = localized;
}
}
return (
<View>
<View style={[styles.row, { paddingLeft: depth * 16 }]}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: depth === 0 ? '700' : '400' }]}>
{isLeaf ? '• ' : ''}{displayName}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{total}
</Text>
</View>
{depth < maxDepth && node.children.map(child => (
<AccountTreeNode key={child.fullName} node={child} depth={depth + 1} maxDepth={maxDepth} />
))}
</View>
);
});
const styles = StyleSheet.create({
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 4 },
});
+32
View File
@@ -0,0 +1,32 @@
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { getCategoryColor } from '../../theme/palette';
import { getCategoryIcon } from './categoryIcons';
interface CategoryIconProps {
categoryId: string;
/** 圆形底色直径,默认 36。 */
size?: number;
}
/** 分类图标:Ionicon + 分类色圆形浅底(设计系统 §图标:替换 emoji)。 */
export function CategoryIcon({ categoryId, size = 36 }: CategoryIconProps) {
const color = getCategoryColor(categoryId);
return (
<View
style={[styles.circle, {
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color + '1A',
}]}
>
<Ionicons name={getCategoryIcon(categoryId)} size={size * 0.55} color={color} />
</View>
);
}
const styles = StyleSheet.create({
circle: { alignItems: 'center', justifyContent: 'center' },
});
@@ -0,0 +1,77 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { getCategoryColor } from '../../theme/palette';
import type { Category } from '../../domain/taxonomy/categories';
import { CategoryIcon } from './CategoryIcon';
import { Touchable } from '../ui/Touchable';
interface CategoryPickerProps {
categories: Category[];
selectedId?: string;
onSelect: (cat: Category) => void;
}
/** 分类选择器(网格卡片化布局)。 */
export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPickerProps) {
const { theme } = useTheme();
return (
<View style={styles.grid}>
{categories.map(cat => {
const active = cat.id === selectedId;
const color = getCategoryColor(cat.id);
return (
<Touchable
key={cat.id}
onPress={() => onSelect(cat)}
style={[
styles.item,
{
backgroundColor: active ? color + '15' : theme.colors.bgTertiary,
borderColor: active ? color : theme.colors.border,
}
]}
>
<CategoryIcon categoryId={cat.id} size={32} />
<Text
style={[
styles.label,
{
color: active ? theme.colors.fgPrimary : theme.colors.fgSecondary,
fontWeight: active ? '700' : '500',
}
]}
numberOfLines={1}
>
{cat.name}
</Text>
</Touchable>
);
})}
</View>
);
}
const styles = StyleSheet.create({
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
paddingVertical: 4,
},
item: {
width: '23%', // 约 4 列排布
aspectRatio: 1,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
borderWidth: 1,
gap: 6,
padding: 4,
},
label: {
fontSize: 12,
},
});
+53
View File
@@ -0,0 +1,53 @@
import React, { useMemo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme, createCommonStyles } from '../../theme';
import type { Tag } from '../../domain/taxonomy/tags';
interface TagPickerProps {
tags: Tag[];
selectedNames: string[];
onToggle: (tag: Tag) => void;
}
/** 标签选择器(主题化,多选 chip)。 */
export function TagPicker({ tags, selectedNames, onToggle }: TagPickerProps) {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
return (
<View style={styles.container}>
{tags.map(tag => {
const active = selectedNames.includes(tag.name);
return (
<Pressable
key={tag.id}
onPress={() => onToggle(tag)}
style={[
commonStyles.chip,
{
backgroundColor: active ? tag.color : 'transparent',
borderColor: active ? tag.color : theme.colors.border,
},
]}
>
<Text
style={[
commonStyles.chipText,
{
color: active ? theme.colors.fgInverse : theme.colors.fgPrimary,
fontSize: 13,
},
]}
>
#{tag.name}
</Text>
</Pressable>
);
})}
</View>
);
}
const styles = StyleSheet.create({
container: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, padding: 16 },
});
+39
View File
@@ -0,0 +1,39 @@
/**
* id Ionicons CategoryPicker emoji
* Vitest(node)
*/
import type { ComponentProps } from 'react';
import type { Ionicons } from '@expo/vector-icons';
export type IoniconName = ComponentProps<typeof Ionicons>['name'];
export const CATEGORY_ICON_NAMES: Record<string, IoniconName> = {
food: 'fast-food-outline',
transport: 'bus-outline',
shopping: 'bag-handle-outline',
housing_utility: 'water-outline',
housing_rent: 'home-outline',
housing_communication: 'call-outline',
entertainment: 'game-controller-outline',
services: 'construct-outline',
personal_care: 'sparkles-outline',
clothing: 'shirt-outline',
health: 'medkit-outline',
learning: 'book-outline',
salary: 'wallet-outline',
income_activity: 'gift-outline',
income_investment: 'trending-up-outline',
// 方向兜底(无分类交易:TransactionCard 传 expense/income/transfer
expense: 'cart-outline',
income: 'cash-outline',
transfer: 'swap-horizontal-outline',
};
const FALLBACK_ICON: IoniconName = 'pricetag-outline';
/** 取分类图标:具名映射,否则 fallback(hasOwn 防原型链穿透)。 */
export function getCategoryIcon(categoryId: string): IoniconName {
return Object.hasOwn(CATEGORY_ICON_NAMES, categoryId)
? CATEGORY_ICON_NAMES[categoryId]
: FALLBACK_ICON;
}
+88
View File
@@ -0,0 +1,88 @@
/**
* plan.md5.2 P4
* //
*/
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { generateAnnualReport } from '../../domain/stats/annualReport';
import type { Transaction } from '../../domain/core/types';
export function AnnualReport({ transactions, year }: { transactions: Transaction[]; year: number }) {
const { theme } = useTheme();
const t = useT();
const report = useMemo(() => generateAnnualReport(transactions, year), [transactions, year]);
// 展示层比率计算,允许 parseFloat(非记账金额)
const maxExpense = useMemo(
() => Math.max(...report.monthlyTrend.map(m => parseFloat(m.expense) || 0)),
[report],
);
return (
<View style={styles.container}>
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{t('report.annualTitle', { year })}</Text>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.annualIncome')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.income }]}>{report.totalIncome}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.annualExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense }]}>{report.totalExpense}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.annualNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent }]}>{report.netIncome}</Text>
</View>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('report.annualStats', { count: report.transactionCount, avg: report.averageDailyExpense })}
</Text>
</View>
{report.topCategories.length > 0 && (
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.topCategory')}</Text>
{report.topCategories.slice(0, 5).map(c => (
<View key={c.category} style={[styles.catRow, { borderTopColor: theme.colors.progressBg }]}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1 }]}>{c.category.replace('Expenses:', '')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>{c.amount} ({c.percentage}%)</Text>
</View>
))}
</View>
)}
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.monthlyRhythm')}</Text>
<View style={styles.rhythmRow}>
{report.monthlyTrend.map(m => {
const ratio = maxExpense > 0 ? Math.min(parseFloat(m.expense) / maxExpense, 1) : 0;
return (
<View key={m.month} style={styles.rhythmCol}>
{/* 节奏条圆角使用 theme.radii.sm / 2,避免硬编码 */}
<View style={[styles.rhythmTrack, { backgroundColor: theme.colors.bgTertiary, borderRadius: theme.radii.sm / 2 }]}>
<View style={[styles.rhythmFill, { height: `${Math.max(ratio * 100, 2)}%`, backgroundColor: theme.colors.financial.expense, borderRadius: theme.radii.sm / 2 }]} />
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: theme.typography.caption.fontSize - 3 }]}>{Number(m.month.slice(5))}</Text>
</View>
);
})}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 12 },
card: { gap: 8 },
statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
statItem: { alignItems: 'center', gap: 4 },
catRow: { flexDirection: 'row', borderTopWidth: 1, paddingTop: 8 },
rhythmRow: { flexDirection: 'row', gap: 4, height: 64, marginTop: 8 },
rhythmCol: { flex: 1, alignItems: 'center', gap: 4 },
rhythmTrack: { flex: 1, width: '100%', justifyContent: 'flex-end', overflow: 'hidden' },
rhythmFill: { width: '100%' },
});
+57
View File
@@ -0,0 +1,57 @@
/**
* plan.md5.2
*
*/
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { groupByCategory } from '../../domain/stats/chartStats';
import type { Transaction } from '../../domain/core/types';
export function CategoryPie({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme();
const t = useT();
const data = useMemo(() => groupByCategory(transactions), [transactions]);
const maxAmount = Math.max(...data.map(d => d.amount), 1);
return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{t('report.categoryTitle')}
</Text>
<View style={styles.list}>
{data.map(d => (
<View key={d.category} style={styles.row}>
<Text
style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1, maxWidth: '38%', fontWeight: '600' }]}
numberOfLines={1}
>
{d.category.replace('Expenses:', '').replace('Income:', '')}
</Text>
{/* 进度条圆角使用 theme.radii.sm / 2,避免硬编码 */}
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, borderWidth: StyleSheet.hairlineWidth, borderRadius: theme.radii.sm / 2 }]}>
<View style={[styles.bar, { width: `${(d.amount / maxAmount) * 100}%`, backgroundColor: theme.colors.accent, borderRadius: theme.radii.sm / 2 }]} />
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 55, textAlign: 'right', fontVariant: ['tabular-nums'], fontSize: theme.typography.bodySmall.fontSize - 1 }]}>
{d.percentage}%
</Text>
</View>
))}
</View>
{data.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 15 }]}>
{t('common.noData')}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 10 },
list: { gap: 8 },
row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
barWrap: { flex: 1, height: 8, overflow: 'hidden' },
bar: { height: '100%' },
});
+55
View File
@@ -0,0 +1,55 @@
/**
* plan.md5.2
*/
import React, { useEffect, useMemo, useRef } from 'react';
import { Animated, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { calculateNetWorthTrend } from '../../domain/stats/netWorth';
import type { Transaction } from '../../domain/core/types';
export function NetWorthChart({ transactions, dates }: { transactions: Transaction[]; dates: string[] }) {
const { theme } = useTheme();
const t = useT();
const points = useMemo(() => calculateNetWorthTrend(transactions, dates), [transactions, dates]);
const maxAbs = Math.max(...points.map(p => Math.abs(parseFloat(p.netWorth))), 1);
const progress = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(progress, { toValue: 1, duration: 600, useNativeDriver: false }).start();
}, [progress]);
return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.netWorthTitle')}</Text>
<View style={styles.chart}>
{points.map(p => (
<View key={p.date} style={styles.col}>
<View style={styles.barWrap}>
{/* 柱状条圆角使用 theme.radii.sm / 2,避免硬编码 */}
<Animated.View style={[styles.bar, {
height: progress.interpolate({ inputRange: [0, 1], outputRange: [0, (Math.abs(parseFloat(p.netWorth)) / maxAbs) * 100] }),
backgroundColor: parseFloat(p.netWorth) >= 0 ? theme.colors.accent : theme.colors.error,
borderRadius: theme.radii.sm / 2,
}]} />
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: theme.typography.caption.fontSize - 3 }]}>{p.date.slice(5)}</Text>
</View>
))}
</View>
{points.length > 0 && (
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('report.netWorthCurrent', { amount: points[points.length - 1].netWorth })}
</Text>
)}
{points.length === 0 && <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('common.noData')}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 8 },
chart: { flexDirection: 'row', alignItems: 'flex-end', height: 120, gap: 4 },
col: { flex: 1, alignItems: 'center', gap: 4 },
barWrap: { height: 100, justifyContent: 'flex-end', width: '100%', alignItems: 'center' },
bar: { width: 12 },
});
+193
View File
@@ -0,0 +1,193 @@
/**
* 线plan.md5.2
* react-native-svg 线
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Animated, Dimensions, StyleSheet, Text, View } from 'react-native';
import Svg, { Path, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import type { Transaction } from '../../domain/core/types';
import { groupByMonth } from '../../domain/stats/chartStats';
export function TrendLine({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme();
const t = useT();
const fade = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(fade, { toValue: 1, duration: 500, useNativeDriver: true }).start();
}, [fade]);
const data = useMemo(() => groupByMonth(transactions).slice(-6), [transactions]);
// 1. 数据配置 —— 自适应宽度:初始取屏幕宽减去页面 padding,onLayout 后更新为容器实际宽度
const [chartWidth, setChartWidth] = useState(() => Dimensions.get('window').width - 64);
const height = 120;
const paddingX = 20;
const paddingY = 15;
const maxVal = Math.max(...data.flatMap(d => [d.income, d.expense]), 1);
// 2. 坐标转换计算
const pointsIncome = data.map((d, i) => {
const x = data.length > 1
? paddingX + (i * (chartWidth - 2 * paddingX)) / (data.length - 1)
: chartWidth / 2;
const y = height - paddingY - (d.income / maxVal) * (height - 2 * paddingY);
return { x, y };
});
const pointsExpense = data.map((d, i) => {
const x = data.length > 1
? paddingX + (i * (chartWidth - 2 * paddingX)) / (data.length - 1)
: chartWidth / 2;
const y = height - paddingY - (d.expense / maxVal) * (height - 2 * paddingY);
return { x, y };
});
// 3. 贝塞尔曲线生成算法
const getBezierPath = (points: { x: number; y: number }[]) => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i];
const p1 = points[i + 1];
const cp1x = p0.x + (p1.x - p0.x) / 2;
const cp1y = p0.y;
const cp2x = p0.x + (p1.x - p0.x) / 2;
const cp2y = p1.y;
path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p1.x} ${p1.y}`;
}
return path;
};
const getClosedBezierPath = (points: { x: number; y: number }[]) => {
if (points.length === 0) return '';
const linePath = getBezierPath(points);
const first = points[0];
const last = points[points.length - 1];
return `${linePath} L ${last.x} ${height - paddingY} L ${first.x} ${height - paddingY} Z`;
};
const incomePath = getBezierPath(pointsIncome);
const incomeClosedPath = getClosedBezierPath(pointsIncome);
const expensePath = getBezierPath(pointsExpense);
const expenseClosedPath = getClosedBezierPath(pointsExpense);
return (
<Animated.View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md, opacity: fade }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{t('report.trendTitle')}
</Text>
{data.length > 0 ? (
<View style={styles.chartWrapper} onLayout={(e) => setChartWidth(e.nativeEvent.layout.width)}>
<Svg width="100%" height={height} viewBox={`0 0 ${chartWidth} ${height}`}>
<Defs>
<LinearGradient id="incomeGrad" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0%" stopColor={theme.colors.financial.income} stopOpacity={0.25} />
<Stop offset="100%" stopColor={theme.colors.financial.income} stopOpacity={0.0} />
</LinearGradient>
<LinearGradient id="expenseGrad" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0%" stopColor={theme.colors.financial.expense} stopOpacity={0.25} />
<Stop offset="100%" stopColor={theme.colors.financial.expense} stopOpacity={0.0} />
</LinearGradient>
</Defs>
{/* 网格线(只绘制一条底线和中线) */}
<Path
d={`M ${paddingX} ${height / 2} L ${chartWidth - paddingX} ${height / 2} M ${paddingX} ${height - paddingY} L ${chartWidth - paddingX} ${height - paddingY}`}
stroke={theme.colors.divider}
strokeWidth={1}
strokeDasharray="4 4"
/>
{/* 收入填充与折线 */}
{incomeClosedPath ? <Path d={incomeClosedPath} fill="url(#incomeGrad)" /> : null}
{incomePath ? (
<Path
d={incomePath}
fill="none"
stroke={theme.colors.financial.income}
strokeWidth={2.5}
strokeLinecap="round"
/>
) : null}
{/* 支出填充与折线 */}
{expenseClosedPath ? <Path d={expenseClosedPath} fill="url(#expenseGrad)" /> : null}
{expensePath ? (
<Path
d={expensePath}
fill="none"
stroke={theme.colors.financial.expense}
strokeWidth={2.5}
strokeLinecap="round"
/>
) : null}
{/* 数据点标记 */}
{pointsIncome.map((p, i) => (
<Circle
key={`income-dot-${i}`}
cx={p.x}
cy={p.y}
r={3.5}
fill={theme.colors.bgSecondary}
stroke={theme.colors.financial.income}
strokeWidth={2}
/>
))}
{pointsExpense.map((p, i) => (
<Circle
key={`expense-dot-${i}`}
cx={p.x}
cy={p.y}
r={3.5}
fill={theme.colors.bgSecondary}
stroke={theme.colors.financial.expense}
strokeWidth={2}
/>
))}
</Svg>
{/* 月度文本 X 轴 */}
<View style={styles.xAxis}>
{data.map((d, i) => (
<Text
key={`x-label-${i}`}
style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: theme.typography.caption.fontSize - 2 }]}
>
{d.month.slice(5)}
</Text>
))}
</View>
</View>
) : (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 20 }]}>
{t('common.noData')}
</Text>
)}
<View style={styles.legend}>
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.income }]} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('home.income')}
</Text>
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.expense }]} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('home.expense')}
</Text>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: { gap: 10 },
chartWrapper: { marginTop: 6 },
xAxis: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 12, marginTop: 4 },
legend: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 },
legendDot: { width: 8, height: 8, borderRadius: 4, marginLeft: 8 },
});
+117
View File
@@ -0,0 +1,117 @@
import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { BottomSheet } from '../ui/BottomSheet';
import { buildMonthGrid } from '../stats/dateGrid';
interface DatePickerFieldProps {
/** YYYY-MM-DD,空串表示未选。 */
value: string;
onChange: (date: string) => void;
placeholder?: string;
}
/** 日期选择字段:输入框外观 + 底部弹层月历(终结手输 YYYY-MM-DD)。 */
export function DatePickerField({ value, onChange, placeholder }: DatePickerFieldProps) {
const { theme } = useTheme();
const t = useT();
// 星期头周一起(与 buildMonthGrid 网格一致),文案走 i18n
const weekdays = useMemo(() => t('datepicker.weekdays').split(','), [t]);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const [open, setOpen] = useState(false);
const [viewYear, setViewYear] = useState(new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(new Date().getMonth() + 1);
const grid = useMemo(() => buildMonthGrid(viewYear, viewMonth), [viewYear, viewMonth]);
const openPicker = () => {
const y = Number(value.slice(0, 4));
const m = Number(value.slice(5, 7));
if (y) {
setViewYear(y);
setViewMonth(m || 1);
}
setOpen(true);
};
const shiftMonth = (delta: number) => {
const m = viewMonth - 1 + delta;
setViewYear(viewYear + Math.floor(m / 12));
setViewMonth(((m % 12) + 12) % 12 + 1);
};
const pick = (date: string) => {
onChange(date);
setOpen(false);
};
return (
<>
<Pressable style={commonStyles.input} onPress={openPicker} accessibilityRole="button" accessibilityLabel={value || placeholder || '选择日期'}>
<Text style={{ color: value ? theme.colors.fgPrimary : theme.colors.fgSecondary }}>
{value || placeholder || 'YYYY-MM-DD'}
</Text>
</Pressable>
<BottomSheet visible={open} onClose={() => setOpen(false)}>
{/* 月份导航 */}
<View style={styles.navRow}>
<Pressable onPress={() => shiftMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月">
<Ionicons name="chevron-back" size={22} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'] }]}>
{viewYear}-{String(viewMonth).padStart(2, '0')}
</Text>
<Pressable onPress={() => shiftMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月">
<Ionicons name="chevron-forward" size={22} color={theme.colors.fgPrimary} />
</Pressable>
</View>
{/* 星期头 */}
<View style={styles.weekRow}>
{weekdays.map(w => (
<Text key={w} style={[styles.weekCell, theme.typography.caption, { color: theme.colors.fgSecondary }]}>{w}</Text>
))}
</View>
{/* 日期网格 */}
{grid.map((week, wi) => (
<View key={wi} style={styles.weekRow}>
{week.map(cell => {
const selected = cell.date === value;
return (
<Pressable
key={cell.date}
onPress={() => pick(cell.date)}
accessibilityRole="button"
accessibilityLabel={cell.date}
accessibilityState={{ selected }}
style={[styles.dayCell, {
backgroundColor: selected ? theme.colors.accent : 'transparent',
borderRadius: theme.radii.full,
}]}
>
<Text style={[theme.typography.bodySmall, {
color: selected
? theme.colors.fgInverse
: cell.inMonth ? theme.colors.fgPrimary : theme.colors.fgSecondary,
opacity: cell.inMonth ? 1 : 0.5,
fontVariant: ['tabular-nums'],
}]}>
{Number(cell.date.slice(8, 10))}
</Text>
</Pressable>
);
})}
</View>
))}
</BottomSheet>
</>
);
}
const styles = StyleSheet.create({
navRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
weekRow: { flexDirection: 'row' },
weekCell: { flex: 1, textAlign: 'center', paddingVertical: 6 },
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', margin: 1 },
});
+95
View File
@@ -0,0 +1,95 @@
/**
* spec §6 / /
*
*/
import React from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { BottomSheet } from '../ui/BottomSheet';
import { DatePickerField } from './DatePickerField';
import { Button } from '../ui/Button';
export interface AdvancedFilterValues {
account: string;
dateFrom: string;
dateTo: string;
amountMin: string;
amountMax: string;
}
interface FilterSheetProps {
visible: boolean;
onClose: () => void;
values: AdvancedFilterValues;
onChange: (values: AdvancedFilterValues) => void;
onReset: () => void;
}
export function FilterSheet({ visible, onClose, values, onChange, onReset }: FilterSheetProps) {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT();
const set = (patch: Partial<AdvancedFilterValues>) => onChange({ ...values, ...patch });
return (
<BottomSheet visible={visible} onClose={onClose} title={t('filter.title')} scrollable>
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
{t('transactions.accountFilterPlaceholder')}
</Text>
<TextInput
value={values.account}
onChangeText={v => set({ account: v })}
placeholder={t('transactions.accountFilterPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
{t('transactions.dateFrom')} ~ {t('transactions.dateTo')}
</Text>
<View style={styles.row}>
<View style={styles.flex1}><DatePickerField value={values.dateFrom} onChange={v => set({ dateFrom: v })} placeholder={t('transactions.dateFrom')} /></View>
<View style={styles.flex1}><DatePickerField value={values.dateTo} onChange={v => set({ dateTo: v })} placeholder={t('transactions.dateTo')} /></View>
</View>
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
{t('transactions.amountMin')} ~ {t('transactions.amountMax')}
</Text>
<View style={styles.row}>
<TextInput
value={values.amountMin}
onChangeText={v => set({ amountMin: v })}
placeholder={t('transactions.amountMin')}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType="decimal-pad"
style={[commonStyles.input, styles.flex1]}
/>
<TextInput
value={values.amountMax}
onChangeText={v => set({ amountMax: v })}
placeholder={t('transactions.amountMax')}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType="decimal-pad"
style={[commonStyles.input, styles.flex1]}
/>
</View>
<View style={[styles.row, styles.actions]}>
<View style={styles.flex1}>
<Button label={t('filter.reset')} variant="secondary" onPress={onReset} />
</View>
<View style={styles.flex1}>
<Button label={t('filter.apply')} onPress={onClose} />
</View>
</View>
</BottomSheet>
);
}
const styles = StyleSheet.create({
label: { marginTop: 10, marginBottom: 4 },
row: { flexDirection: 'row', gap: 8 },
flex1: { flex: 1 },
actions: { marginTop: 16 },
});

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