Compare commits

..
4 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
272 changed files with 26433 additions and 25274 deletions
+2
View File
@@ -48,3 +48,5 @@ CLAUDE.md
/.agents/ /.agents/
/reference_project/ /reference_project/
/.zcode/ /.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`
+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 ```bash
npm install npm install --legacy-peer-deps # 安装依赖(必须带 --legacy-peer-deps
npm run start 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 ```beancount
include "mobile.bean" include "main.bean"
``` ```
应用不改写主账本或其他桌面维护文件;确认的交易只追加到 `mobile.bean`导出的账本包由用户再同步到 Git、WebDAV 或 iCloud。 应用不改写主账本或其他桌面维护文件;确认的交易只追加到 `main.bean`
## 当前边界
已实现账务校验、基础 `.bean` 解析、CSV 账单规范化、去重、规则分类、转账候选和 `mobile.bean` 序列化。Tree-sitter 原生模块、真实文件选择/ZIP 导出、正式支付宝/微信/各银行字段映射、SecureStore 密钥和多设备冲突处理仍需在真机集成阶段接入。
+11 -5
View File
@@ -1,8 +1,9 @@
{ {
"expo": { "expo": {
"name": "Bean Mobile", "name": "浮记",
"slug": "bean-mobile", "slug": "drift-ledger",
"scheme": "beanmobile", "scheme": "driftledger",
"icon": "./assets/icon/direction_a_feather.png",
"plugins": [ "plugins": [
[ [
"expo-sqlite", "expo-sqlite",
@@ -25,10 +26,15 @@
"typedRoutes": true "typedRoutes": true
}, },
"ios": { "ios": {
"bundleIdentifier": "com.example.beanmobile" "bundleIdentifier": "com.example.driftledger",
"icon": "./assets/icon/direction_a_feather.png"
}, },
"android": { "android": {
"package": "com.example.beanmobile" "package": "com.example.driftledger",
"adaptiveIcon": {
"foregroundImage": "./assets/icon/direction_a_feather.png",
"backgroundColor": "#1B1F20"
}
} }
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

+56 -190
View File
@@ -1,206 +1,72 @@
# Design System Master File # Beancount Mobile 设计系统(MASTER
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`. > 本文件与 `src/theme/presets.ts` 一一对应,是实现的事实描述而非平行标准。
> If that file exists, its rules **override** this Master file. > 修改配色/字阶/圆角时必须先改 presets.ts,再同步本文件。
> If not, strictly follow the rules below.
--- ## 设计方向:明亮 Bento 现代风
**Project:** beancount-mobile 浅色为主、大圆角卡片(Bento)、近黑白单色 + 财务语义色点缀。
**Generated:** 2026-07-16 16:32:57 品牌感靠排版与财务色表达,不依赖彩色 accent。暗色为 OLED 纯黑完整对等主题。
**Category:** Personal Finance Tracker
--- ## 色板(= presets.ts
## Global Rules ### 浅色(默认)
| 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)` | 遮罩 |
### Color Palette ### 暗色(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)` |
| Role | Hex | CSS Variable | 分类/标签/渠道颜色见 `src/theme/palette.ts`(分类 12 色循环 + 具名映射、TAG_COLORS、渠道品牌色)。
|------|-----|--------------| 其余 tokendivider / skeleton / progressBg / success / warning / error / info)见 `presets.ts`
| Primary | `#1E40AF` | `--color-primary` | 全工程颜色字面量只允许存在于 `presets.ts` `palette.ts`
| On Primary | `#FFFFFF` | `--color-on-primary` |
| Secondary | `#3B82F6` | `--color-secondary` |
| Accent/CTA | `#059669` | `--color-accent` |
| Background | `#0F172A` | `--color-background` |
| Foreground | `#FFFFFF` | `--color-foreground` |
| Muted | `#101A34` | `--color-muted` |
| Border | `rgba(255,255,255,0.08)` | `--color-border` |
| Destructive | `#DC2626` | `--color-destructive` |
| Ring | `#1E40AF` | `--color-ring` |
**Color Notes:** Trust blue + profit green on dark ## 字体
### Typography 系统字体(iOS SF / Android Roboto),不加载自定义字体。
金额数字一律 `fontVariant: ['tabular-nums']` 等宽对齐。
- **Heading Font:** Caveat 字阶:display 34/800 · h1 28/700 · h2 22/700 · h3 17/600 · body 16/400 · bodySmall 14/400 · caption 12/400。
- **Body Font:** Quicksand
- **Mood:** handwritten, personal, friendly, casual, warm, charming
- **Google Fonts:** [Caveat + Quicksand](https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap)
**CSS Import:** ## 圆角 / 间距 / 阴影
```css
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap');
```
### Spacing Variables - 圆角:sm 8 · md 12 · lg 16 · **xl 24(弹窗默认;卡片组件 P2 统一升级)** · full
- 间距:xs 4 · sm 8 · md 16 · lg 24 · xl 32
- 阴影:浅色 4–12px 弥散轻阴影;暗色阴影减重,辅以半透边框
| Token | Value | Usage | ## 图标
|-------|-------|-------|
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
| `--space-md` | `16px` / `1rem` | Standard padding |
| `--space-lg` | `24px` / `1.5rem` | Section padding |
| `--space-xl` | `32px` / `2rem` | Large gaps |
| `--space-2xl` | `48px` / `3rem` | Section margins |
| `--space-3xl` | `64px` / `4rem` | Hero padding |
### Shadow Depths 统一 Ionicons`@expo/vector-icons`)。禁止用 emoji 充当图标;
分类图标 = Ionicon + 圆形彩色底(CategoryIcon 组件,P2 落地)。
| Level | Value | Usage | ## 反模式
|-------|-------|-------|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
--- - 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token
- 禁止低对比文字(< 4.5:1
## Component Specs - 禁止瞬间状态变化,交互反馈 150–300ms
- 禁止绕过 commonStyles 重复造 input/chip/modal 样式
### Buttons
```css
/* Primary Button */
.btn-primary {
background: #059669;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Secondary Button */
.btn-secondary {
background: transparent;
color: #1E40AF;
border: 2px solid #1E40AF;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
```
### Cards
```css
.card {
background: #0F172A;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow-md);
transition: all 200ms ease;
cursor: pointer;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
```
### Inputs
```css
.input {
padding: 12px 16px;
border: 1px solid #E2E8F0;
border-radius: 8px;
font-size: 16px;
transition: border-color 200ms ease;
}
.input:focus {
border-color: #1E40AF;
outline: none;
box-shadow: 0 0 0 3px #1E40AF20;
}
```
### Modals
```css
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-xl);
max-width: 500px;
width: 90%;
}
```
---
## Style Guidelines
**Style:** Dark Mode (OLED)
**Keywords:** Dark theme, low light, high contrast, deep black, midnight blue, eye-friendly, OLED, night mode, power efficient
**Best For:** Night-mode apps, coding platforms, entertainment, eye-strain prevention, OLED devices, low-light
**Key Effects:** Minimal glow (text-shadow: 0 0 10px), dark-to-light transitions, low white emission, high readability, visible focus
### Page Pattern
**Pattern Name:** Interactive Product Demo
- **CTA Placement:** Above fold
- **Section Order:** Hero > Features > CTA
---
## Anti-Patterns (Do NOT Use)
- ❌ Pure white backgrounds
### Additional Forbidden Patterns
-**Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
-**Missing cursor:pointer** — All clickable elements must have cursor:pointer
-**Layout-shifting hovers** — Avoid scale transforms that shift layout
-**Low contrast text** — Maintain 4.5:1 minimum contrast ratio
-**Instant state changes** — Always use transitions (150-300ms)
-**Invisible focus states** — Focus states must be visible for a11y
---
## Pre-Delivery Checklist
Before delivering any UI code, verify:
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons from consistent icon set (Heroicons/Lucide)
- [ ] `cursor-pointer` on all clickable elements
- [ ] Hover states with smooth transitions (150-300ms)
- [ ] Light mode: text contrast 4.5:1 minimum
- [ ] Focus states visible for keyboard navigation
- [ ] `prefers-reduced-motion` respected
- [ ] Responsive: 375px, 768px, 1024px, 1440px
- [ ] No content hidden behind fixed navbars
- [ ] No horizontal scroll on mobile
+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` 标志和页面签名白名单的配合。
+283 -13
View File
@@ -6,6 +6,51 @@
--- ---
## 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.1 ABI 分包 (ABI Splits) ### 1.1 ABI 分包 (ABI Splits)
@@ -31,11 +76,14 @@ reactNativeArchitectures=arm64-v8a
``` ```
* **为什么只包含 arm64-v8a**:目前 99% 的主流现代 Android 实体机都是 64 位 ARM 架构(`arm64-v8a`)。在进行日常分发与本地 Release 测试时,默认只编译 arm64-v8a,能够省去编译另外 3 个架构的机器指令时间,**编译速度提升 3~4 倍**,且输出的包体最小。 * **为什么只包含 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) 的自动应用 ### 1.3 Expo 持续原生生成 (Config Plugin) 的自动应用
> [!IMPORTANT] > [!IMPORTANT]
> 由于 `android/` 目录被 Git 忽略,**不要直接手动在其他设备上提交原生配置变更** > 由于 `android/` 目录被 Git 忽略(见 `.gitignore`),**不要直接手动修改 `android/` 下的文件并提交**——它们是 prebuild 生成的产物,会在下次重建时丢失
> 本项目已编写了专门的本地 Config Plugin[size-optimization](file:///c:/Users/fmq/Documents/work/beancount-mobile/plugins/size-optimization/app.plugin.js)。 > 本项目已编写了专门的本地 Config Plugin[size-optimization](file:///c:/Users/fmq/Documents/work/DriftLedger/plugins/size-optimization/app.plugin.js)。
> 当在新设备上重新 `git clone` 项目后,运行以下指令即可自动拉起 Config Plugin 并在重新生成的 `android/` 目录中完美注入上述所有的体积优化配置(ABI 分包、默认单架构编译): > 当在新设备上重新 `git clone` 项目后,运行以下指令即可自动拉起所有 Config Plugin 并在重新生成的 `android/` 目录中完美注入上述所有的体积优化配置(ABI 分包、默认单架构编译、NDK 版本统一):
> ```bash > ```bash
> npx expo prebuild --platform android > npx expo prebuild --platform android
> ``` > ```
@@ -47,15 +95,24 @@ reactNativeArchitectures=arm64-v8a
请在项目的根目录(若已在 `android/` 目录中则不需要前缀 `cd android`)执行以下指令: 请在项目的根目录(若已在 `android/` 目录中则不需要前缀 `cd android`)执行以下指令:
### 2.1 本地测试/实体分发(仅编译 arm64-v8a,最快最推荐) ### 2.1 本地测试/实体分发(仅编译 arm64-v8a,最快最推荐)
直接运行默认编译,会使用 `gradle.properties` 中配置 of `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 ```powershell
# 在 Windows Powershell 下执行:
$env:JAVA_HOME="C:\Program Files\Java\jdk-21" $env:JAVA_HOME="C:\Program Files\Java\jdk-21"
$env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk" $env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk"
cd android cd android
.\gradlew.bat :app:assembleRelease --offline --no-daemon .\gradlew.bat :app:assembleRelease --offline --no-daemon
``` ```
编译完成后,可在以下路径找到适合真机安装的轻量版 APK(约 37MB): 编译完成后,可在以下路径找到适合真机安装的轻量版 APK(开启混淆后约 **24MB**):
* `android\app\build\outputs\apk\release\app-arm64-v8a-release.apk` * `android\app\build\outputs\apk\release\app-arm64-v8a-release.apk`
--- ---
@@ -86,15 +143,228 @@ cd android
--- ---
## 3. 高级优化项:Proguard/R8 混淆 (选填) ## 3. Proguard/R8 混淆(已默认开启)
如需进一步将独立包体积压缩到 25MB 左右,可以考虑在 `gradle.properties` 中开启混淆并做裁剪防御。
1. `gradle.properties` 中添加 本项目的 `gradle.properties` 已**默认启用**代码混淆与资源压缩
```properties ```properties
android.enableMinifyInReleaseBuilds=true android.enableMinifyInReleaseBuilds=true
android.enableShrinkResourcesInReleaseBuilds=true android.enableShrinkResourcesInReleaseBuilds=true
``` ```
2. 注意:由于引入了 `ONNX Runtime` 动态调用,如果运行崩溃,需要在 `android/app/proguard-rules.pro` 中加入如下混淆保留白名单: 这使得 arm64-v8a 独立包从 ~37MB 压缩到约 **24MB**。无需手动开启。
```proguard
-keep class com.microsoft.onnxruntime.** { *; } > [!WARNING]
-dontwarn com.microsoft.onnxruntime.** > 若新增了依赖反射/动态加载的库(如 `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',
},
},
);
+1324 -13
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -1,5 +1,5 @@
{ {
"name": "beancount-mobile", "name": "drift-ledger",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"main": "expo-router/entry", "main": "expo-router/entry",
@@ -8,11 +8,11 @@
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"test": "vitest run", "test": "vitest run",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"lint": "eslint src/ tests/",
"lint:fix": "eslint src/ tests/ --fix"
}, },
"dependencies": { "dependencies": {
"@expo-google-fonts/caveat": "^0.4.2",
"@expo-google-fonts/quicksand": "^0.4.1",
"@expo/metro-runtime": "^6.1.2", "@expo/metro-runtime": "^6.1.2",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-navigation/drawer": "^7.5.0", "@react-navigation/drawer": "^7.5.0",
@@ -45,8 +45,13 @@
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "~19.1.0", "@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": "~5.9.0",
"typescript-eslint": "^8.65.0",
"vitest": "^3.2.0" "vitest": "^3.2.0"
}, },
"overrides": { "overrides": {
+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 后请验证版本
@@ -1,5 +1,6 @@
package com.beancount.mobile.accessibility package com.beancount.mobile.accessibility
import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReactMethod
@@ -7,11 +8,12 @@ import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.WritableNativeArray import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
/** /**
* 无障碍服务 RN 桥接模块plan.md3.6 无障碍服务JS 接线 * 无障碍服务 RN 桥接模块plan.md3.6 无障碍服务JS 接线
* *
* BillingAccessibilityService AccessibilityService 子类 RN 模块 * SelectToSpeakService AccessibilityService 子类 RN 模块
* 其方法无法直接从 JS 调用本模块作为中间层通过 instance 静态引用 * 其方法无法直接从 JS 调用本模块作为中间层通过 instance 静态引用
* JS 调用委托给服务实例 * JS 调用委托给服务实例
* *
@@ -20,6 +22,10 @@ import com.facebook.react.bridge.ReadableArray
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) : class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) { ReactContextBaseJavaModule(reactContext) {
companion object {
private const val TAG = "AccessibilityBridge"
}
init { init {
ReactContextHolder.context = reactContext ReactContextHolder.context = reactContext
} }
@@ -36,7 +42,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 无障碍服务是否已连接(用户已在系统设置中启用)。 */ /** 无障碍服务是否已连接(用户已在系统设置中启用)。 */
@ReactMethod @ReactMethod
fun isServiceRunning(promise: Promise) { fun isServiceRunning(promise: Promise) {
promise.resolve(BillingAccessibilityService.instance != null) promise.resolve(SelectToSpeakService.instance != null)
} }
/** /**
@@ -45,7 +51,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
*/ */
@ReactMethod @ReactMethod
fun rememberCurrentPage(promise: Promise) { fun rememberCurrentPage(promise: Promise) {
val service = BillingAccessibilityService.instance val service = SelectToSpeakService.instance
if (service == null) { if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用") promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return return
@@ -66,7 +72,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 手动触发一次 OCR(截取当前屏幕并发送给 JS 层处理)。 */ /** 手动触发一次 OCR(截取当前屏幕并发送给 JS 层处理)。 */
@ReactMethod @ReactMethod
fun triggerManualOcr(promise: Promise) { fun triggerManualOcr(promise: Promise) {
val service = BillingAccessibilityService.instance val service = SelectToSpeakService.instance
if (service == null) { if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用") promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return return
@@ -82,7 +88,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 获取所有已记住的页面签名列表。 */ /** 获取所有已记住的页面签名列表。 */
@ReactMethod @ReactMethod
fun getPageSignatures(promise: Promise) { fun getPageSignatures(promise: Promise) {
val service = BillingAccessibilityService.instance val service = SelectToSpeakService.instance
val sigsSet = if (service != null) { val sigsSet = if (service != null) {
service.getPageSignatures() service.getPageSignatures()
} else { } else {
@@ -108,7 +114,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 清空所有已记住的页面签名。 */ /** 清空所有已记住的页面签名。 */
@ReactMethod @ReactMethod
fun clearPageSignatures(promise: Promise) { fun clearPageSignatures(promise: Promise) {
val service = BillingAccessibilityService.instance val service = SelectToSpeakService.instance
if (service != null) { if (service != null) {
service.clearPageSignatures() service.clearPageSignatures()
} else { } else {
@@ -126,7 +132,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 删除指定页面签名。 */ /** 删除指定页面签名。 */
@ReactMethod @ReactMethod
fun removePageSignature(signature: String, promise: Promise) { fun removePageSignature(signature: String, promise: Promise) {
val service = BillingAccessibilityService.instance val service = SelectToSpeakService.instance
if (service != null) { if (service != null) {
service.removePageSignature(signature) service.removePageSignature(signature)
} else { } else {
@@ -149,7 +155,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
@ReactMethod @ReactMethod
fun getPaymentPackages(promise: Promise) { fun getPaymentPackages(promise: Promise) {
val arr = WritableNativeArray() val arr = WritableNativeArray()
for (pkg in BillingAccessibilityService.PAYMENT_PACKAGES) { for (pkg in SelectToSpeakService.PAYMENT_PACKAGES) {
arr.pushString(pkg) arr.pushString(pkg)
} }
promise.resolve(arr) promise.resolve(arr)
@@ -158,7 +164,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 获取当前顶部 App 信息(供 JS 判断用户是否在支付页面)。 */ /** 获取当前顶部 App 信息(供 JS 判断用户是否在支付页面)。 */
@ReactMethod @ReactMethod
fun getTopApp(promise: Promise) { fun getTopApp(promise: Promise) {
val service = BillingAccessibilityService.instance val service = SelectToSpeakService.instance
if (service == null) { if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用") promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return return
@@ -172,7 +178,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 将当前应用拉起至前台,用以在后台识别出账单后,弹窗让用户进行交易确认 */ /** 将当前应用拉起至前台,用以在后台识别出账单后,弹窗让用户进行交易确认 */
@ReactMethod @ReactMethod
fun bringAppToForeground(promise: Promise) { fun bringAppToForeground(promise: Promise) {
val service = BillingAccessibilityService.instance val service = SelectToSpeakService.instance
if (service == null) { if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用") promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return return
@@ -195,10 +201,11 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
categories: ReadableArray, categories: ReadableArray,
accounts: ReadableArray, accounts: ReadableArray,
direction: String, direction: String,
currency: String,
draftId: String, draftId: String,
promise: Promise promise: Promise
) { ) {
val context = reactContext.currentActivity ?: BillingAccessibilityService.instance val context = SelectToSpeakService.instance ?: reactContext.currentActivity
if (context == null) { if (context == null) {
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例") promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
return return
@@ -222,7 +229,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
android.os.Handler(android.os.Looper.getMainLooper()).post { android.os.Handler(android.os.Looper.getMainLooper()).post {
try { try {
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction) val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction, currency)
floatingView.show() floatingView.show()
promise.resolve(true) promise.resolve(true)
} catch (e: Exception) { } catch (e: Exception) {
@@ -230,4 +237,88 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
} }
} }
} }
/** 设置悬浮球开关状态。 */
@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)
}
}
} }
+235 -149
View File
@@ -17,9 +17,11 @@ import android.widget.LinearLayout
import android.text.InputType import android.text.InputType
import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.modules.core.DeviceEventManagerModule
import java.math.BigDecimal
/** /**
* 浮窗账单提示直接呈现高度优化支持三方向切换的修改入账面板 * 浮窗账单提示直接呈现高度优化支持三方向切换的修改入账面板
* P6 重设计颜色/文案走 FloatingUiConfigStore新增币种 chip金额校验改正则+BigDecimal
*/ */
class FloatingBillView( class FloatingBillView(
private val context: Context, private val context: Context,
@@ -30,12 +32,27 @@ class FloatingBillView(
private val packageName: String, private val packageName: String,
private val categories: List<Map<String, String>> = emptyList(), private val categories: List<Map<String, String>> = emptyList(),
private val accounts: List<String> = emptyList(), private val accounts: List<String> = emptyList(),
private val initialDirection: String = "expense" private val initialDirection: String = "expense",
private val initialCurrency: String = "CNY"
) { ) {
companion object { companion object {
private const val TAG = "FloatingBillView" 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 val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private var view: View? = null private var view: View? = null
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
@@ -49,6 +66,20 @@ class FloatingBillView(
private var accountContainer: LinearLayout? = null private var accountContainer: LinearLayout? = null
private var saveBtn: Button? = 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 { private fun dp(value: Float): Int {
val density = context.resources.displayMetrics.density val density = context.resources.displayMetrics.density
return (value * density).toInt() return (value * density).toInt()
@@ -74,29 +105,34 @@ class FloatingBillView(
currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense" currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense"
// 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部 // 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( val windowParams = WindowManager.LayoutParams(
dp(310f), dp(310f),
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, overlayType,
0, // 0 标志代表可获取焦点 0, // 0 标志代表可获取焦点
PixelFormat.TRANSLUCENT PixelFormat.TRANSLUCENT
).apply { ).apply {
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
y = dp(12f) // 尽量靠下以留出上方账单对照 y = dp(48f) // 留出底部导航栏空间 + 按钮/键盘安全
} }
// 2. 使用 55% 透明度 OLED 磨砂效果背景,发光靛蓝细边框 // 2. 实色卡片背景(不再半透明磨砂),圆角 16dp,1dp 描边
val containerBg = GradientDrawable().apply { val containerBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE shape = GradientDrawable.RECTANGLE
cornerRadius = dp(14f).toFloat() cornerRadius = dp(16f).toFloat()
setColor(0x8C050506.toInt()) // 55% 透明度 OLED 黑色 setColor(colorCardBg)
setStroke(dp(1.2f), 0x995E6AD2.toInt()) // 60% 透明度靛蓝发光边框 setStroke(dp(1f), colorBorder) // 使用 config border 颜色
} }
val container = LinearLayout(context).apply { val container = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
background = containerBg background = containerBg
setPadding(dp(12f), dp(8f), dp(12f), dp(8f)) setPadding(dp(16f), dp(16f), dp(16f), dp(16f))
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT LinearLayout.LayoutParams.WRAP_CONTENT
@@ -114,9 +150,9 @@ class FloatingBillView(
} }
val titleText = TextView(context).apply { val titleText = TextView(context).apply {
text = "调整交易草稿" text = config.labels.billTitle
textSize = 12f textSize = 13f
setTextColor(0xFF98A2FF.toInt()) setTextColor(colorFgPrimary)
paint.isFakeBoldText = true paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
} }
@@ -126,12 +162,12 @@ class FloatingBillView(
val segmentContainer = LinearLayout(context).apply { val segmentContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
background = GradientDrawable().apply { background = GradientDrawable().apply {
cornerRadius = dp(4f).toFloat() cornerRadius = dp(6f).toFloat()
setColor(0x20FFFFFF.toInt()) // 12% white opacity setColor((colorFgPrimary and 0x00FFFFFF) or 0x12000000) // 7% fg overlay
} }
} }
val tabTexts = listOf("支出", "收入", "转账") val tabTexts = listOf(config.labels.dirExpense, config.labels.dirIncome, config.labels.dirTransfer)
val tabDirections = listOf("expense", "income", "transfer") val tabDirections = listOf("expense", "income", "transfer")
val tabViews = mutableListOf<TextView>() val tabViews = mutableListOf<TextView>()
@@ -139,11 +175,11 @@ class FloatingBillView(
for (i in tabViews.indices) { for (i in tabViews.indices) {
val active = tabDirections[i] == currentDirection val active = tabDirections[i] == currentDirection
tabViews[i].apply { tabViews[i].apply {
setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) setTextColor(if (active) colorAccentFg else colorFgSecondary)
background = if (active) { background = if (active) {
GradientDrawable().apply { GradientDrawable().apply {
cornerRadius = dp(4f).toFloat() cornerRadius = dp(6f).toFloat()
setColor(0xFF5E6AD2.toInt()) // Indigo highlight setColor(colorAccent)
} }
} else { } else {
null null
@@ -173,40 +209,75 @@ class FloatingBillView(
headerLayout.addView(segmentContainer) headerLayout.addView(segmentContainer)
container.addView(headerLayout) container.addView(headerLayout)
// 金额编辑 // 金额行:币种 chip(左)+ 金额输入框(右)
val amountLabel = TextView(context).apply { val amountLabel = TextView(context).apply {
text = "金额" text = config.labels.amountLabel
textSize = 9f textSize = 10f
paint.isFakeBoldText = true paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt()) setTextColor(colorFgSecondary)
setPadding(0, dp(4f), 0, dp(1f)) setPadding(0, dp(8f), 0, dp(1f))
} }
container.addView(amountLabel) container.addView(amountLabel)
val amountInput = EditText(context).apply { val amountRow = LinearLayout(context).apply {
textSize = 13f orientation = LinearLayout.HORIZONTAL
setTextColor(0xFFFFFFFF.toInt()) gravity = Gravity.CENTER_VERTICAL
background = GradientDrawable().apply {
setColor(0x4012131A.toInt()) // 25% 半透底色
cornerRadius = dp(6f).toFloat()
setStroke(dp(1f), 0x80222433.toInt())
}
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT LinearLayout.LayoutParams.WRAP_CONTENT
) )
} }
container.addView(amountInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) }) // 币种 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 { val merchantLabel = TextView(context).apply {
text = "交易对手" text = config.labels.payeeLabel
textSize = 9f textSize = 10f
paint.isFakeBoldText = true paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt()) setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(1f)) setPadding(0, 0, 0, dp(1f))
} }
container.addView(merchantLabel) container.addView(merchantLabel)
@@ -214,56 +285,57 @@ class FloatingBillView(
val merchantInput = EditText(context).apply { val merchantInput = EditText(context).apply {
setText(merchant) setText(merchant)
textSize = 12f textSize = 12f
setTextColor(0xFFFFFFFF.toInt()) setTextColor(colorFgPrimary)
setHintTextColor(colorFgSecondary)
background = GradientDrawable().apply { background = GradientDrawable().apply {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
cornerRadius = dp(6f).toFloat() cornerRadius = dp(10f).toFloat()
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
setPadding(dp(10f), dp(4f), dp(10f), dp(4f)) setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT LinearLayout.LayoutParams.WRAP_CONTENT
) )
} }
container.addView(merchantInput) container.addView(merchantInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) }) container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
// 叙述备注编辑 // 叙述备注编辑
val narrationLabel = TextView(context).apply { val narrationLabel = TextView(context).apply {
text = "描述/备注" text = config.labels.narrationLabel
textSize = 9f textSize = 10f
paint.isFakeBoldText = true paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt()) setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(1f)) setPadding(0, 0, 0, dp(1f))
} }
container.addView(narrationLabel) container.addView(narrationLabel)
val narrationInput = EditText(context).apply { val narrationInput = EditText(context).apply {
hint = "输入交易叙述" hint = config.labels.narrationHint
setHintTextColor(0xFF6B7280.toInt()) setHintTextColor(colorFgSecondary)
textSize = 12f textSize = 12f
setTextColor(0xFFFFFFFF.toInt()) setTextColor(colorFgPrimary)
background = GradientDrawable().apply { background = GradientDrawable().apply {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
cornerRadius = dp(6f).toFloat() cornerRadius = dp(10f).toFloat()
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
setPadding(dp(10f), dp(4f), dp(10f), dp(4f)) setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT LinearLayout.LayoutParams.WRAP_CONTENT
) )
} }
container.addView(narrationInput) container.addView(narrationInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) }) container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
// Row 1 分类/转入选择 // Row 1 分类/转入选择
categoryLabel = TextView(context).apply { categoryLabel = TextView(context).apply {
text = "交易分类" text = config.labels.categoryExpense
textSize = 9f textSize = 10f
paint.isFakeBoldText = true paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt()) setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(2f)) setPadding(0, 0, 0, dp(2f))
} }
container.addView(categoryLabel) container.addView(categoryLabel)
@@ -281,14 +353,14 @@ class FloatingBillView(
) )
} }
container.addView(categoryScroll) container.addView(categoryScroll)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) }) container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
// Row 2 资金出入账户选择 // Row 2 资金出入账户选择
accountLabel = TextView(context).apply { accountLabel = TextView(context).apply {
text = "资金来源账户" text = config.labels.accountExpense
textSize = 9f textSize = 10f
paint.isFakeBoldText = true paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt()) setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(2f)) setPadding(0, 0, 0, dp(2f))
} }
container.addView(accountLabel) container.addView(accountLabel)
@@ -306,7 +378,7 @@ class FloatingBillView(
) )
} }
container.addView(accountScroll) container.addView(accountScroll)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(8f)) }) container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(10f)) })
// 底部操作栏 // 底部操作栏
val btnContainer = LinearLayout(context).apply { val btnContainer = LinearLayout(context).apply {
@@ -320,16 +392,16 @@ class FloatingBillView(
// 1) 打开应用按钮 // 1) 打开应用按钮
val openAppBtn = Button(context).apply { val openAppBtn = Button(context).apply {
text = "打开应用" text = config.labels.openApp
setTextColor(0xFFD1D5DB.toInt()) setTextColor(colorFgPrimary)
background = GradientDrawable().apply { background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat() cornerRadius = dp(12f).toFloat()
setColor(0xFF1F2937.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0xFF374151.toInt()) setStroke(dp(1f), colorBorder)
} }
textSize = 11f textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) } layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener { setOnClickListener {
val newAmount = amountInput.text.toString().trim() val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim() val newPayee = merchantInput.text.toString().trim()
@@ -337,23 +409,22 @@ class FloatingBillView(
sendOpenAppEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount) sendOpenAppEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
dismiss() dismiss()
BillingAccessibilityService.instance?.bringAppToForeground() SelectToSpeakService.instance?.bringAppToForeground()
} }
} }
// 2) 忽略按钮 // 2) 忽略按钮
val cancelBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat()
setColor(0xFF1F2937.toInt())
setStroke(dp(1f), 0xFF374151.toInt())
}
val cancelBtn = Button(context).apply { val cancelBtn = Button(context).apply {
text = "忽略" text = config.labels.dismiss
setTextColor(0xFFD1D5DB.toInt()) setTextColor(colorFgPrimary)
background = cancelBg background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
textSize = 11f textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) } layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener { setOnClickListener {
sendCancelEvent() sendCancelEvent()
dismiss() dismiss()
@@ -361,18 +432,17 @@ class FloatingBillView(
} }
// 3) 确认入账按钮 // 3) 确认入账按钮
val saveBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat()
setColor(0xFF5E6AD2.toInt())
}
saveBtn = Button(context).apply { saveBtn = Button(context).apply {
text = "确认入账" text = config.labels.confirm
setTextColor(0xFFFFFFFF.toInt()) setTextColor(colorAccentFg)
background = saveBg background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorAccent)
}
textSize = 11f textSize = 11f
paint.isFakeBoldText = true paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1.3f) layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1.3f)
setOnClickListener { setOnClickListener {
val newAmount = amountInput.text.toString().trim() val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim() val newPayee = merchantInput.text.toString().trim()
@@ -388,21 +458,23 @@ class FloatingBillView(
btnContainer.addView(saveBtn) btnContainer.addView(saveBtn)
container.addView(btnContainer) container.addView(btnContainer)
// 构建金额安全校验 // 金额安全校验BigDecimal + 正则)
val amountWatcher = object : android.text.TextWatcher { val amountWatcher = object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: android.text.Editable?) { override fun afterTextChanged(s: android.text.Editable?) {
try { try {
val text = s?.toString()?.trim() ?: "" val text = s?.toString()?.trim() ?: ""
val value = text.toDoubleOrNull() val pattern = Regex("^\\d+(\\.\\d{1,2})?$")
val isValid = value != null && !value.isNaN() && !value.isInfinite() && value > 0.0 val value = text.toBigDecimalOrNull()
val isValid = pattern.matches(text) && value != null && value > BigDecimal.ZERO
saveBtn?.isEnabled = isValid saveBtn?.isEnabled = isValid
saveBtn?.background = GradientDrawable().apply { saveBtn?.background = GradientDrawable().apply {
cornerRadius = dp(8f).toFloat() shape = GradientDrawable.RECTANGLE
setColor(if (isValid) 0xFF5E6AD2.toInt() else 0xFF374151.toInt()) cornerRadius = dp(12f).toFloat()
setColor(if (isValid) colorAccent else colorInputBg)
} }
saveBtn?.setTextColor(if (isValid) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) saveBtn?.setTextColor(if (isValid) colorAccentFg else colorFgSecondary)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "金额校验异常", e) Log.e(TAG, "金额校验异常", e)
saveBtn?.isEnabled = false saveBtn?.isEnabled = false
@@ -419,7 +491,7 @@ class FloatingBillView(
view = container view = container
windowManager.addView(view, windowParams) windowManager.addView(view, windowParams)
Log.i(TAG, "悬浮修改记账面板已显示: ¥$amount") Log.i(TAG, "悬浮修改记账面板已显示: $currentCurrency $amount")
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器 // 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus -> val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
@@ -438,11 +510,11 @@ class FloatingBillView(
false false
} }
// 15 秒无操作自动消失(如果用户没有交互的话) // 30 秒无操作自动消失(如果用户没有交互的话)
handler.postDelayed({ handler.postDelayed({
sendCancelEvent() sendCancelEvent()
dismiss() dismiss()
}, 15000L) }, 30000L)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e) Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
@@ -459,7 +531,7 @@ class FloatingBillView(
// === 1. 绘制第一行 (分类 / 转入) === // === 1. 绘制第一行 (分类 / 转入) ===
if (currentDirection == "expense") { if (currentDirection == "expense") {
categoryLabel?.text = "交易分类" categoryLabel?.text = config.labels.categoryExpense
val filteredCats = categories.filter { it["type"] == "expense" } val filteredCats = categories.filter { it["type"] == "expense" }
for (cat in filteredCats) { for (cat in filteredCats) {
val catAccount = cat["account"] ?: "" val catAccount = cat["account"] ?: ""
@@ -470,14 +542,14 @@ class FloatingBillView(
for (pair in categoryChips) { for (pair in categoryChips) {
val active = pair.first == selected val active = pair.first == selected
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt()) if (active) setColor(colorAccent)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} }
@@ -492,18 +564,18 @@ class FloatingBillView(
categoryChips.forEach { pair -> categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt()) if (active) setColor(colorAccent)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} else if (currentDirection == "income") { } else if (currentDirection == "income") {
categoryLabel?.text = "收入分类" categoryLabel?.text = config.labels.categoryIncome
val filteredCats = categories.filter { it["type"] == "income" } val filteredCats = categories.filter { it["type"] == "income" }
for (cat in filteredCats) { for (cat in filteredCats) {
val catAccount = cat["account"] ?: "" val catAccount = cat["account"] ?: ""
@@ -514,14 +586,14 @@ class FloatingBillView(
for (pair in categoryChips) { for (pair in categoryChips) {
val active = pair.first == selected val active = pair.first == selected
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt()) if (active) setColor(colorAccent)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} }
@@ -536,19 +608,19 @@ class FloatingBillView(
categoryChips.forEach { pair -> categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt()) if (active) setColor(colorAccent)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} else { } else {
// transfer // transfer — 第一行 = 转入账户,选中色 = transfer
categoryLabel?.text = "转入账户" categoryLabel?.text = config.labels.transferTarget
for (acct in accounts) { for (acct in accounts) {
val shortName = acct.split(":").lastOrNull() ?: acct val shortName = acct.split(":").lastOrNull() ?: acct
val chip = createChipView(shortName) val chip = createChipView(shortName)
@@ -557,14 +629,14 @@ class FloatingBillView(
for (pair in categoryChips) { for (pair in categoryChips) {
val active = pair.first == selected val active = pair.first == selected
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) setColor(0xFF10B981.toInt()) if (active) setColor(colorTransfer)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} }
@@ -579,26 +651,29 @@ class FloatingBillView(
categoryChips.forEach { pair -> categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) setColor(0xFF10B981.toInt()) if (active) setColor(colorTransfer)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} }
// === 2. 绘制第二行 (资金账户) === // === 2. 绘制第二行 (资金账户) ===
if (currentDirection == "expense") { if (currentDirection == "expense") {
accountLabel?.text = "资金来源" accountLabel?.text = config.labels.accountExpense
} else if (currentDirection == "income") { } else if (currentDirection == "income") {
accountLabel?.text = "存入账户" accountLabel?.text = config.labels.accountIncome
} else { } else {
accountLabel?.text = "转出账户" accountLabel?.text = config.labels.accountTransfer
} }
// 账户行选中色按方向:expense→expense色,income→income色,transfer→expense色
val accountSelectedColor = if (currentDirection == "income") colorIncome else colorExpense
for (acct in accounts) { for (acct in accounts) {
val shortName = acct.split(":").lastOrNull() ?: acct val shortName = acct.split(":").lastOrNull() ?: acct
val chip = createChipView(shortName) val chip = createChipView(shortName)
@@ -607,15 +682,15 @@ class FloatingBillView(
for (pair in accountChips) { for (pair in accountChips) {
val active = pair.first == selected val active = pair.first == selected
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) { if (active) {
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt()) setColor(accountSelectedColor)
} else { } else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} }
@@ -631,14 +706,14 @@ class FloatingBillView(
for (p in categoryChips) { for (p in categoryChips) {
val act = p.first == selectedCategoryAccount val act = p.first == selectedCategoryAccount
p.second.background = GradientDrawable().apply { p.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (act) setColor(0xFF10B981.toInt()) if (act) setColor(colorTransfer)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
p.second.setTextColor(if (act) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) p.second.setTextColor(if (act) colorAccentFg else colorFgSecondary)
} }
} }
} }
@@ -651,15 +726,15 @@ class FloatingBillView(
accountChips.forEach { pair -> accountChips.forEach { pair ->
val active = pair.first == selectedSourceAccount val active = pair.first == selectedSourceAccount
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) { if (active) {
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt()) setColor(accountSelectedColor)
} else { } else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} }
@@ -677,6 +752,7 @@ class FloatingBillView(
putString("time", time) putString("time", time)
putString("direction", currentDirection) putString("direction", currentDirection)
putString("packageName", packageName) putString("packageName", packageName)
putString("currency", currentCurrency)
putBoolean("confirmed", true) putBoolean("confirmed", true)
putBoolean("editRequested", false) putBoolean("editRequested", false)
putBoolean("isManualEdit", true) putBoolean("isManualEdit", true)
@@ -703,6 +779,7 @@ class FloatingBillView(
putString("time", time) putString("time", time)
putString("direction", currentDirection) putString("direction", currentDirection)
putString("packageName", packageName) putString("packageName", packageName)
putString("currency", currentCurrency)
} }
reactContext reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
@@ -736,4 +813,13 @@ class FloatingBillView(
} catch (_: Exception) {} } catch (_: Exception) {}
view = null view = null
} }
/** Kotlin 中缺失的 toBigDecimalOrNull 扩展。 */
private fun String.toBigDecimalOrNull(): BigDecimal? {
return try {
BigDecimal(this)
} catch (_: Exception) {
null
}
}
} }
+78 -28
View File
@@ -24,10 +24,13 @@ import android.widget.TextView
* 贴合在屏幕边缘采用高透超轻量竖线指示器点击后展开垂直对齐的功能菜单 * 贴合在屏幕边缘采用高透超轻量竖线指示器点击后展开垂直对齐的功能菜单
*/ */
class FloatingHelper( class FloatingHelper(
private val service: BillingAccessibilityService private val service: SelectToSpeakService
) { ) {
companion object { companion object {
private const val TAG = "FloatingHelper" 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 lastX = 0
private var lastY = 400 private var lastY = 400
} }
@@ -41,7 +44,7 @@ class FloatingHelper(
private val params = WindowManager.LayoutParams( private val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
PixelFormat.TRANSLUCENT PixelFormat.TRANSLUCENT
).apply { ).apply {
@@ -57,6 +60,25 @@ class FloatingHelper(
try { try {
val context = service 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 val density = service.resources.displayMetrics.density
fun dp(value: Float) = (value * density).toInt() fun dp(value: Float) = (value * density).toInt()
@@ -79,25 +101,31 @@ class FloatingHelper(
layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f)) 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 { val indicatorView = View(context).apply {
background = GradientDrawable().apply { background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE shape = GradientDrawable.RECTANGLE
cornerRadius = dp(1.5f).toFloat() // 高度圆润 cornerRadius = dp(1.5f).toFloat()
setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框 setColor(indicatorColor)
} }
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply { layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
gravity = Gravity.CENTER gravity = Gravity.CENTER_VERTICAL or Gravity.START
} }
} }
bubbleLayout.addView(indicatorView) bubbleLayout.addView(indicatorView)
bubbleView = bubbleLayout bubbleView = bubbleLayout
// 3. 创建展开菜单 (垂直布局,高紧凑度设计) // 3. 创建展开菜单 (垂直布局,高紧凑度设计)
// 菜单背景:实色 cardBgper plan §T4
val menuBgColor = colorCardBg
// 菜单描边:border 色 + 半透明 alpha
val menuBorderColor = (colorBorder and 0x00FFFFFF) or 0x22000000.toInt()
val menuBg = GradientDrawable().apply { val menuBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE shape = GradientDrawable.RECTANGLE
cornerRadius = dp(10f).toFloat() cornerRadius = dp(10f).toFloat()
setColor(0xA611131E.toInt()) // 65% OLED high-transparency black setColor(menuBgColor)
setStroke(dp(0.8f), 0x22FFFFFF.toInt()) // subtle 13% white border setStroke(dp(0.8f), menuBorderColor)
} }
menuView = LinearLayout(context).apply { menuView = LinearLayout(context).apply {
@@ -107,30 +135,37 @@ class FloatingHelper(
setPadding(dp(4f), dp(4f), dp(4f), dp(4f)) setPadding(dp(4f), dp(4f), dp(4f), dp(4f))
visibility = View.GONE visibility = View.GONE
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
dp(96f), // ultra-compact width: 96dp dp(96f),
LinearLayout.LayoutParams.WRAP_CONTENT LinearLayout.LayoutParams.WRAP_CONTENT
).apply { ).apply {
leftMargin = dp(4f) // spacing with indicator line leftMargin = dp(4f)
} }
} }
// Vector icons // Vector icons
val sizePx = dp(14f) val sizePx = dp(14f)
val strokePx = dp(1.4f).toFloat() val strokePx = dp(1.4f).toFloat()
val ocrIcon = ScanIconDrawable(0xFF818CF8.toInt(), strokePx, 0xFFF87171.toInt(), sizePx) // ScanIconDrawableaccentFg 色(在 accent 底按钮上可见),laserColor 保留红色语义
val pinIcon = PinIconDrawable(0xFF34D399.toInt(), strokePx, sizePx) 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()
// Button 1: "识别账单"
val btnOcr = TextView(context).apply { val btnOcr = TextView(context).apply {
text = "识别账单" text = config.labels.ballOcr
setTextColor(0xFFFFFFFF.toInt()) setTextColor(colorAccentFg)
textSize = 11f textSize = 11f
paint.isFakeBoldText = true paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0) setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply { background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat() cornerRadius = dp(7f).toFloat()
setColor(0x1F5E6AD2.toInt()) // translucent indigo background setColor(btnOcrBgNormal)
} }
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
@@ -145,13 +180,13 @@ class FloatingHelper(
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply { view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat() cornerRadius = dp(7f).toFloat()
setColor(0x405E6AD2.toInt()) setColor(btnOcrBgPressed)
} }
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply { view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat() cornerRadius = dp(7f).toFloat()
setColor(0x1F5E6AD2.toInt()) setColor(btnOcrBgNormal)
} }
if (event.action == MotionEvent.ACTION_UP) { if (event.action == MotionEvent.ACTION_UP) {
view.performClick() view.performClick()
@@ -165,17 +200,22 @@ class FloatingHelper(
} }
} }
// Button 2: "记住此页" // 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 { val btnRemember = TextView(context).apply {
text = "记住此页" text = config.labels.ballRemember
setTextColor(0xFFE5E7EB.toInt()) setTextColor(colorFgPrimary)
textSize = 11f textSize = 11f
paint.isFakeBoldText = true paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0) setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply { background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat() cornerRadius = dp(7f).toFloat()
setColor(0x15FFFFFF.toInt()) // subtle translucent gray setColor(btnRememberBgNormal)
} }
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
@@ -188,13 +228,13 @@ class FloatingHelper(
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply { view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat() cornerRadius = dp(7f).toFloat()
setColor(0x30FFFFFF.toInt()) setColor(btnRememberBgPressed)
} }
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply { view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat() cornerRadius = dp(7f).toFloat()
setColor(0x15FFFFFF.toInt()) setColor(btnRememberBgNormal)
} }
if (event.action == MotionEvent.ACTION_UP) { if (event.action == MotionEvent.ACTION_UP) {
view.performClick() view.performClick()
@@ -206,9 +246,9 @@ class FloatingHelper(
setOnClickListener { setOnClickListener {
try { try {
service.rememberCurrentPage() service.rememberCurrentPage()
FloatingTip(service, "📌 已将当前页面加入识别白名单!", FloatingTip.TipPosition.TOP, 2500L).show() FloatingTip(service, config.labels.rememberSuccess, FloatingTip.TipPosition.TOP, 2500L).show()
} catch (e: Exception) { } catch (e: Exception) {
FloatingTip(service, "记录失败: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show() FloatingTip(service, "${config.labels.rememberFail}: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show()
} }
collapse() collapse()
} }
@@ -255,16 +295,22 @@ class FloatingHelper(
if (!isMoving) { if (!isMoving) {
toggleMenu() toggleMenu()
} else { } else {
// 拖动抬起时自动吸附到屏幕边缘 // 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘)
if (params.x < service.resources.displayMetrics.widthPixels / 2) { if (params.x < service.resources.displayMetrics.widthPixels / 2) {
params.x = 0 params.x = -dp(1.5f)
} else { } else {
params.x = service.resources.displayMetrics.widthPixels - dp(24f) params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f)
} }
containerView?.let { windowManager.updateViewLayout(it, params) } containerView?.let { windowManager.updateViewLayout(it, params) }
lastX = params.x lastX = params.x
lastY = params.y lastY = params.y
// 位置持久化:吸附后写入 SharedPreferences
prefs.edit()
.putInt(KEY_X, params.x)
.putInt(KEY_Y, params.y)
.apply()
} }
true true
} }
@@ -276,6 +322,10 @@ class FloatingHelper(
Log.i(TAG, "记账助手悬浮窗显示成功") Log.i(TAG, "记账助手悬浮窗显示成功")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e) Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e)
// 修复:失败时清理状态,允许下次重试
containerView = null
bubbleView = null
menuView = null
} }
} }
+7 -29
View File
@@ -6,14 +6,10 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/** /**
* 浮窗提示plan.md3.8 浮窗账单提示 * 浮窗提示plan.md3.8 浮窗账单提示
@@ -47,6 +43,10 @@ class FloatingTip(
/** 显示浮窗提示。 */ /** 显示浮窗提示。 */
fun show() { fun show() {
try { 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( val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
@@ -65,42 +65,20 @@ class FloatingTip(
val container = LinearLayout(context).apply { val container = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
setBackgroundColor(0xF0333333.toInt()) setBackgroundColor(bgColor)
setPadding(32, 16, 32, 16) setPadding(32, 16, 32, 16)
} }
val text = TextView(context).apply { val text = TextView(context).apply {
text = message text = message
textSize = 13f textSize = 13f
setTextColor(0xFFFFFFFF.toInt()) setTextColor(textColor)
}
// 倒计时进度条
val progress = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal)
progress.max = 100
progress.progress = 100
progress.layoutParams = LinearLayout.LayoutParams(200, 8).apply {
setMargins(16, 0, 0, 0)
} }
container.addView(text) container.addView(text)
container.addView(progress)
view = container view = container
windowManager.addView(view, layoutParams) windowManager.addView(view, layoutParams)
Log.d(TAG, "FloatingTip 显示: $message") Log.d(TAG, "FloatingTip 显示: $message")
// 倒计时动画(300ms 内 progress 从 100 到 0
val steps = 30
val stepMs = durationMs / steps
var currentStep = 0
handler.post(object : Runnable {
override fun run() {
currentStep++
progress.progress = 100 - (currentStep * 100 / steps)
if (currentStep < steps) {
handler.postDelayed(this, stepMs)
}
}
})
handler.postDelayed({ dismiss() }, durationMs) handler.postDelayed({ dismiss() }, durationMs)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "FloatingTip 显示失败: ${e.message}") Log.e(TAG, "FloatingTip 显示失败: ${e.message}")
@@ -121,7 +99,7 @@ class FloatingTip(
*/ */
class RepeatToast(private val context: Context, private val message: String) { class RepeatToast(private val context: Context, private val message: String) {
fun show() { fun show() {
val tip = FloatingTip(context, "$message", FloatingTip.TipPosition.TOP, 2000L) val tip = FloatingTip(context, message, FloatingTip.TipPosition.TOP, 2000L)
tip.show() tip.show()
Log.d("RepeatToast", "重复提示: $message") 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
}
}
@@ -14,7 +14,7 @@ import android.app.PendingIntent
* - 用户下拉快速设置点击OCR 记账磁贴触发一次手动 OCR * - 用户下拉快速设置点击OCR 记账磁贴触发一次手动 OCR
* - Android 14+ PendingIntent + startActivityAndCollapse * - Android 14+ PendingIntent + startActivityAndCollapse
* *
* 触发后调用 BillingAccessibilityService.triggerManualOcr() * 触发后调用 SelectToSpeakService.triggerManualOcr()
*/ */
class OcrTileService : TileService() { class OcrTileService : TileService() {
@@ -38,9 +38,9 @@ class OcrTileService : TileService() {
triggerManualOcr() triggerManualOcr()
} }
/** 触发手动 OCR(通过 BillingAccessibilityService)。 */ /** 触发手动 OCR(通过 SelectToSpeakService)。 */
private fun triggerManualOcr() { private fun triggerManualOcr() {
val service = BillingAccessibilityService.instance val service = SelectToSpeakService.instance
if (service != null) { if (service != null) {
service.triggerManualOcr() service.triggerManualOcr()
return return
@@ -50,8 +50,8 @@ class OcrTileService : TileService() {
val pi = PendingIntent.getActivity( val pi = PendingIntent.getActivity(
this, 0, this, 0,
Intent().apply { Intent().apply {
setClassName(packageName, "com.beancount.mobile.MainActivity") setClassName(packageName, "$packageName.MainActivity")
action = "com.beancount.mobile.TRIGGER_OCR" action = "$packageName.TRIGGER_OCR"
flags = Intent.FLAG_ACTIVITY_NEW_TASK flags = Intent.FLAG_ACTIVITY_NEW_TASK
}, },
PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_IMMUTABLE,
@@ -29,16 +29,25 @@ import android.view.accessibility.AccessibilityNodeInfo
* - 横屏免打扰游戏/视频时不触发 * - 横屏免打扰游戏/视频时不触发
* - ocrDoing 守卫防止重复触发 * - ocrDoing 守卫防止重复触发
* *
* 伪装说明plan.md 决策 3纯开源侧载保留无障碍伪装非应用商店分发 * 伪装说明绕过微信 8.0.52+ 节点混淆
* 注意本服务类名在 manifest 中声明为 BillingAccessibilityService * 微信按 ComponentName包名/类名识别系统服务白名单TalkBackSelectToSpeak
* 伪装为系统服务包名 com.beancount.mobile.accessibility仅在侧载版本保留 * 系统服务不受节点混淆影响第三方服务则被混淆
* 本服务的 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 * 通过 DeviceEventEmitter 把识别事件推送到 JS automationStore
* *
* src/domain/constants.ts 同步PAYMENT_PACKAGES * src/domain/constants.ts 同步PAYMENT_PACKAGES
* 修改时需两边同时更新 * 修改时需两边同时更新
*/ */
class BillingAccessibilityService : AccessibilityService() { class SelectToSpeakService : AccessibilityService() {
companion object { companion object {
private const val TAG = "BillingAccessibility" private const val TAG = "BillingAccessibility"
@@ -46,10 +55,17 @@ class BillingAccessibilityService : AccessibilityService() {
private const val PREF_PAGE_SIGNATURES = "page_signatures" private const val PREF_PAGE_SIGNATURES = "page_signatures"
@Volatile @Volatile
var instance: BillingAccessibilityService? = null var instance: SelectToSpeakService? = null
private set private set
/** 支付 App 白名单。 */ /** 悬浮球全局开关(由 JS 层通过 AccessibilityBridge 控制)。 */
@Volatile
var floatingBallEnabled = true
/**
* 支付 App 包名自动生成来源src/domain/constants.ts PAYMENT_PACKAGES
* 修改时只需编辑 constants.ts运行 expo prebuild 即可同步
*/
val PAYMENT_PACKAGES = setOf( val PAYMENT_PACKAGES = setOf(
"com.eg.android.AlipayGphone", // 支付宝 "com.eg.android.AlipayGphone", // 支付宝
"com.tencent.mm", // 微信 "com.tencent.mm", // 微信
@@ -79,6 +95,7 @@ class BillingAccessibilityService : AccessibilityService() {
} }
private var ocrDoing = false private var ocrDoing = false
private var ocrDoingTimestamp = 0L
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val debounceRunnable = Runnable { processContentChange() } private val debounceRunnable = Runnable { processContentChange() }
@Volatile private var topPackage: String? = null @Volatile private var topPackage: String? = null
@@ -93,6 +110,14 @@ class BillingAccessibilityService : AccessibilityService() {
Log.i(TAG, "无障碍账单识别服务已连接") Log.i(TAG, "无障碍账单识别服务已连接")
loadPageSignatures() loadPageSignatures()
configureService() configureService()
// 修复:检查当前前台 App,恢复悬浮球
val root = rootInActiveWindow
val pkg = root?.packageName?.toString()
root?.recycle()
if (pkg != null) {
topPackage = pkg
updateFloatingHelperVisibility(pkg)
}
} }
/** 动态配置服务能力(截图 + 页面变化监听)。 */ /** 动态配置服务能力(截图 + 页面变化监听)。 */
@@ -103,6 +128,7 @@ class BillingAccessibilityService : AccessibilityService() {
feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC
flags = AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY or flags = AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY or
AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or
AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS or
AccessibilityServiceInfo.DEFAULT AccessibilityServiceInfo.DEFAULT
notificationTimeout = 100L notificationTimeout = 100L
} }
@@ -110,7 +136,15 @@ class BillingAccessibilityService : AccessibilityService() {
} }
override fun onAccessibilityEvent(event: AccessibilityEvent?) { override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (ocrDoing) return // 处理中,跳过 // ocrDoing 超时兜底:5秒后强制重置
if (ocrDoing) {
if (System.currentTimeMillis() - ocrDoingTimestamp > 5000) {
Log.w(TAG, "ocrDoing 超时重置")
ocrDoing = false
} else {
return
}
}
val eventPackage = event?.packageName?.toString() ?: return val eventPackage = event?.packageName?.toString() ?: return
when (event.eventType) { when (event.eventType) {
@@ -124,51 +158,27 @@ class BillingAccessibilityService : AccessibilityService() {
// 更新悬浮窗助手状态 // 更新悬浮窗助手状态
updateFloatingHelperVisibility(eventPackage) updateFloatingHelperVisibility(eventPackage)
if (PAYMENT_PACKAGES.contains(eventPackage)) { // 页面切换时检查是否有已记住的签名需要触发文本提取
scheduleContentChange() scheduleContentChange()
}
// 调试:如果是微信或支付宝,延迟 800ms 抓取并打印全屏无障碍文本内容 // 注意:自动监听调试日志已移除(微信 8.0.52+ 混淆节点文本,自动监听无意义)。
if (eventPackage == "com.tencent.mm" || eventPackage == "com.eg.android.AlipayGphone") { // 账单识别仅在用户点击悬浮球时触发(isManual=true),此时提取完整节点文本。
handler.postDelayed({
val rootNode = rootInActiveWindow
val texts = mutableListOf<String>()
dumpNodeTexts(rootNode, texts)
rootNode?.recycle()
val reactContext = ReactContextHolder.context
if (reactContext != null) {
try {
val map = WritableNativeMap().apply {
putString("package", eventPackage)
putString("activity", activityName)
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) {
// 忽略
}
}
}, 800)
}
} }
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> { AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
if (PAYMENT_PACKAGES.contains(eventPackage)) { // 内容变化时也检查(防抖合并)
scheduleContentChange() scheduleContentChange()
} }
} }
} }
}
private fun updateFloatingHelperVisibility(pkg: String?) { private fun updateFloatingHelperVisibility(pkg: String?) {
handler.post { handler.post {
if (pkg != null && PAYMENT_PACKAGES.contains(pkg)) { val shouldShow = floatingBallEnabled
&& pkg != null
&& !filterPackage(pkg, topActivity ?: "")
&& !isLandscape()
if (shouldShow) {
if (floatingHelper == null) { if (floatingHelper == null) {
floatingHelper = FloatingHelper(this) floatingHelper = FloatingHelper(this)
floatingHelper?.show() floatingHelper?.show()
@@ -204,11 +214,8 @@ class BillingAccessibilityService : AccessibilityService() {
return // 未记住的页面不自动触发 return // 未记住的页面不自动触发
} }
// 抓取并提取屏幕所有无障碍文本,并推送至 JS 侧进行解析/控制 // 抓取并提取屏幕所有无障碍文本(含 WebView 子窗口),并推送至 JS 侧
val rootNode = rootInActiveWindow val texts = dumpAllTexts()
val texts = mutableListOf<String>()
dumpNodeTexts(rootNode, texts)
rootNode?.recycle()
val reactContext = ReactContextHolder.context val reactContext = ReactContextHolder.context
if (reactContext != null) { if (reactContext != null) {
@@ -236,6 +243,7 @@ class BillingAccessibilityService : AccessibilityService() {
private fun takeScreenshotAndProcess(packageName: String) { private fun takeScreenshotAndProcess(packageName: String) {
if (ocrDoing) return if (ocrDoing) return
ocrDoing = true ocrDoing = true
ocrDoingTimestamp = System.currentTimeMillis()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
ocrDoing = false ocrDoing = false
return return
@@ -250,7 +258,7 @@ class BillingAccessibilityService : AccessibilityService() {
val bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace) val bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
result.hardwareBuffer.close() result.hardwareBuffer.close()
if (bitmap != null) { if (bitmap != null) {
val base64 = bitmapToBase64(bitmap) val base64 = bitmapToBase64(bitmap, packageName)
bitmap.recycle() bitmap.recycle()
// 推送到 JS 层(NativeEventEmitter // 推送到 JS 层(NativeEventEmitter
sendScreenshotEvent(base64, packageName) sendScreenshotEvent(base64, packageName)
@@ -310,7 +318,12 @@ class BillingAccessibilityService : AccessibilityService() {
}, 150) }, 150)
} }
/** 手动触发一次节点文本提取并发送到 JS,从而让 JS 优先尝试直接文本解析。 */ /**
* 手动触发一次节点文本提取并发送到 JS从而让 JS 优先尝试直接文本解析
*
* 使用 dumpAllTexts() 覆盖所有窗口 WebView 子窗口而非仅 rootInActiveWindow
* 伪装生效后这条路径会成为微信账单页的主识别路径必须保证完整性
*/
fun triggerManualExtraction() { fun triggerManualExtraction() {
handler.post { handler.post {
floatingHelper?.collapse() floatingHelper?.collapse()
@@ -319,10 +332,7 @@ class BillingAccessibilityService : AccessibilityService() {
val activity = topActivity ?: "" val activity = topActivity ?: ""
val sigKey = "$pkg|$activity" val sigKey = "$pkg|$activity"
val rootNode = rootInActiveWindow val texts = dumpAllTexts()
val texts = mutableListOf<String>()
dumpNodeTexts(rootNode, texts)
rootNode?.recycle()
val reactContext = ReactContextHolder.context val reactContext = ReactContextHolder.context
if (reactContext != null) { if (reactContext != null) {
@@ -352,11 +362,12 @@ class BillingAccessibilityService : AccessibilityService() {
val pkg = topPackage ?: return val pkg = topPackage ?: return
val activity = topActivity ?: "" val activity = topActivity ?: ""
val sig = "$pkg|$activity" val sig = "$pkg|$activity"
val uiLabels = FloatingUiConfigStore.load(this).labels
if (pageSignatures.add(sig)) { if (pageSignatures.add(sig)) {
savePageSignatures() savePageSignatures()
Log.i(TAG, "已记住页面: $sig") Log.i(TAG, "已记住页面: $sig")
handler.post { handler.post {
android.widget.Toast.makeText(this, "已记住页面签名:\n$sig", android.widget.Toast.LENGTH_LONG).show() android.widget.Toast.makeText(this, "${uiLabels.pageRemembered}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
} }
// 抓取并提取屏幕所有无障碍文本 // 抓取并提取屏幕所有无障碍文本
@@ -388,7 +399,7 @@ class BillingAccessibilityService : AccessibilityService() {
} }
} else { } else {
handler.post { handler.post {
android.widget.Toast.makeText(this, "该页面签名已存在:\n$sig", android.widget.Toast.LENGTH_LONG).show() android.widget.Toast.makeText(this, "${uiLabels.pageSignatureExists}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
} }
} }
} }
@@ -451,6 +462,21 @@ class BillingAccessibilityService : AccessibilityService() {
} }
} }
/** 关闭悬浮球(由 JS 层 setFloatingBallEnabled(false) 调用)。 */
fun dismissFloatingHelper() {
handler.post {
floatingHelper?.dismiss()
floatingHelper = null
}
}
/** 刷新悬浮球状态(由 JS 层 setFloatingBallEnabled(true) 调用)。 */
fun refreshFloatingHelper() {
handler.post {
updateFloatingHelperVisibility(topPackage)
}
}
/** 横屏检测(plan.md「3.10 横屏免打扰」)。 */ /** 横屏检测(plan.md「3.10 横屏免打扰」)。 */
private fun isLandscape(): Boolean { private fun isLandscape(): Boolean {
val dm = getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY) val dm = getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
@@ -459,28 +485,31 @@ class BillingAccessibilityService : AccessibilityService() {
return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270 return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270
} }
/** 过滤系统组件/桌面(不触发 OCR)。 */ /** 过滤不应处理的系统组件桌面启动器、SystemUI、输入法等)。 */
private fun filterPackage(pkg: String, className: String): Boolean { private fun filterPackage(pkg: String, className: String): Boolean {
val p = pkg.lowercase() val p = pkg.lowercase()
// 针对自身应用的特殊过滤:只允许 MainActivity 通过以触发隐藏悬浮窗; // 自身 App:只放行 MainActivity,避免悬浮球容器触发自毁式关闭
// 其他自身组件(如悬浮球容器 LinearLayout)一律过滤,避免自毁式关闭。
if (pkg == packageName) { if (pkg == packageName) {
return className != "com.example.beanmobile.MainActivity" if (className.isEmpty()) return false
return !className.endsWith(".MainActivity")
} }
if (p == "android" || p.startsWith("com.android.") || p.startsWith("com.google.android.")) return true // 桌面启动器(悬浮球在桌面无意义)
if (p.contains("systemui") || p.contains("settings") || p.contains("inputmethod") || p.contains("keyboard") || p.contains("input")) return true
if (LAUNCHER_PACKAGES.any { p.contains(it) }) return true 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 return false
} }
/** Bitmap → base64JPEG 质量 60,参考 AutoAccounting bitmapToBase64)。 */ /** Bitmap → base64JPEG 质量 60,参考 AutoAccounting bitmapToBase64)。 */
private fun bitmapToBase64(bitmap: Bitmap): String { private fun bitmapToBase64(bitmap: Bitmap, packageName: String = ""): String {
// 安全起见,如果 bitmap 是 HARDWARE 格式,将其复制为 ARGB_8888 软件格式 // 安全起见,如果 bitmap 是 HARDWARE 格式,将其复制为 ARGB_8888 软件格式
val softwareBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.config == Bitmap.Config.HARDWARE) { val softwareBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.config == Bitmap.Config.HARDWARE) {
bitmap.copy(Bitmap.Config.ARGB_8888, false) bitmap.copy(Bitmap.Config.ARGB_8888, false)
} else { } else {
bitmap bitmap
} }
// 诊断:分析截图有效性(区分「截到空白帧」与「有内容但 OCR 失败」)
analyzeScreenshot(softwareBitmap, packageName)
val baos = ByteArrayOutputStream() val baos = ByteArrayOutputStream()
softwareBitmap.compress(Bitmap.CompressFormat.JPEG, 60, baos) softwareBitmap.compress(Bitmap.CompressFormat.JPEG, 60, baos)
if (softwareBitmap !== bitmap) { if (softwareBitmap !== bitmap) {
@@ -489,6 +518,70 @@ class BillingAccessibilityService : AccessibilityService() {
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP) 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。 */ /** 辅助:构造 WritableNativeMap。 */
private fun writableMapOf(vararg pairs: Pair<String, Any?>): WritableNativeMap { private fun writableMapOf(vararg pairs: Pair<String, Any?>): WritableNativeMap {
val map = WritableNativeMap() val map = WritableNativeMap()
@@ -521,13 +614,18 @@ class BillingAccessibilityService : AccessibilityService() {
} }
} }
/** 递归提取无障碍节点树的全部文本。 */ /** 递归提取无障碍节点树的全部文本text + contentDescription。 */
private fun dumpNodeTexts(node: AccessibilityNodeInfo?, list: MutableList<String>) { private fun dumpNodeTexts(node: AccessibilityNodeInfo?, list: MutableList<String>) {
if (node == null) return if (node == null) return
val text = node.text?.toString() val text = node.text?.toString()
if (!text.isNullOrBlank()) { if (!text.isNullOrBlank()) {
list.add(text) 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 val childCount = node.childCount
for (i in 0 until childCount) { for (i in 0 until childCount) {
val child = node.getChild(i) ?: continue val child = node.getChild(i) ?: continue
@@ -536,6 +634,38 @@ class BillingAccessibilityService : AccessibilityService() {
} }
} }
/**
* 增强版文本提取先尝试 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 { private fun findTextInNode(node: AccessibilityNodeInfo?, keywords: List<String>): Boolean {
if (node == null) return false if (node == null) return false
+95 -15
View File
@@ -2,7 +2,13 @@
* 无障碍服务 Config Pluginplan.md3.6 无障碍服务+决策 4 * 无障碍服务 Config Pluginplan.md3.6 无障碍服务+决策 4
* *
* expo prebuild 时注册 Android 无障碍服务manifest service + xml 配置 * expo prebuild 时注册 Android 无障碍服务manifest service + xml 配置
* 参考 AutoAccounting SelectToSpeakService 伪装机制侧载保留plan.md 决策 3 *
* 伪装机制绕过微信 8.0.52+ 节点混淆
* 微信按 ComponentName包名/类名识别系统服务白名单早期只伪装类名不伪装包名
* 因此被微信识别为第三方服务对节点 text/contentDescription 做混淆
* 现在把 7 kt 文件整体移到 com.google.android.accessibility.selecttospeak
* 并把 Manifest 服务名写成完整全限定名让微信将其识别为系统 SelectToSpeak 服务
* 从而拿到未混淆的真实节点文本
* *
* 注意withAndroidManifest modResults 结构是 { manifest: { ... } } * 注意withAndroidManifest modResults 结构是 { manifest: { ... } }
* 操作 application 必须通过 modResults.manifest.application * 操作 application 必须通过 modResults.manifest.application
@@ -12,7 +18,43 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require('
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const PACKAGE = 'com.beancount.mobile.accessibility'; /**
* 伪装目标包名系统 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) { function copyDir(src, dest) {
@@ -27,18 +69,28 @@ function copyDir(src, dest) {
} }
function withAccessibilityService(config) { function withAccessibilityService(config) {
// 7 个 kt 文件全部跟随 SelectToSpeakService 伪装到 FAKE_PACKAGE 包下,
// 不再使用应用真实包名(appId)。
// 1. 复制 Kotlin 源码 + res/xml 资源到原生工程 // 1. 复制 Kotlin 源码 + res/xml 资源到原生工程
config = withDangerousMod(config, [ config = withDangerousMod(config, [
'android', 'android',
async (modConfig) => { async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot; const projectRoot = modConfig.modRequest.platformProjectRoot;
// Kotlin 源码(只复制 .kt 文件,不含 res/ 子目录) // Kotlin 源码统一复制到 FAKE_PACKAGE 目录下(伪装为系统 SelectToSpeak 服务包名)。
const ktDest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/accessibility'); // 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 }); fs.mkdirSync(ktDest, { recursive: true });
const androidDir = path.join(__dirname, 'android'); const androidDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(androidDir)) { for (const f of fs.readdirSync(androidDir)) {
if (f.endsWith('.kt')) { if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(androidDir, f), path.join(ktDest, f)); 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 资源 // res/xml 资源
@@ -46,6 +98,23 @@ function withAccessibilityService(config) {
if (fs.existsSync(resSrc)) { if (fs.existsSync(resSrc)) {
copyDir(resSrc, path.join(projectRoot, 'app/src/main/res')); 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 字符串资源 // 确保有 accessibility_service_description 字符串资源
const stringsXmlPath = path.join(projectRoot, 'app/src/main/res/values/strings.xml'); const stringsXmlPath = path.join(projectRoot, 'app/src/main/res/values/strings.xml');
if (fs.existsSync(stringsXmlPath)) { if (fs.existsSync(stringsXmlPath)) {
@@ -63,16 +132,17 @@ function withAccessibilityService(config) {
]); ]);
// 2. 注册 AccessibilityBridgePackage 到 MainApplication // 2. 注册 AccessibilityBridgePackage 到 MainApplication
// 注意:AccessibilityBridgePackage 跟随其他 kt 文件一起被复制到 FAKE_PACKAGE 下,
// 因此 import 路径要用 FAKE_PACKAGE 而非 PACKAGE(应用包名)。
config = withMainApplication(config, (modConfig) => { config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents; let content = modConfig.modResults.contents;
// 2a. 注入 importAccessibilityBridgePackage // 2a. 注入 importAccessibilityBridgePackage
if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) { content = content.replace(/^import\s+[\w.]+\.AccessibilityBridgePackage\s*$/gm, '');
content = content.replace( content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m, /^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`, `$1\nimport ${FAKE_PACKAGE}.AccessibilityBridgePackage`,
); );
}
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage()) // 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
if (!content.includes('add(AccessibilityBridgePackage())')) { if (!content.includes('add(AccessibilityBridgePackage())')) {
@@ -93,16 +163,26 @@ function withAccessibilityService(config) {
return modConfig; return modConfig;
}); });
// 3. 注册服务到 AndroidManifest // 3. 注册权限 + 服务到 AndroidManifest
config = withAndroidManifest(config, (modConfig) => { config = withAndroidManifest(config, (modConfig) => {
const manifest = modConfig.modResults.manifest; const manifest = modConfig.modResults.manifest;
// 1. 添加无障碍服务声明 // 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 = { const serviceNode = {
$: { $: {
'android:name': 'com.beancount.mobile.accessibility.BillingAccessibilityService', 'android:name': FAKE_SERVICE_NAME,
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE', 'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
'android:label': '账单识别', 'android:label': '浮记-账单识别',
'android:exported': 'false', 'android:exported': 'false',
}, },
'intent-filter': [{ 'intent-filter': [{
@@ -125,7 +205,7 @@ function withAccessibilityService(config) {
app.service = []; app.service = [];
} }
const exists = app.service.some( const exists = app.service.some(
s => s.$['android:name'] === 'com.beancount.mobile.accessibility.BillingAccessibilityService' s => s.$['android:name'] === FAKE_SERVICE_NAME
); );
if (!exists) { if (!exists) {
app.service.push(serviceNode); app.service.push(serviceNode);
@@ -134,7 +214,7 @@ function withAccessibilityService(config) {
// 3. 添加 OcrTileService 声明(快速设置磁贴,plan.md「3.11」) // 3. 添加 OcrTileService 声明(快速设置磁贴,plan.md「3.11」)
const tileServiceNode = { const tileServiceNode = {
$: { $: {
'android:name': 'com.beancount.mobile.accessibility.OcrTileService', 'android:name': FAKE_TILE_SERVICE_NAME,
'android:label': 'OCR 记账', 'android:label': 'OCR 记账',
'android:icon': '@android:drawable/ic_menu_camera', 'android:icon': '@android:drawable/ic_menu_camera',
'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE', 'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE',
@@ -145,7 +225,7 @@ function withAccessibilityService(config) {
}], }],
}; };
const tileExists = app.service.some( const tileExists = app.service.some(
s => s.$['android:name'] === 'com.beancount.mobile.accessibility.OcrTileService' s => s.$['android:name'] === FAKE_TILE_SERVICE_NAME
); );
if (!tileExists) { if (!tileExists) {
app.service.push(tileServiceNode); app.service.push(tileServiceNode);
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "beancount-mobile-plugin-accessibility", "name": "drift-ledger-plugin-accessibility",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"main": "app.plugin.js" "main": "app.plugin.js"
@@ -8,6 +8,7 @@ import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.modules.core.DeviceEventManagerModule
import com.beancount.mobile.accessibility.SelectToSpeakService
import com.beancount.mobile.accessibility.ReactContextHolder import com.beancount.mobile.accessibility.ReactContextHolder
/** /**
@@ -15,7 +16,7 @@ import com.beancount.mobile.accessibility.ReactContextHolder
* *
* 参考 AutoAccounting NotificationListenerService * 参考 AutoAccounting NotificationListenerService
* - 提取支付 App 通知的 title/text * - 提取支付 App 通知的 title/text
* - 白名单过滤仅支付类 App * - 白名单过滤复用 SelectToSpeakService.PAYMENT_PACKAGES单一数据源
* - 关键词黑白名单JS keywordFilter 进一步过滤 * - 关键词黑白名单JS keywordFilter 进一步过滤
* - MD5 去重JS NotificationChannel 处理避免原生持有状态 * - MD5 去重JS NotificationChannel 处理避免原生持有状态
* - onListenerDisconnected requestRebind 自动重连 * - onListenerDisconnected requestRebind 自动重连
@@ -26,26 +27,14 @@ class BillingNotificationListenerService : NotificationListenerService() {
companion object { companion object {
private const val TAG = "BillingNotification" private const val TAG = "BillingNotification"
/** 支付 App 白名单(与 JS 层 DEFAULT_PAYMENT_PACKAGES 一致)。 */
private val PAYMENT_PACKAGES = setOf(
"com.eg.android.AlipayGphone",
"com.tencent.mm",
"com.unionpay",
"com.cmbchina",
"com.icbc",
"com.chinamworld.main",
"com.ccbrcb",
"com.bankcomm.Bankcomm",
)
} }
override fun onNotificationPosted(sbn: StatusBarNotification?) { override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn) super.onNotificationPosted(sbn)
runCatching { runCatching {
val packageName = sbn?.packageName?.toString() ?: return val packageName = sbn?.packageName?.toString() ?: return
// 白名单过滤 // 白名单过滤(复用无障碍服务的 PAYMENT_PACKAGES
if (!PAYMENT_PACKAGES.contains(packageName)) return if (!SelectToSpeakService.PAYMENT_PACKAGES.contains(packageName)) return
val notification = sbn.notification val notification = sbn.notification
val extras = notification.extras val extras = notification.extras
+28 -5
View File
@@ -11,18 +11,41 @@ const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins'
const fs = require('fs'); const fs = require('fs');
const path = require('path'); 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) { function withNotificationListener(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.notification`;
// 1. 复制 Kotlin 源码到原生工程 // 1. 复制 Kotlin 源码到原生工程
config = withDangerousMod(config, [ config = withDangerousMod(config, [
'android', 'android',
async (modConfig) => { async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot; const projectRoot = modConfig.modRequest.platformProjectRoot;
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/notification'); const pkgPath = appId.replace(/\./g, '/');
const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'notification');
fs.mkdirSync(dest, { recursive: true }); fs.mkdirSync(dest, { recursive: true });
const srcDir = path.join(__dirname, 'android'); const srcDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(srcDir)) { for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) { if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f)); 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; return modConfig;
@@ -36,9 +59,9 @@ function withNotificationListener(config) {
// 1. 添加通知监听服务 // 1. 添加通知监听服务
const serviceNode = { const serviceNode = {
$: { $: {
'android:name': 'com.beancount.mobile.notification.BillingNotificationListenerService', 'android:name': `${PACKAGE}.BillingNotificationListenerService`,
'android:permission': 'android.permission.BIND_NOTIFICATION_LISTENER_SERVICE', 'android:permission': 'android.permission.BIND_NOTIFICATION_LISTENER_SERVICE',
'android:exported': 'false', 'android:exported': 'true',
}, },
'intent-filter': [{ 'intent-filter': [{
action: [{ $: { 'android:name': 'android.service.notification.NotificationListenerService' } }], action: [{ $: { 'android:name': 'android.service.notification.NotificationListenerService' } }],
@@ -54,7 +77,7 @@ function withNotificationListener(config) {
app.service = []; app.service = [];
} }
const exists = app.service.some( const exists = app.service.some(
s => s.$['android:name'] === 'com.beancount.mobile.notification.BillingNotificationListenerService' s => s.$['android:name'] === `${PACKAGE}.BillingNotificationListenerService`
); );
if (!exists) { if (!exists) {
app.service.push(serviceNode); app.service.push(serviceNode);
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "beancount-mobile-plugin-notification-listener", "name": "drift-ledger-plugin-notification-listener",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"main": "app.plugin.js" "main": "app.plugin.js"
+40 -22
View File
@@ -1,9 +1,19 @@
# PP-OCRv5 (ONNX Runtime) Config Plugin # PP-OCR (ONNX Runtime) Config Plugin
本插件在 `expo prebuild` 时注入 PP-OCRv5 本地 OCR 原生模块(plan.md 决策 4)。 本插件在 `expo prebuild` 时注入 PP-OCR 本地 OCR 原生模块(plan.md 决策 4)。
引擎选用 **ONNX Runtime**(跨平台、微软官方、Windows 友好),替代原 NCNN 方案。 引擎选用 **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 |
## 文件结构 ## 文件结构
``` ```
@@ -13,43 +23,41 @@ plugins/ppocr/
│ ├── OcrModule.kt # React Native BridgeONNX Runtime 推理 + det/rec 前后处理 │ ├── OcrModule.kt # React Native BridgeONNX Runtime 推理 + det/rec 前后处理
│ └── OcrPackage.kt # RN Package 注册(注入到 MainApplication.getPackages │ └── OcrPackage.kt # RN Package 注册(注入到 MainApplication.getPackages
└── assets/ # ONNX 模型 + 字典(需自行下载放置) └── assets/ # ONNX 模型 + 字典(需自行下载放置)
├── ppocrv5_det.onnx # 文本检测模型 ├── ppocrv6_det.onnx # 文本检测模型PP-OCRv6 small
├── ppocrv5_rec.onnx # 文本识别模型(多语言,输出 18385 维 ├── ppocrv6_rec.onnx # 文本识别模型(PP-OCRv6 small多语言)
└── ppocrv5_dict.txt # PP-OCRv5 多语言字典(18383 字符,CTC 解码用) └── ppocrv6_dict.txt # PP-OCRv6 多语言字典(CTC 解码用)
``` ```
## 模型获取(一键下载) ## 模型获取(一键下载)
社区已转好的 ONNX 版本(来自官方 Paddle 权重,无质量损失) PP-OCRv6 官方 ONNX 模型来自 [PaddlePaddle/PP-OCRv6 系列](https://huggingface.co/collections/PaddlePaddle/pp-ocrv6)
```bash ```bash
# 在项目根目录执行 # 在项目根目录执行
mkdir -p plugins/ppocr/assets mkdir -p plugins/ppocr/assets
cd plugins/ppocr/assets cd plugins/ppocr/assets
# det 模型(4.8 MB # det 模型(PP-OCRv6 small
curl -L -o ppocrv5_det.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_det.onnx curl -L -o ppocrv6_det.onnx \
https://huggingface.co/PaddlePaddle/PP-OCRv6_small_det_onnx/resolve/main/inference.onnx
# rec 模型(16.6 MB # rec 模型(PP-OCRv6 small
curl -L -o ppocrv5_rec.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_rec.onnx curl -L -o ppocrv6_rec.onnx \
https://huggingface.co/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/main/inference.onnx
# PP-OCRv5 多语言字典(74 KB必须与上面的 rec 模型配套) # PP-OCRv6 多语言字典(必须与上面的 rec 模型配套)
curl -L -o ppocrv5_dict.txt https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv5_dict.txt curl -L -o ppocrv6_dict.txt \
https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv6_dict.txt
``` ```
或用 HuggingFace CLI(首次下载原生模型再转 ONNX 的方式,参见历史 git log) > ⚠️ **字典必须与 rec 模型配套**:v6 字典字符集与 v5 完全不同,混用会导致 CTC 解码乱码
> 若之前使用过 v5 模型,务必删除旧文件(`ppocrv5_det.onnx``ppocrv5_rec.onnx``ppocrv5_dict.txt`)。
> ⚠️ **字典必须与 rec 模型配套**ppocrv5_rec.onnx 输出 18385 维(= 18383 字符 + blank + 特殊位),
> 必须使用 `ppocrv5_dict.txt`18383 行)。若错用旧版 `ppocr_keys_v1.txt`(仅 6623 行),
> CTC 解码会把真实字符的高索引全部丢弃,只输出形如 `'消'青'露'仰'` 的单引号穿插单字符乱码。
> 来源说明:[ilaylow/PP_OCRv5_mobile_onnx](https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx) 是社区维护的 PP-OCRv5 mobile ONNX 镜像,基于官方 [PaddlePaddle/PP-OCRv5_mobile_det](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_det) 与 [_rec](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_rec) 转换而来。
## 性能配置(参考 AutoAccounting OcrProcessor.kt ## 性能配置(参考 AutoAccounting OcrProcessor.kt
| 优化项 | 配置 | | 优化项 | 配置 |
|--------|------| | -------- | -------------------------------------- |
| 引擎 | ONNX Runtime Android 1.20.1 | | 引擎 | ONNX Runtime Android |
| 执行器 | CPU(兼容性最稳,部分设备 GPU 会崩溃) | | 执行器 | CPU(兼容性最稳,部分设备 GPU 会崩溃) |
| 线程 | intraOp=2 / interOp=2 | | 线程 | intraOp=2 / interOp=2 |
| det 图像 | 最大边 960px,短边压缩 720px | | det 图像 | 最大边 960px,短边压缩 720px |
@@ -81,4 +89,14 @@ JS 层通过 `src/services/ocrBridge.ts` 的 `NativeOcrBridge` 调用,桥接
真机构建步骤:放置模型文件 → `npx expo prebuild --platform android`Config Plugin 会把 Kotlin 源码与 `assets/` 下的模型/字典复制进 `android/`)→ `npx expo run:android` 真机构建步骤:放置模型文件 → `npx expo prebuild --platform android`Config Plugin 会把 Kotlin 源码与 `assets/` 下的模型/字典复制进 `android/`)→ `npx expo run:android`
> 若之前已 prebuild 过且更换过字典/模型文件,务必重新执行 `npx expo prebuild --clean`,否则 `android/app/src/main/assets/` 下可能残留旧字典(如 `ppocr_keys_v1.txt`),导致新代码找不到配套字典。 > 若之前已 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
+230 -122
View File
@@ -24,16 +24,18 @@ import kotlinx.coroutines.launch
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStreamReader import java.io.InputStreamReader
import java.nio.FloatBuffer import java.nio.FloatBuffer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantLock
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
/** /**
* PP-OCRv5 (ONNX Runtime) React Native Bridgeplan.md3.4 Layer 2+决策 4 Config Plugin * PP-OCRv6 (ONNX Runtime) React Native Bridgeplan.md3.4 Layer 2+决策 4 Config Plugin
* *
* 引擎ONNX Runtime跨平台微软官方Windows 友好替代 NCNN 路线 * 引擎ONNX Runtime跨平台微软官方Windows 友好替代 NCNN 路线
* 模型ppocrv5_det.onnx + ppocrv5_rec.onnx ilaylow/PP_OCRv5_mobile_onnx 下载 * 模型ppocrv6_det.onnx + ppocrv6_rec.onnxPP-OCRv6 small PaddlePaddle 官方 HuggingFace 下载
* 字典ppocrv5_dict.txtPP-OCRv5 多语言字典18383 字符rec 模型 18385 维输出 = 字典 + blank + 特殊位 * 字典ppocrv6_dict.txtPP-OCRv6 多语言字典18708 字符rec 模型 18710 维输出 = 字典 + blank + 特殊位
* *
* 流水线 * 流水线
* 1. det文本检测bitmap DB 后处理得到文本框 * 1. det文本检测bitmap DB 后处理得到文本框
@@ -62,6 +64,11 @@ class OcrModule(private val context: ReactApplicationContext) :
@Volatile private var initialized = false @Volatile private var initialized = false
@Volatile private var initFailed = 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 getName(): String = OCR_MODULE_NAME
override fun initialize() { override fun initialize() {
@@ -70,7 +77,7 @@ class OcrModule(private val context: ReactApplicationContext) :
scope.launch { initEngine() } scope.launch { initEngine() }
} }
/** 从 assets 加载 det/rec ONNX 模型与字典。 */ /** 从 assets 或 filesystem 加载 det/rec ONNX 模型与字典。 */
private fun initEngine() { private fun initEngine() {
lock.lock() lock.lock()
try { try {
@@ -83,22 +90,47 @@ class OcrModule(private val context: ReactApplicationContext) :
// 移动端关闭内存优化里的图优化级别过高(部分模型会崩) // 移动端关闭内存优化里的图优化级别过高(部分模型会崩)
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT) 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 detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() }
val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() } val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() }
val det = env.createSession(detBytes, opts) Log.i(OCR_MODULE_NAME, "从 assets 加载模型(兼容模式)")
val rec = env.createSession(recBytes, opts) Triple(
val dict = loadDictionary() env.createSession(detBytes, opts),
env.createSession(recBytes, opts),
loadDictionaryFromAssets()
)
}
detSession = det detSession = det
recSession = rec recSession = rec
ortEnv = env ortEnv = env
dictionary = dict dictionary = dict
initialized = true initialized = true
Log.i(OCR_MODULE_NAME, "PP-OCRv5 ONNX 模型加载成功(det+rec, dict=${dict.size}") initLatch.countDown()
Log.i(OCR_MODULE_NAME, "PP-OCRv6 ONNX 模型加载成功(det+rec, dict=${dict.size}")
} catch (e: Exception) { } catch (e: Exception) {
initFailed = true initFailed = true
initLatch.countDown()
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e) 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") Log.e(OCR_MODULE_NAME, "请确认 assets 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
}
} finally { } finally {
lock.unlock() lock.unlock()
} }
@@ -108,13 +140,13 @@ class OcrModule(private val context: ReactApplicationContext) :
private val recEnv: OrtEnvironment? get() = ortEnv private val recEnv: OrtEnvironment? get() = ortEnv
/** /**
* 加载 ppocrv5_dict.txt 字典 * assets 加载 ppocrv6_dict.txt 字典
* *
* PaddleOCR CTC 约定模型输出 logits index 0 blank字符从 index 1 开始 * PaddleOCR CTC 约定模型输出 logits index 0 blank字符从 index 1 开始
* 字典条目 dictionary[i] 对应模型输出 index i+1解码时 dictIdx = argmaxIdx - 1 * 字典条目 dictionary[i] 对应模型输出 index i+1解码时 dictIdx = argmaxIdx - 1
* 字典本身不含 blank运行时固定用 index 0 blank ctcGreedyDecode * 字典本身不含 blank运行时固定用 index 0 blank ctcGreedyDecode
*/ */
private fun loadDictionary(): List<String> { private fun loadDictionaryFromAssets(): List<String> {
val words = mutableListOf<String>() val words = mutableListOf<String>()
context.assets.open(ASSET_DICT).use { stream -> context.assets.open(ASSET_DICT).use { stream ->
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines -> BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines ->
@@ -127,6 +159,17 @@ class OcrModule(private val context: ReactApplicationContext) :
return words 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 * @param imageBase64 base64 编码的图片JPEG/PNG
@@ -144,7 +187,7 @@ class OcrModule(private val context: ReactApplicationContext) :
promise.reject("DECODE_FAILED", "base64 解码失败") promise.reject("DECODE_FAILED", "base64 解码失败")
return@launch return@launch
} }
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE) scaled = capLongEdge(bitmap, CAP_LONG_EDGE)
val blocks = runInference(scaled) val blocks = runInference(scaled)
val text = blocks.joinToString("\n") { it.text } val text = blocks.joinToString("\n") { it.text }
promise.resolve(text) promise.resolve(text)
@@ -176,7 +219,7 @@ class OcrModule(private val context: ReactApplicationContext) :
promise.reject("DECODE_FAILED", "base64 解码失败") promise.reject("DECODE_FAILED", "base64 解码失败")
return@launch return@launch
} }
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE) scaled = capLongEdge(bitmap, CAP_LONG_EDGE)
val blocks = runInference(scaled) val blocks = runInference(scaled)
// 序列化为 RN WritableArray // 序列化为 RN WritableArray
val arr = WritableNativeArray() val arr = WritableNativeArray()
@@ -209,15 +252,41 @@ class OcrModule(private val context: ReactApplicationContext) :
promise.resolve(initialized) 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() { private fun ensureReady() {
if (!initialized && !initFailed) initEngine() if (!initialized && !initFailed) initEngine()
// 修复:异步 initEngine 进行中时等待完成(最多 15 秒),而非立即抛异常
if (!initialized && !initFailed) {
initLatch.await(15, TimeUnit.SECONDS)
}
if (!initialized) throw IllegalStateException("OCR 引擎未就绪(模型未加载,${if (initFailed) "初始化失败" else "加载中"}") if (!initialized) throw IllegalStateException("OCR 引擎未就绪(模型未加载,${if (initFailed) "初始化失败" else "加载中"}")
} }
/** 完整推理:det 检测文本框 → 对每个框 rec 识别 → 返回带坐标的文本块。 */ /** 完整推理:det 检测文本框 → 对每个框 rec 识别 → 返回带坐标的文本块。 */
private fun runInference(bitmap: Bitmap): List<OcrBlock> { private fun runInference(bitmap: Bitmap): List<OcrBlock> {
val totalStartTime = System.currentTimeMillis()
Log.i(OCR_MODULE_NAME, "runInference 开始: bitmap 尺寸 = ${bitmap.width}x${bitmap.height}") Log.i(OCR_MODULE_NAME, "runInference 开始: bitmap 尺寸 = ${bitmap.width}x${bitmap.height}")
val det = detSession ?: run { val det = detSession ?: run {
Log.e(OCR_MODULE_NAME, "detSession 为空,放弃推理") Log.e(OCR_MODULE_NAME, "detSession 为空,放弃推理")
@@ -233,8 +302,14 @@ class OcrModule(private val context: ReactApplicationContext) :
var detOutputs: OrtSession.Result? = null var detOutputs: OrtSession.Result? = null
val results = mutableListOf<OcrBlock>() val results = mutableListOf<OcrBlock>()
var detTime = 0L
var postTime = 0L
var recTime = 0L
var boxCount = 0
try { try {
// ---- 1. 文本检测(DB---- // ---- 1. 文本检测(DB----
val detStartTime = System.currentTimeMillis()
resized = resizeForDet(bitmap, DET_LIMIT_MAX_SIDE) resized = resizeForDet(bitmap, DET_LIMIT_MAX_SIDE)
Log.i(OCR_MODULE_NAME, "det 图像缩放后尺寸 = ${resized.width}x${resized.height}") Log.i(OCR_MODULE_NAME, "det 图像缩放后尺寸 = ${resized.width}x${resized.height}")
val ratioX = bitmap.width.toFloat() / resized.width val ratioX = bitmap.width.toFloat() / resized.width
@@ -243,18 +318,30 @@ class OcrModule(private val context: ReactApplicationContext) :
val detInput = preprocessDet(resized) val detInput = preprocessDet(resized)
detInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(detInput.data), longArrayOf(1L, 3L, detInput.h.toLong(), detInput.w.toLong())) detInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(detInput.data), longArrayOf(1L, 3L, detInput.h.toLong(), detInput.w.toLong()))
val detInputs = mapOf("x" to detInputTensor) val detInputs = mapOf("x" to detInputTensor)
val detRunStart = System.currentTimeMillis()
detOutputs = det.run(detInputs) detOutputs = det.run(detInputs)
detTime = System.currentTimeMillis() - detRunStart
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val detProb = (detOutputs[0].value as Array<Array<Array<FloatArray>>>)[0][0] // [H,W] val detProb = (detOutputs[0].value as Array<Array<Array<FloatArray>>>)[0][0] // [H,W]
Log.i(OCR_MODULE_NAME, "det 推理完成,概率图尺寸 = ${detProb.size}x${detProb[0].size}") Log.i(OCR_MODULE_NAME, "det 推理完成 (耗时: ${detTime}ms),概率图尺寸 = ${detProb.size}x${detProb[0].size}")
// DB 后处理:threshold → 轮廓 → 最小外接矩形 // DB 后处理:threshold → 轮廓 → 最小外接矩形
val postStart = System.currentTimeMillis()
val boxes = dbPostprocess(detProb, detInput.h, detInput.w, ratioX, ratioY) val boxes = dbPostprocess(detProb, detInput.h, detInput.w, ratioX, ratioY)
Log.i(OCR_MODULE_NAME, "dbPostprocess 后处理完成,检测到文本框数量 = ${boxes.size}") postTime = System.currentTimeMillis() - postStart
boxCount = boxes.size
Log.i(OCR_MODULE_NAME, "dbPostprocess 后处理完成 (耗时: ${postTime}ms),检测到文本框数量 = ${boxCount}")
if (boxes.isEmpty()) return emptyList() 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---- // ---- 2. 文本识别(CRNN+CTC----
val recStart = System.currentTimeMillis()
for ((idx, box) in boxes.withIndex()) { for ((idx, box) in boxes.withIndex()) {
var crop: Bitmap? = null var crop: Bitmap? = null
var recInputTensor: OnnxTensor? = null var recInputTensor: OnnxTensor? = null
@@ -292,6 +379,7 @@ class OcrModule(private val context: ReactApplicationContext) :
recOutputs?.close() recOutputs?.close()
} }
} }
recTime = System.currentTimeMillis() - recStart
} finally { } finally {
if (resized !== bitmap) { if (resized !== bitmap) {
resized?.recycle() resized?.recycle()
@@ -300,6 +388,12 @@ class OcrModule(private val context: ReactApplicationContext) :
detOutputs?.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 return results
} }
@@ -321,8 +415,8 @@ class OcrModule(private val context: ReactApplicationContext) :
for (y in 0 until h) { for (y in 0 until h) {
for (x in 0 until w) { for (x in 0 until w) {
val px = pixels[y * w + x] val px = pixels[y * w + x]
// 提取 R/G/Bc=0→R, 1→G, 2→B // 对齐官方训练通道序 BGRc=0→B, 1→G, 2→R);mean/std 数值按 BGR 顺序配套
val channelVal = (px shr (16 - 8 * c)) and 0xFF val channelVal = (px shr (8 * c)) and 0xFF
data[c * w * h + y * w + x] = (channelVal / 255.0f - mean) / std data[c * w * h + y * w + x] = (channelVal / 255.0f - mean) / std
} }
} }
@@ -334,9 +428,10 @@ class OcrModule(private val context: ReactApplicationContext) :
private fun preprocessRec(bmp: Bitmap): TensorData { private fun preprocessRec(bmp: Bitmap): TensorData {
var w = bmp.width var w = bmp.width
val h = bmp.height val h = bmp.height
// resize 到高度 48,宽度等比缩放 // resize 到高度 48,宽度等比缩放(ceil 取整对齐官方,避免右缘字符因 floor 被裁)
var resizedW = (w.toFloat() / h * REC_IMAGE_HEIGHT).toInt() // 注: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 = min(resizedW, REC_MAX_WIDTH)
resizedW = max(resizedW, 1) resizedW = max(resizedW, 1)
val resized = if (resizedW == w && h == REC_IMAGE_HEIGHT) bmp val resized = if (resizedW == w && h == REC_IMAGE_HEIGHT) bmp
@@ -362,9 +457,9 @@ class OcrModule(private val context: ReactApplicationContext) :
return TensorData(data, w, REC_IMAGE_HEIGHT) return TensorData(data, w, REC_IMAGE_HEIGHT)
} }
// ============== DB 后处理(简化版 ============== // ============== DB 后处理(连通域法,对齐 PaddleOCR 官方 ==============
// 参考 PaddleOCR db_postprocesssigmoid → threshold → 连通域 → 最小外接矩形 // sigmoid → 阈值二值化4-连通域标记每域 bbox 按官方 unclip 公式外扩 → box_score_fast 过滤。
// 这里用轻量实现:逐像素阈值化后用投影法估框,对常见单/多行账单足够 // 取代旧的「水平/垂直投影法」:投影法会把基线孤立小数点切到行外导致金额丢点(¥143.97→¥14397
/** /**
* DB 后处理sigmoid + 阈值 0.3 二值图 连通域外接矩形 * DB 后处理sigmoid + 阈值 0.3 二值图 连通域外接矩形
@@ -389,6 +484,7 @@ class OcrModule(private val context: ReactApplicationContext) :
val endY = (h * 0.92).toInt() val endY = (h * 0.92).toInt()
val binMask = Array(h) { IntArray(w) } val binMask = Array(h) { IntArray(w) }
val sigMap = Array(h) { FloatArray(w) } // 概率图(sigmoid 后),供连通域 box_score_fast 取文本像素均值
var activeCountTotal = 0 var activeCountTotal = 0
for (y in 0 until h) { for (y in 0 until h) {
for (x in 0 until w) { for (x in 0 until w) {
@@ -402,6 +498,7 @@ class OcrModule(private val context: ReactApplicationContext) :
} else { } else {
raw raw
} }
sigMap[y][x] = sig
val isActive = if (sig > DET_THRESH) 1 else 0 val isActive = if (sig > DET_THRESH) 1 else 0
binMask[y][x] = isActive binMask[y][x] = isActive
if (isActive == 1) activeCountTotal++ if (isActive == 1) activeCountTotal++
@@ -426,71 +523,68 @@ class OcrModule(private val context: ReactApplicationContext) :
} }
Log.i(OCR_MODULE_NAME, "垂直线噪清理完成: 清理了 $clearedColsCount / $w") Log.i(OCR_MODULE_NAME, "垂直线噪清理完成: 清理了 $clearedColsCount / $w")
// 水平投影:按行找文本行 // 连通域标记(4-连通,迭代 BFS 防栈溢出):取代旧的「水平投影切行 + 垂直投影切列」。
val rowHits = IntArray(h) // 投影法会把基线/顶线上的孤立标点(小数点等,该行水平投影 < 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 (y in 0 until h) {
var sum = 0 for (x in 0 until w) {
for (x in 0 until w) sum += binMask[y][x] if (binMask[y][x] == 1 && labels[y][x] == 0) {
rowHits[y] = sum 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)) }
} }
val minRowWidth = max(1, w / 20) // 一行至少要有这么多像素才算文本 compX0.add(x0); compY0.add(y0); compX1.add(x1); compY1.add(y1)
val rowRanges = mutableListOf<IntArray>() compArea.add(area); compSum.add(sum)
var inLine = false
var lineStart = 0
for (y in 0 until h) {
val isText = rowHits[y] >= minRowWidth
if (isText && !inLine) { inLine = true; lineStart = y }
else if (!isText && inLine) {
rowRanges.add(intArrayOf(lineStart, y - 1))
inLine = false
} }
} }
if (inLine) rowRanges.add(intArrayOf(lineStart, h - 1)) }
Log.i(OCR_MODULE_NAME, "dbPostprocess 水平分割完成,找到行Ranges数 = ${rowRanges.size}") Log.i(OCR_MODULE_NAME, "dbPostprocess 连通域标记完成,连通域数 = $nComp")
val boxes = mutableListOf<List<FloatArray>>() val boxes = mutableListOf<List<FloatArray>>()
// 对每行做垂直投影切列(账单每行通常是连续一段或多段) var dropSmall = 0; var dropScore = 0
for ((y0, y1) in rowRanges.map { it[0] to it[1] }) { for (i in 0 until nComp) {
val colHits = IntArray(w) val bx0 = compX0[i]; val by0 = compY0[i]; val bx1 = compX1[i]; val by1 = compY1[i]
for (x in 0 until w) { val bw = bx1 - bx0 + 1; val bh = by1 - by0 + 1
var sum = 0 val area = compArea[i]
for (y in y0..y1) sum += binMask[y][x] val perim = 2 * (bw + bh)
colHits[x] = sum // 官方 unclip 公式:distance = 连通域面积 × 比例 / 周长(水平文本下≈各向同性外扩,把基线标点纳入框)
} var d = if (perim > 0) area * UNCLIP_RATIO / perim else 0f
val minColHeight = max(1, (y1 - y0 + 1) / 12) if (d < 1f) d = 1f
var inSeg = false val nx0 = max(0, Math.round(bx0 - d))
var segStart = 0 val ny0 = max(0, Math.round(by0 - d))
var segs = mutableListOf<IntArray>() val nx1 = min(w - 1, Math.round(bx1 + d))
for (x in 0 until w) { val ny1 = min(h - 1, Math.round(by1 + d))
val isText = colHits[x] >= minColHeight if ((nx1 - nx0 + 1) < MIN_SIZE || (ny1 - ny0 + 1) < MIN_SIZE) { dropSmall++; continue }
if (isText && !inSeg) { inSeg = true; segStart = x } // box_score_fast:域内文本像素 prob 均值(非整 bbox 均值,否则被背景稀释而误杀)
else if (!isText && inSeg) { val score = compSum[i] / area
// 合并间隔很近的段 if (score < BOX_THRESH) { dropScore++; continue }
if (segs.isNotEmpty() && segStart - segs.last()[1] < DET_MERGE_GAP) { val fx0 = nx0 * ratioX; val fy0 = ny0 * ratioY
segs.last()[1] = x - 1 val fx1 = nx1 * ratioX; val fy1 = ny1 * ratioY
} else {
segs.add(intArrayOf(segStart, x - 1))
}
inSeg = false
}
}
if (inSeg) {
if (segs.isNotEmpty() && (w - 1) - segs.last()[1] < DET_MERGE_GAP) {
segs.last()[1] = w - 1
} else {
segs.add(intArrayOf(segStart, w - 1))
}
}
for ((x0, x1) in segs.map { it[0] to it[1] }) {
// 过滤过小的框
val boxW = x1 - x0 + 1
val boxH = y1 - y0 + 1
if (boxW < 4 || boxH < 2) continue
// 映射回原图坐标(4 个角点)
val fx0 = x0 * ratioX
val fx1 = x1 * ratioX
val fy0 = y0 * ratioY
val fy1 = y1 * ratioY
boxes.add(listOf( boxes.add(listOf(
floatArrayOf(fx0, fy0), floatArrayOf(fx0, fy0),
floatArrayOf(fx1, fy0), floatArrayOf(fx1, fy0),
@@ -498,7 +592,7 @@ class OcrModule(private val context: ReactApplicationContext) :
floatArrayOf(fx0, fy1), floatArrayOf(fx0, fy1),
)) ))
} }
} Log.i(OCR_MODULE_NAME, "dbPostprocess 取框完成: 扩后min_size丢=$dropSmall, box_score丢=$dropScore, 保留=${boxes.size}")
return boxes return boxes
} }
@@ -509,8 +603,9 @@ class OcrModule(private val context: ReactApplicationContext) :
* *
* PaddleOCR 约定logits index 0 固定是 blank字符从 index 1 * PaddleOCR 约定logits index 0 固定是 blank字符从 index 1
* dictionary[i] 对应模型输出 index i+1因此 dictIdx = argmaxIdx - 1 * dictionary[i] 对应模型输出 index i+1因此 dictIdx = argmaxIdx - 1
* 已用 onnxruntime 实证argmax 序列中 0 占多数 blank真实字符索引 *
* 90'支' idx-1 映射到 dictionary 即可正确还原中文 * PP-OCRv6 模型输出已经是概率分布值域 [0,1]无需额外 softmax
* 直接取 argmax 对应的值作为该时间步的置信度
*/ */
private fun ctcGreedyDecode(logits: Array<FloatArray>): Pair<String, Float> { private fun ctcGreedyDecode(logits: Array<FloatArray>): Pair<String, Float> {
if (logits.isEmpty()) return "" to 0f if (logits.isEmpty()) return "" to 0f
@@ -527,16 +622,12 @@ class OcrModule(private val context: ReactApplicationContext) :
for (i in 1 until numClasses) { for (i in 1 until numClasses) {
if (logits[t][i] > maxVal) { maxVal = logits[t][i]; maxIdx = i } if (logits[t][i] > maxVal) { maxVal = logits[t][i]; maxIdx = i }
} }
// softmax 概率(用于置信度统计)
var expSum = 0.0
for (i in 0 until numClasses) expSum += Math.exp(logits[t][i].toDouble())
val prob = Math.exp(maxVal.toDouble()) / expSum
if (maxIdx != blankIdx && maxIdx != lastIdx) { if (maxIdx != blankIdx && maxIdx != lastIdx) {
val dictIdx = maxIdx - 1 // index 1..N → dictionary[0..N-1] val dictIdx = maxIdx - 1 // index 1..N → dictionary[0..N-1]
if (dictIdx in 0 until dictionary.size) { if (dictIdx in 0 until dictionary.size) {
sb.append(dictionary[dictIdx]) sb.append(dictionary[dictIdx])
confSum += prob.toFloat() confSum += maxVal // v6 输出已是概率,直接用
confCount++ confCount++
} }
} }
@@ -560,18 +651,29 @@ class OcrModule(private val context: ReactApplicationContext) :
val w = maxX - minX val w = maxX - minX
val h = maxY - minY val h = maxY - minY
if (w < 2 || h < 2) return null if (w < 2 || h < 2) return null
return Bitmap.createBitmap(bmp, minX, minY, w, h) 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 { private fun resizeForDet(bmp: Bitmap, maxSide: Int): Bitmap {
val ratio = maxSide.toFloat() / max(bmp.width, bmp.height) // 长边上限 + 无条件对齐 32(det 下采样要求),half-up 取整对齐官方 round。
if (ratio >= 1f) return bmp // 关键修复:旧实现 ratio>=1 时直接 return 不对齐,若整图未预压且非 32 倍数会喂入非法尺寸,
val newW = (bmp.width * ratio).toInt() // 导致 det 内部特征图广播报错;现无条件对齐,且 cap 后图通常 ≤32 倍数时仅轻微取整。
val newH = (bmp.height * ratio).toInt() val ratioC = minOf(1f, maxSide.toFloat() / max(bmp.width, bmp.height))
// 确保尺寸是 32 的倍数(det 模型下采样要求) val newW = Math.round(bmp.width * ratioC)
val alignedW = (newW / 32) * 32 val newH = Math.round(bmp.height * ratioC)
val alignedH = (newH / 32) * 32 val alignedW = max(32, Math.round(newW / 32f) * 32)
if (alignedW < 32 || alignedH < 32) return bmp 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) return Bitmap.createScaledBitmap(bmp, alignedW, alignedH, true)
} }
@@ -591,17 +693,18 @@ class OcrModule(private val context: ReactApplicationContext) :
} }
/** /**
* 短边压缩到 maxShortEdge参考 AutoAccounting scaleDownForOcr * 整图长边上限降采样仅当长边超过 cap 才按比例缩小half-up 取整否则原样返回
* 像素量比 1440p 减少约 75%识别速度大幅提升 * 使 rec crop 源尽量高清手机截图通常不触发仅防超大图 OOM
* 取代旧的短边压 720旧法把整图先砍掉 75% 像素叠加 det resize 后小数点等细笔画被严重淡化
*/ */
private fun scaleDownForOcr(bitmap: Bitmap, maxShortEdge: Int): Bitmap { private fun capLongEdge(bitmap: Bitmap, capLong: Int): Bitmap {
val width = bitmap.width val width = bitmap.width
val height = bitmap.height val height = bitmap.height
val shortEdge = minOf(width, height) val longEdge = maxOf(width, height)
if (shortEdge <= maxShortEdge) return bitmap if (longEdge <= capLong) return bitmap
val scale = maxShortEdge.toFloat() / shortEdge val scale = capLong.toFloat() / longEdge
val newWidth = (width * scale).toInt() val newWidth = Math.round(width * scale)
val newHeight = (height * scale).toInt() val newHeight = Math.round(height * scale)
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
} }
@@ -637,23 +740,28 @@ class OcrModule(private val context: ReactApplicationContext) :
private data class TensorData(val data: FloatArray, val w: Int, val h: Int) private data class TensorData(val data: FloatArray, val w: Int, val h: Int)
companion object { companion object {
/** OCR 最大短边(参考 AutoAccounting OCR_MAX_SHORT_EDGE)。 */ /** 整图长边上限仅超大图降采样防 OOM手机截图(2400)不预压使 rec crop 源为高清原图
private const val OCR_MAX_SHORT_EDGE = 720 * 对齐官方只放大不缩小语义的移动端折中官方 max_side_limit=4000 */
/** det resize 最大边(PaddleOCR limit_max_side_len 默认值)。 */ private const val CAP_LONG_EDGE = 3000
private const val DET_LIMIT_MAX_SIDE = 960 /** det 输入长边上限(官方 OCR 管线不降采样;移动端为性能折中取 1600,旧值 960 会让小数点仅 1-2px 而糊掉)。 */
/** DB 二值化阈值。 */ private const val DET_LIMIT_MAX_SIDE = 1600
private const val DET_THRESH = 0.3f /** DB 二值化阈值(对齐官方模型 inference.yml=0.2,对细笔画/小数点更敏感;噪声框由 BOX_THRESH 兜底)。 */
/** 投影法合并相邻文本段的间隔(像素)。 */ private const val DET_THRESH = 0.2f
private const val DET_MERGE_GAP = 10 /** 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 固定图像高度。 */ /** rec 固定图像高度。 */
private const val REC_IMAGE_HEIGHT = 48 private const val REC_IMAGE_HEIGHT = 48
/** rec 单行最大宽度。 */ /** rec 单行最大宽度(官方 cap=3200,移动端折中 1280;旧值 320 会把长商户名水平压扁 5×+)。 */
private const val REC_MAX_WIDTH = 320 private const val REC_MAX_WIDTH = 1280
/** assets 中的模型/字典文件名。 */ /** assets 中的模型/字典文件名。 */
private const val ASSET_DET_MODEL = "ppocrv5_det.onnx" private const val ASSET_DET_MODEL = "ppocrv6_det.onnx"
private const val ASSET_REC_MODEL = "ppocrv5_rec.onnx" private const val ASSET_REC_MODEL = "ppocrv6_rec.onnx"
// PP-OCRv5 多语言识别模型的配套字典(18383 字符 + 运行时 1 blank = 18385 维输出)。 // PP-OCRv6 多语言识别模型的配套字典(18708 字符 + 运行时 1 blank = 18710 维输出)。
// 注意:必须与 rec 模型配套,错用旧版 ppocr_keys_v1.txt6623)会导致 CTC 解码乱码 // 注意:必须与 rec 模型配套,错用 v5 字典(18383)会导致 CTC 解码丢字符
private const val ASSET_DICT = "ppocrv5_dict.txt" private const val ASSET_DICT = "ppocrv6_dict.txt"
} }
} }
+38 -8
View File
@@ -22,9 +22,33 @@ const {
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const PACKAGE = 'com.beancount.mobile.ppocr'; function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
/** 递归复制目录,prebuild 阶段执行一次。 */ /** 递归复制目录,prebuild 阶段执行并替换包名。 */
function copyAndReplaceDir(src, dest, appId) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src)) {
const s = path.join(src, entry);
const d = path.join(dest, entry);
if (fs.statSync(s).isDirectory()) {
copyAndReplaceDir(s, d, appId);
} else {
if (entry.endsWith('.kt') || entry.endsWith('.java')) {
let content = fs.readFileSync(s, 'utf8');
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
fs.writeFileSync(d, content, 'utf8');
} else {
fs.copyFileSync(s, d);
}
}
}
}
/** 递归复制目录(仅用于模型资源复制,不替换包名)。 */
function copyDir(src, dest) { function copyDir(src, dest) {
if (!fs.existsSync(src)) return; if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true }); fs.mkdirSync(dest, { recursive: true });
@@ -37,17 +61,24 @@ function copyDir(src, dest) {
} }
function withPpOcr(config) { function withPpOcr(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.ppocr`;
// 1+2. 复制 Kotlin 源码与 ONNX 模型/字典到原生工程 // 1+2. 复制 Kotlin 源码与 ONNX 模型/字典到原生工程
config = withDangerousMod(config, [ config = withDangerousMod(config, [
'android', 'android',
async (modConfig) => { async (modConfig) => {
// platformProjectRoot 在 modRequest 里(项目根的 android/ 子目录)
const projectRoot = modConfig.modRequest.platformProjectRoot; const projectRoot = modConfig.modRequest.platformProjectRoot;
// Kotlin 源码(OcrModule.kt + OcrPackage.kt const pkgPath = appId.replace(/\./g, '/');
copyDir( const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'ppocr');
// Kotlin 源码并自动重命名包名
copyAndReplaceDir(
path.join(__dirname, 'android'), path.join(__dirname, 'android'),
path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/ppocr'), ktDest,
appId,
); );
// ONNX 模型 + 字典(若已下载) // ONNX 模型 + 字典(若已下载)
const assetsSrc = path.join(__dirname, 'assets'); const assetsSrc = path.join(__dirname, 'assets');
if (fs.existsSync(assetsSrc)) { if (fs.existsSync(assetsSrc)) {
@@ -65,12 +96,11 @@ function withPpOcr(config) {
let content = modConfig.modResults.contents; let content = modConfig.modResults.contents;
// 3a. 注入 import(在 package 声明行后插入) // 3a. 注入 import(在 package 声明行后插入)
if (!content.includes(`import ${PACKAGE}.OcrPackage`)) { content = content.replace(/^import\s+[\w.]+\.OcrPackage\s*$/gm, '');
content = content.replace( content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m, /^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.OcrPackage`, `$1\nimport ${PACKAGE}.OcrPackage`,
); );
}
// 3b. 在 getPackages() 的 .apply {} 块里注入 add(OcrPackage()) // 3b. 在 getPackages() 的 .apply {} 块里注入 add(OcrPackage())
if (!content.includes('add(OcrPackage())')) { if (!content.includes('add(OcrPackage())')) {
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "beancount-mobile-plugin-ppocr", "name": "drift-ledger-plugin-ppocr",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"main": "app.plugin.js" "main": "app.plugin.js"
+24 -6
View File
@@ -12,37 +12,55 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require('
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const PACKAGE = 'com.beancount.mobile.screenshot'; 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) { function withScreenshotMonitor(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.screenshot`;
// 1. 复制 Kotlin 源码到原生工程 // 1. 复制 Kotlin 源码到原生工程
config = withDangerousMod(config, [ config = withDangerousMod(config, [
'android', 'android',
async (modConfig) => { async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot; const projectRoot = modConfig.modRequest.platformProjectRoot;
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/screenshot'); const pkgPath = appId.replace(/\./g, '/');
const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'screenshot');
fs.mkdirSync(dest, { recursive: true }); fs.mkdirSync(dest, { recursive: true });
const srcDir = path.join(__dirname, 'android'); const srcDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(srcDir)) { for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) { if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f)); 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; return modConfig;
}, },
]); ]);
// 2. 注册 ScreenshotPackage 到 MainApplication(复制 ppocr 模式) // 2. 注册 ScreenshotPackage 到 MainApplication
config = withMainApplication(config, (modConfig) => { config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents; let content = modConfig.modResults.contents;
// 2a. 注入 import // 2a. 注入 import
if (!content.includes(`import ${PACKAGE}.ScreenshotPackage`)) { content = content.replace(/^import\s+[\w.]+\.ScreenshotPackage\s*$/gm, '');
content = content.replace( content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m, /^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.ScreenshotPackage`, `$1\nimport ${PACKAGE}.ScreenshotPackage`,
); );
}
// 2b. 在 getPackages() 的 .apply {} 块里注入 add(ScreenshotPackage()) // 2b. 在 getPackages() 的 .apply {} 块里注入 add(ScreenshotPackage())
if (!content.includes('add(ScreenshotPackage())')) { if (!content.includes('add(ScreenshotPackage())')) {
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "beancount-mobile-plugin-screenshot-monitor", "name": "drift-ledger-plugin-screenshot-monitor",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"main": "app.plugin.js" "main": "app.plugin.js"
+122 -18
View File
@@ -1,28 +1,46 @@
const { withDangerousMod } = require('@expo/config-plugins'); const { withDangerousMod, withGradleProperties } = require('@expo/config-plugins');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
function withSizeOptimization(config) { /**
// 1. 在 prebuild 时修改 gradle.properties * gradle.properties modResults 数组中设置某个 key存在则更新不存在则追加
config = withDangerousMod(config, [ *
'android', * 必须用 withGradleProperties expo 内存 mod 流程而非 withDangerousMod直接改文件
async (modConfig) => { * 否则会被 expo gradleProperties base mod write 阶段用内存快照全量覆盖
const projectRoot = modConfig.modRequest.platformProjectRoot; * 典型症状reactNativeArchitectures 改了不生效被覆盖回全架构
const propertiesPath = path.join(projectRoot, 'gradle.properties'); * minify/shrink 等新 key 反而生效不在 expo 内存快照里未被覆盖
if (fs.existsSync(propertiesPath)) { */
let content = fs.readFileSync(propertiesPath, 'utf8'); function setGradleProperty(modResults, key, value) {
if (content.includes('reactNativeArchitectures=')) { let found = false;
content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a'); const updated = modResults.map((prop) => {
} else { if (prop.type === 'property' && prop.key === key) {
content += '\nreactNativeArchitectures=arm64-v8a\n'; found = true;
return { ...prop, value };
} }
fs.writeFileSync(propertiesPath, content, 'utf8'); return prop;
});
if (!found) {
updated.push({ type: 'property', key, value });
} }
return modConfig; return updated;
} }
]);
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包 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, [ config = withDangerousMod(config, [
'android', 'android',
async (modConfig) => { async (modConfig) => {
@@ -30,13 +48,99 @@ function withSizeOptimization(config) {
const buildGradlePath = path.join(projectRoot, 'app/build.gradle'); const buildGradlePath = path.join(projectRoot, 'app/build.gradle');
if (fs.existsSync(buildGradlePath)) { if (fs.existsSync(buildGradlePath)) {
let content = fs.readFileSync(buildGradlePath, 'utf8'); let content = fs.readFileSync(buildGradlePath, 'utf8');
// ABI Splits 分包配置
if (!content.includes('splits {')) { if (!content.includes('splits {')) {
content = content.replace( content = content.replace(
/android\s*\{/, /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 }` `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'); 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 modConfig;
} }
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"name": "size-optimization", "name": "drift-ledger-plugin-size-optimization",
"main": "app.plugin.js" "main": "app.plugin.js"
} }
+25 -4
View File
@@ -11,18 +11,39 @@ const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins'
const fs = require('fs'); const fs = require('fs');
const path = require('path'); 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) { function withSmsReceiver(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.sms`;
// 1. 复制 Kotlin 源码到原生工程 // 1. 复制 Kotlin 源码到原生工程
config = withDangerousMod(config, [ config = withDangerousMod(config, [
'android', 'android',
async (modConfig) => { async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot; const projectRoot = modConfig.modRequest.platformProjectRoot;
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/sms'); const pkgPath = appId.replace(/\./g, '/');
const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'sms');
fs.mkdirSync(dest, { recursive: true }); fs.mkdirSync(dest, { recursive: true });
const srcDir = path.join(__dirname, 'android'); const srcDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(srcDir)) { for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) { if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f)); 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; return modConfig;
@@ -48,7 +69,7 @@ function withSmsReceiver(config) {
// 2. 添加短信 Receiver // 2. 添加短信 Receiver
const receiverNode = { const receiverNode = {
$: { $: {
'android:name': 'com.beancount.mobile.sms.BillingSmsReceiver', 'android:name': `${PACKAGE}.BillingSmsReceiver`,
'android:exported': 'true', 'android:exported': 'true',
}, },
'intent-filter': [{ 'intent-filter': [{
@@ -65,7 +86,7 @@ function withSmsReceiver(config) {
app.receiver = []; app.receiver = [];
} }
const exists = app.receiver.some( const exists = app.receiver.some(
r => r.$['android:name'] === 'com.beancount.mobile.sms.BillingSmsReceiver' r => r.$['android:name'] === `${PACKAGE}.BillingSmsReceiver`
); );
if (!exists) { if (!exists) {
app.receiver.push(receiverNode); app.receiver.push(receiverNode);
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "beancount-mobile-plugin-sms-receiver", "name": "drift-ledger-plugin-sms-receiver",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"main": "app.plugin.js" "main": "app.plugin.js"
+9 -1
View File
@@ -9,7 +9,7 @@
* domain/ai.ts processNaturalLanguage + buildNaturalLanguagePrompt * domain/ai.ts processNaturalLanguage + buildNaturalLanguagePrompt
*/ */
import { CHAT_TRANSACTION_KEYWORDS } from '../domain/constants'; import { CHAT_TRANSACTION_KEYWORDS } from '../domain/core/constants';
import { processNaturalLanguage, removeThink, type AiProvider, type AiBillResult } from '../domain/ai'; import { processNaturalLanguage, removeThink, type AiProvider, type AiBillResult } from '../domain/ai';
export interface ChatMessage { export interface ChatMessage {
@@ -39,6 +39,8 @@ export function isTransactionIntent(input: string): boolean {
return hasAmount && hasKeyword; return hasAmount && hasKeyword;
} }
import { logger } from '../utils/logger';
/** /**
* *
*/ */
@@ -47,11 +49,14 @@ export async function processChatMessage(
provider: AiProvider, provider: AiProvider,
conversation?: ChatConversation, conversation?: ChatConversation,
): Promise<ChatResponse> { ): Promise<ChatResponse> {
logger.info('aiChat', `收到 AI 助手对话请求 [文本: "${input}"]`);
try { try {
// 意图判定 // 意图判定
if (isTransactionIntent(input)) { if (isTransactionIntent(input)) {
logger.info('aiChat', '检测到记账意图,触发自然语言识别流水线');
const result = await processNaturalLanguage(input, provider); const result = await processNaturalLanguage(input, provider);
if (result.type === 'draft') { 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] }; return { type: 'bill_card', billCards: [result.draft] };
} }
// 非 draft 但有意图 → 返回 AI 文本 // 非 draft 但有意图 → 返回 AI 文本
@@ -59,10 +64,13 @@ export async function processChatMessage(
} }
// 自由对话 // 自由对话
logger.info('aiChat', '未命中记账模式,发起 AI 自由问答');
const messages = buildChatContext(input, conversation); const messages = buildChatContext(input, conversation);
const response = await provider.chat(messages); const response = await provider.chat(messages);
logger.info('aiChat', 'AI 自由问答回复成功');
return { type: 'text', text: removeThink(response) }; return { type: 'text', text: removeThink(response) };
} catch (e) { } catch (e) {
logger.error('aiChat', 'AI 对话助手响应失败', e);
return { type: 'error', error: e instanceof Error ? e.message : String(e) }; return { type: 'error', error: e instanceof Error ? e.message : String(e) };
} }
} }
+16 -7
View File
@@ -10,9 +10,9 @@
*/ */
import { buildMonthlySummaryPrompt, type AiProvider } from '../domain/ai'; import { buildMonthlySummaryPrompt, type AiProvider } from '../domain/ai';
import { generateAnnualReport } from '../domain/annualReport'; import { generateAnnualReport } from '../domain/stats/annualReport';
import { removeThink } from '../domain/ai'; import { removeThink } from '../domain/ai';
import type { Transaction } from '../domain/types'; import type { Transaction } from '../domain/core/types';
export interface MonthlyStats { export interface MonthlyStats {
year: number; year: number;
@@ -50,6 +50,8 @@ export function calculateMonthlyStats(
}; };
} }
import { logger } from '../utils/logger';
/** /**
* AI * AI
*/ */
@@ -59,6 +61,8 @@ export async function generateMonthlySummary(
month: number, month: number,
provider: AiProvider, provider: AiProvider,
): Promise<MonthlySummaryResult> { ): Promise<MonthlySummaryResult> {
logger.info('aiSummary', `开始生成 AI 月度财务总结 [${year}${month}月]`);
try {
const stats = calculateMonthlyStats(transactions, year, month); const stats = calculateMonthlyStats(transactions, year, month);
const messages = buildMonthlySummaryPrompt({ const messages = buildMonthlySummaryPrompt({
@@ -69,23 +73,28 @@ export async function generateMonthlySummary(
}); });
const response = await provider.chat(messages); const response = await provider.chat(messages);
logger.info('aiSummary', `AI 月度财务总结生成成功 [${year}${month}月]`);
return { return {
stats, stats,
summary: removeThink(response), summary: removeThink(response),
}; };
} catch (e) {
logger.error('aiSummary', `生成 AI 月度总结失败 [${year}${month}月]`, e);
throw e;
}
} }
/** 无 AI 的纯文本总结(降级,不调 AI)。 */ /** 无 AI 的纯文本总结(降级,不调 AI)。 */
export function generatePlainTextSummary(stats: MonthlyStats): string { export function generatePlainTextSummary(stats: MonthlyStats): string {
const lines: string[] = []; const lines: string[] = [];
lines.push(`📊 ${stats.year}${stats.month}月财务总结`); lines.push(`${stats.year}${stats.month}月财务总结`);
lines.push(''); lines.push('');
lines.push(`💰 总收入:${stats.totalIncome}`); lines.push(`总收入:${stats.totalIncome}`);
lines.push(`💸 总支出:${stats.totalExpense}`); lines.push(`总支出:${stats.totalExpense}`);
lines.push(`📝 交易笔数:${stats.transactionCount}`); lines.push(`交易笔数:${stats.transactionCount}`);
lines.push(''); lines.push('');
if (stats.topCategories.length > 0) { if (stats.topCategories.length > 0) {
lines.push('🏷️ 主要支出分类:'); lines.push('主要支出分类:');
for (const cat of stats.topCategories) { for (const cat of stats.topCategories) {
lines.push(` ${cat.category}: ${cat.amount}`); lines.push(` ${cat.category}: ${cat.amount}`);
} }
+8 -29
View File
@@ -1,53 +1,32 @@
import React from 'react'; import React from 'react';
import { Tabs } from 'expo-router'; import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { AppTabBar } from '../../components/layout/AppTabBar';
/** 底部 Tab 导航:首页/交易/报表/导入/规则/设置。 */ /** 底部 Tab 导航:首页/交易/报表/设置 + 中央+(AppTabBar。 */
export default function TabsLayout() { export default function TabsLayout() {
const { theme } = useTheme();
const t = useT(); const t = useT();
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{ headerShown: false }}
headerShown: false, tabBar={(props) => <AppTabBar {...props} />}
tabBarActiveTintColor: theme.colors.accent,
tabBarInactiveTintColor: theme.colors.fgSecondary,
tabBarStyle: {
backgroundColor: theme.colors.bgSecondary,
borderTopColor: theme.colors.border,
},
}}
> >
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{ title: t('tab.home') }}
title: t('tab.home'),
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
}}
/> />
<Tabs.Screen <Tabs.Screen
name="transactions" name="transactions"
options={{ options={{ title: t('tab.transactions') }}
title: t('tab.transactions'),
tabBarIcon: ({ color, size }) => <Ionicons name="list-outline" size={size} color={color} />,
}}
/> />
<Tabs.Screen <Tabs.Screen
name="report" name="report"
options={{ options={{ title: t('tab.report') }}
title: t('tab.report'),
tabBarIcon: ({ color, size }) => <Ionicons name="pie-chart-outline" size={size} color={color} />,
}}
/> />
<Tabs.Screen <Tabs.Screen
name="settings" name="settings"
options={{ options={{ title: t('tab.settings') }}
title: t('tab.settings'),
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
}}
/> />
</Tabs> </Tabs>
); );
+116 -147
View File
@@ -1,20 +1,29 @@
import React, { useEffect, useMemo, useRef } from 'react'; /**
import { Pressable, ScrollView, StyleSheet, Text, View, Alert } from 'react-native'; * 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 { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore'; import { useMetadataStore } from '../../store/metadataStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { SpeedDial } from '../../components/SpeedDial'; import { TransactionCard } from '../../components/transaction/TransactionCard';
import { TransactionCard } from '../../components/TransactionCard'; import { TodoStrip } from '../../components/stats/TodoStrip';
import { AccountTree } from '../../components/AccountTree';
import { TrendLine } from '../../components/charts/TrendLine'; import { TrendLine } from '../../components/charts/TrendLine';
import { calculateNetWorth } from '../../domain/netWorth'; import { calculateNetWorth } from '../../domain/stats/netWorth';
import { groupByMonth } from '../../domain/chartStats'; import { groupByMonth } from '../../domain/stats/chartStats';
import { buildAccountTree } from '../../domain/accountTree'; import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/finance/recurring';
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/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() { export default function HomeScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
@@ -23,28 +32,19 @@ export default function HomeScreen() {
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction); const addTransaction = useLedgerStore(s => s.addTransaction);
const recurringTransactions = useMetadataStore(s => s.recurringTransactions); const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction); 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 transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
const netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]); const netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]);
const monthlyData = useMemo(() => groupByMonth(transactions), [transactions]); const monthlyData = useMemo(() => groupByMonth(transactions), [transactions]);
const currentMonth = useMemo(() => { const currentMonth = useMemo(() => monthlyData.length ? monthlyData[monthlyData.length - 1] : null, [monthlyData]);
if (monthlyData.length === 0) return null;
return monthlyData[monthlyData.length - 1];
}, [monthlyData]);
// 1. 账户树计算
const accountNodes = useMemo(() => {
if (!ledger || !ledger.accounts) return [];
return buildAccountTree(ledger.accounts, transactions, ledger);
}, [ledger, transactions]);
// 2. 周期性记账到期计算 const todayStr = useMemo(() => toDateString(new Date()), []);
const todayStr = useMemo(() => new Date().toISOString().slice(0, 10), []); const dueRecurring = useMemo(() => getDueRecurring(recurringTransactions, todayStr), [recurringTransactions, todayStr]);
const dueRecurring = useMemo(() => {
return getDueRecurring(recurringTransactions, todayStr);
}, [recurringTransactions, todayStr]);
// 周期记账自动触发:启动时自动确认标记了 autoConfirm 的到期项 // 周期记账自动触发:启动时自动确认标记了 autoConfirm 的到期项
const autoConfirmed = useRef(false); const autoConfirmed = useRef(false);
@@ -70,129 +70,86 @@ export default function HomeScreen() {
return () => { isMounted = false; }; return () => { isMounted = false; };
}, [dueRecurring, addTransaction, updateRecurringTransaction]); }, [dueRecurring, addTransaction, updateRecurringTransaction]);
const handleConfirmRecurring = async (rec: any) => { // 月度预算剩余合计(无月度预算则不显示)
try { const budgetRemaining = useMemo(() => {
const draft = instantiateRecurring(rec); const monthlyBudgets = budgets.filter(b => b.period === 'monthly');
await addTransaction(draft); if (monthlyBudgets.length === 0) return null;
const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate); const remainings = monthlyBudgets.map(b => calculateBudgetProgress(b, transactions, todayStr).remaining);
updateRecurringTransaction(rec.id, { nextDueDate }); return remainings.reduce((a, b) => addDecimals([a, b]), '0');
Alert.alert(t('home.recurringSuccess'), t('home.recurringSuccessDesc', { name: rec.name })); }, [budgets, transactions, todayStr]);
} catch (e) {
Alert.alert(t('home.recurringFail'), String(e));
}
};
// 3. 千分位格式化金额 // 最近 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) => { const fmtAmount = (s: string) => {
if (!s || s === '0') return '¥0.00'; if (!s || s === '0') return '¥0.00';
const num = parseFloat(s); const num = parseFloat(s);
if (isNaN(num)) return s; if (isNaN(num)) return s;
const formatted = num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const formatted = num.toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return num >= 0 ? `¥${formatted}` : `${Math.abs(num).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; return num >= 0 ? `¥${formatted}` : `${Math.abs(num).toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}; };
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}> <View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h1.fontFamily }]}> <Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{greeting}</Text>
{t('app.name')} <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>{dateLine}</Text>
</Text>
</View> </View>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}> <FlatList
data={recentTxs}
{/* 净资产 Hero 卡片 */} keyExtractor={(tx) => tx.id}
<Card contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
style={{ ListHeaderComponent={
backgroundColor: theme.colors.accent, <View style={{ gap: theme.spacing.md }}>
borderColor: theme.colors.accentLight, {/* 净资产白卡 */}
shadowColor: theme.colors.accent, <Card>
shadowOpacity: 0.15, <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorthTitle')}</Text>
shadowRadius: 10, <Text style={[theme.typography.display, { color: theme.colors.fgPrimary, marginVertical: 6, fontVariant: ['tabular-nums'] }]}>
}}
>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontSize: 13, fontWeight: '600', fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.netWorthTitle')}
</Text>
<Text style={[theme.typography.h1, { color: '#FFFFFF', fontSize: 32, fontWeight: '800', marginVertical: 8, fontFamily: 'monospace' }]}>
{fmtAmount(netWorth.netWorth)} {fmtAmount(netWorth.netWorth)}
</Text> </Text>
<View style={styles.heroFooter}> <View style={[styles.heroFooter, { borderTopColor: theme.colors.divider }]}>
<View> <View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthExpense')}</Text>
{t('home.assets')} <Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
</Text> -{fmtAmount(currentMonth?.expense.toString() ?? '0')}
<Text style={[theme.typography.h3, { color: '#FFFFFF', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
{fmtAmount(netWorth.assets)}
</Text> </Text>
</View> </View>
<View style={{ alignItems: 'flex-end' }}> <View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthIncome')}</Text>
{t('home.liabilities')} <Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
</Text> +{fmtAmount(currentMonth?.income.toString() ?? '0')}
<Text style={[theme.typography.h3, { color: 'rgba(255,255,255,0.9)', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
{fmtAmount(netWorth.liabilities)}
</Text> </Text>
</View> </View>
</View> {budgetRemaining !== null && (
</Card> <View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.budgetRemaining')}</Text>
{/* Bento 双格排列 */} <Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
{(dueRecurring.length > 0 || currentMonth) && ( {fmtAmount(budgetRemaining)}
<View style={styles.bentoRow}>
{dueRecurring.length > 0 ? (
<Card title={t('home.dueRecurring')} style={styles.bentoCol}>
<View style={styles.recurringList}>
{dueRecurring.slice(0, 2).map(rec => (
<View key={rec.id} style={[styles.recurringItem, { borderBottomColor: theme.colors.divider }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, fontWeight: '700', fontFamily: theme.typography.caption.fontFamily }]} numberOfLines={1}>
{rec.name}
</Text> </Text>
<Pressable
onPress={() => handleConfirmRecurring(rec)}
style={({ pressed }) => [
styles.confirmBtnMini,
{
backgroundColor: theme.colors.bgTertiary,
borderColor: theme.colors.border,
borderRadius: theme.radii.sm,
opacity: pressed ? 0.7 : 1,
},
]}
>
<Text style={{ color: theme.colors.accent, fontSize: 10, fontWeight: '800', textAlign: 'center' }}>
{rec.draft.postings[0]?.amount} CNY
</Text>
</Pressable>
</View>
))}
</View>
</Card>
) : null}
{currentMonth ? (
<Card title={`${currentMonth.month.slice(5)} ${t('home.title')}`} style={styles.bentoCol}>
<View style={styles.bentoStats}>
<View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.income')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
+{fmtAmount(currentMonth.income.toString())}
</Text>
</View>
<View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.expense')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
-{fmtAmount(currentMonth.expense.toString())}
</Text>
</View>
</View>
</Card>
) : null}
</View> </View>
)} )}
</View>
</Card>
<TodoStrip />
{/* 月度趋势 */} {/* 月度趋势 */}
{monthlyData.length > 0 && ( {monthlyData.length > 0 && (
@@ -201,18 +158,34 @@ export default function HomeScreen() {
</Card> </Card>
)} )}
{/* 账户与资产余额树 */} {/* 最近交易标题 */}
{accountNodes.length > 0 && ( {recentTxs.length > 0 && (
<Card title={t('home.accountTree')}> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{t('home.recentTransactions')}</Text>
<AccountTree nodes={accountNodes} />
</Card>
)} )}
</ScrollView> </View>
<SpeedDial actions={[ }
{ key: 'new', icon: 'create-outline', label: t('home.newTransaction'), onPress: () => router.push('/transaction/new') }, renderItem={({ item: tx }) => (
{ key: 'ocr', icon: 'camera-outline', label: t('home.ocrScan'), onPress: () => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } }) }, <TransactionCard key={tx.id} transaction={tx} categoryId={categoryIdFor(tx)} onPress={() => router.push(`/transaction/${tx.id}`)} />
{ key: 'import', icon: 'download-outline', label: t('tab.import'), onPress: () => router.push('/import') }, )}
]} /> 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> </SafeAreaView>
); );
} }
@@ -220,12 +193,8 @@ export default function HomeScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 }, header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 96 }, content: { padding: 16, paddingBottom: 40 },
heroFooter: { flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: 'rgba(255,255,255,0.15)', paddingTop: 10, marginTop: 4 }, heroFooter: { flexDirection: 'row', gap: 16, borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10, marginTop: 4 },
bentoRow: { flexDirection: 'row', gap: 12 }, heroStat: { gap: 2 },
bentoCol: { flex: 1 }, viewAll: { alignItems: 'center', paddingVertical: 6 },
recurringList: { gap: 8 },
recurringItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 6, borderBottomWidth: StyleSheet.hairlineWidth },
confirmBtnMini: { paddingVertical: 4, paddingHorizontal: 8, borderWidth: StyleSheet.hairlineWidth },
bentoStats: { gap: 8 },
}); });
+143 -266
View File
@@ -1,11 +1,10 @@
/** /**
* plan.md5.2 +5.3 * plan.md5.2 +5.3 P4 spec §7.3
* *
* *
* - / * - anchor + //periodNav
* - //// * - ////
* - react-native-view-shot * - AI react-native-view-shot
* - AI generatePlainTextSummary / generateMonthlySummary
*/ */
import React, { useMemo, useRef, useState } from 'react'; import React, { useMemo, useRef, useState } from 'react';
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
@@ -18,20 +17,31 @@ import { useLedgerStore } from '../../store/ledgerStore';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { useTheme, createCommonStyles } from '../../theme'; import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/ui/Button';
import { MonthlyReport } from '../../components/charts/MonthlyReport'; import { BottomSheet } from '../../components/ui/BottomSheet';
import { CategoryPie } from '../../components/charts/CategoryPie'; import { CategoryPie } from '../../components/charts/CategoryPie';
import { CalendarView } from '../../components/CalendarView'; import { CalendarView } from '../../components/stats/CalendarView';
import { TransactionCard } from '../../components/TransactionCard'; import { TransactionCard } from '../../components/transaction/TransactionCard';
import { NetWorthChart } from '../../components/charts/NetWorthChart'; import { NetWorthChart } from '../../components/charts/NetWorthChart';
import { AnnualReport } from '../../components/charts/AnnualReport'; import { AnnualReport } from '../../components/charts/AnnualReport';
import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary'; import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary';
import { BaseOpenAIProvider } from '../../domain/ai'; 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() { export default function ReportScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = createCommonStyles(theme); 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 t = useT();
const router = useRouter(); const router = useRouter();
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
@@ -39,17 +49,14 @@ export default function ReportScreen() {
const aiApiKey = useSettingsStore(s => s.aiApiKey); const aiApiKey = useSettingsStore(s => s.aiApiKey);
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl); const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl);
const aiModel = useSettingsStore(s => s.aiModel); const aiModel = useSettingsStore(s => s.aiModel);
const locale = useSettingsStore(s => s.locale);
// Tab 状态 // 周期 + 单一 anchor 日期(P4:替代旧版年/月/周三套独立切换状态
const [activeTab, setActiveTab] = useState<'weekly' | 'monthly' | 'annual'>('monthly'); const [period, setPeriod] = useState<ReportPeriod>('monthly');
const [anchor, setAnchor] = useState(() => toDateString(new Date()));
// 月份切换状态 // ⋯ 菜单
const now = new Date(); const [menuOpen, setMenuOpen] = useState(false);
const [viewYear, setViewYear] = useState(now.getFullYear());
const [viewMonth, setViewMonth] = useState(now.getMonth() + 1);
// 周切换状态
const [viewWeekDate, setViewWeekDate] = useState(new Date());
// 日历选中日期用于明细展开 // 日历选中日期用于明细展开
const [selectedReportDate, setSelectedReportDate] = useState<string | null>(null); const [selectedReportDate, setSelectedReportDate] = useState<string | null>(null);
@@ -63,116 +70,66 @@ export default function ReportScreen() {
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]); const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
// 周报表的时间范围:计算周一和周日 // anchor 所在周期的起止日期与周期内交易
const weeklyRange = useMemo(() => { const range = useMemo(() => periodRange(anchor, period), [anchor, period]);
const d = new Date(viewWeekDate); const rangeTxs = useMemo(
const day = d.getDay(); // 0 is Sunday, 1-6 is Mon-Sat () => transactions.filter(tx => tx.date >= range.start && tx.date <= range.end),
const diffToMonday = day === 0 ? -6 : 1 - day; [transactions, range],
);
const monday = new Date(d); // 周期收支统计(Income 腿取负、Expenses 腿原值,与旧 weeklyStats/monthlyStats 同算法)
monday.setDate(d.getDate() + diffToMonday); const rangeStats = useMemo(() => {
monday.setHours(0,0,0,0); 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]);
const sunday = new Date(monday); // 周期切换器 label
sunday.setDate(monday.getDate() + 6); const periodLabel = useMemo(() => {
sunday.setHours(23,59,59,999); 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]);
// 计算 ISO 周数 // 净资产趋势的日期序列(基于 anchor 所在月,往前 6 个月)
const date = new Date(monday.getTime());
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);
const week1 = new Date(date.getFullYear(), 0, 4);
const weekNum = 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7);
return {
startStr: monday.toISOString().slice(0, 10),
endStr: sunday.toISOString().slice(0, 10),
label: `${monday.getFullYear()}年第${weekNum}周 (${monday.getMonth()+1}/${monday.getDate()} ~ ${sunday.getMonth()+1}/${sunday.getDate()})`
};
}, [viewWeekDate]);
const changeWeek = (days: number) => {
setSelectedReportDate(null);
const next = new Date(viewWeekDate);
next.setDate(next.getDate() + days);
setViewWeekDate(next);
};
// 净资产趋势的日期序列(基于选中月份,往前 6 个月)
const netWorthDates = useMemo(() => { const netWorthDates = useMemo(() => {
const dates: string[] = []; const dates: string[] = [];
for (let i = 5; i >= 0; i--) { for (let i = 5; i >= 0; i--) {
const d = new Date(viewYear, viewMonth - 1 - i, 1); const d = new Date(Number(anchor.slice(0, 4)), Number(anchor.slice(5, 7)) - 1 - i, 1);
dates.push(d.toISOString().slice(0, 10)); dates.push(toDateString(d));
} }
return dates; return dates;
}, [viewYear, viewMonth]); }, [anchor]);
const prevMonth = () => { const shift = (delta: number) => {
setSelectedReportDate(null); setSelectedReportDate(null);
if (viewMonth === 1) { setViewMonth(12); setViewYear(y => y - 1); } setAnchor(a => shiftAnchor(a, period, delta));
else setViewMonth(m => m - 1);
}; };
const nextMonth = () => {
setSelectedReportDate(null);
if (viewMonth === 12) { setViewMonth(1); setViewYear(y => y + 1); }
else setViewMonth(m => m + 1);
};
// 过滤后的交易
const weeklyTxs = useMemo(() => {
return transactions.filter(t => t.date >= weeklyRange.startStr && t.date <= weeklyRange.endStr);
}, [transactions, weeklyRange]);
const monthlyTxs = useMemo(() => {
const prefix = `${viewYear}-${String(viewMonth).padStart(2, '0')}`;
return transactions.filter(t => t.date.startsWith(prefix));
}, [transactions, viewYear, viewMonth]);
const annualTxs = useMemo(() => {
return transactions.filter(t => t.date.startsWith(String(viewYear)));
}, [transactions, viewYear]);
// 周收支统计
const weeklyStats = useMemo(() => {
let income = 0;
let expense = 0;
for (const t of weeklyTxs) {
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
if (p.account.startsWith('Income')) income += -amt;
if (p.account.startsWith('Expenses')) expense += amt;
}
}
return { income, expense, net: income - expense, count: weeklyTxs.length };
}, [weeklyTxs]);
// 月度收支统计
const monthlyStats = useMemo(() => {
let income = 0;
let expense = 0;
for (const t of monthlyTxs) {
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
if (p.account.startsWith('Income')) income += -amt;
if (p.account.startsWith('Expenses')) expense += amt;
}
}
return { income, expense, net: income - expense, count: monthlyTxs.length };
}, [monthlyTxs]);
// 选中日期的交易明细 // 选中日期的交易明细
const selectedDayTxs = useMemo(() => { const selectedDayTxs = useMemo(() => {
if (!selectedReportDate) return []; if (!selectedReportDate) return [];
return monthlyTxs.filter(t => t.date.slice(0, 10) === selectedReportDate); return rangeTxs.filter(tx => tx.date.slice(0, 10) === selectedReportDate);
}, [monthlyTxs, selectedReportDate]); }, [rangeTxs, selectedReportDate]);
// 千分位格式化金额 // 千分位格式化金额
const localeTag = locale === 'zh' ? 'zh-CN' : 'en-US';
const fmtAmount = (num: number) => { const fmtAmount = (num: number) => {
const formatted = num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const formatted = num.toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return num >= 0 ? `¥${formatted}` : `${Math.abs(num).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; return num >= 0 ? `¥${formatted}` : `${Math.abs(num).toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}; };
// 导出报表为图片 // 导出报表为图片
@@ -190,9 +147,11 @@ export default function ReportScreen() {
} }
}; };
// AI / 纯文本月度总结 // AI / 纯文本月度总结(基于 anchor 所在月)
const handleMonthlySummary = async () => { const handleMonthlySummary = async () => {
const stats = calculateMonthlyStats(transactions, viewYear, viewMonth); const year = Number(anchor.slice(0, 4));
const month = Number(anchor.slice(5, 7));
const stats = calculateMonthlyStats(transactions, year, month);
if (aiEnabled && aiApiKey && aiBaseUrl) { if (aiEnabled && aiApiKey && aiBaseUrl) {
try { try {
const provider = new (class extends BaseOpenAIProvider {})({ const provider = new (class extends BaseOpenAIProvider {})({
@@ -202,7 +161,7 @@ export default function ReportScreen() {
baseUrl: aiBaseUrl, baseUrl: aiBaseUrl,
model: aiModel || 'glm-4-flash', model: aiModel || 'glm-4-flash',
}); });
const result = await generateMonthlySummary(transactions, viewYear, viewMonth, provider); const result = await generateMonthlySummary(transactions, year, month, provider);
setSummaryText(result.summary || generatePlainTextSummary(stats)); setSummaryText(result.summary || generatePlainTextSummary(stats));
} catch { } catch {
setSummaryText(generatePlainTextSummary(stats)); setSummaryText(generatePlainTextSummary(stats));
@@ -213,180 +172,80 @@ export default function ReportScreen() {
setSummaryModal(true); setSummaryModal(true);
}; };
const monthLabel = `${viewYear}-${String(viewMonth).padStart(2, '0')}`; const monthLabel = anchor.slice(0, 7);
const anchorYear = Number(anchor.slice(0, 4));
const anchorMonth = Number(anchor.slice(5, 7));
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}> <View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.report')}</Text> <Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.report')}</Text>
<View style={styles.headerActions}> <Pressable
<Pressable onPress={handleMonthlySummary} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}> onPress={() => setMenuOpen(true)}
<Ionicons name="sparkles-outline" size={22} color={theme.colors.accent} /> 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> </Pressable>
<Pressable onPress={handleExportImage} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="share-outline" size={22} color={theme.colors.accent} />
</Pressable>
</View>
</View> </View>
{/* Tab 选择切换栏 */} {/* Tab 选择切换栏(统一分段选择器) */}
<View style={[styles.tabContainer, { borderBottomColor: theme.colors.divider }]}> <SegmentedControl
{([ options={[
{ key: 'weekly', label: t('report.tabWeekly') }, { key: 'weekly', label: t('report.tabWeekly') },
{ key: 'monthly', label: t('report.tabMonthly') }, { key: 'monthly', label: t('report.tabMonthly') },
{ key: 'annual', label: t('report.tabAnnual') } { key: 'annual', label: t('report.tabAnnual') },
] as const).map(tab => ( ]}
<Pressable value={period}
key={tab.key} onChange={key => {
onPress={() => { setPeriod(key as ReportPeriod);
setActiveTab(tab.key);
setSelectedReportDate(null); setSelectedReportDate(null);
}} }}
style={[ />
styles.tabBtn,
activeTab === tab.key && { borderBottomColor: theme.colors.accent }
]}
>
<Text
style={[
theme.typography.body,
{
color: activeTab === tab.key ? theme.colors.accent : theme.colors.fgSecondary,
fontWeight: activeTab === tab.key ? '700' : '400',
paddingVertical: 10
}
]}
>
{tab.label}
</Text>
</Pressable>
))}
</View>
{/* 日期选择切换器 */} {/* 统一周期切换器 */}
{activeTab === 'weekly' && ( <PeriodSwitcher label={periodLabel} onPrev={() => shift(-1)} onNext={() => shift(1)} />
<View style={styles.monthSwitcher}>
<Pressable onPress={() => changeWeek(-7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{weeklyRange.label}
</Text>
<Pressable onPress={() => changeWeek(7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable>
</View>
)}
{activeTab === 'monthly' && (
<View style={styles.monthSwitcher}>
<Pressable onPress={prevMonth} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{monthLabel}
</Text>
<Pressable onPress={nextMonth} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable>
</View>
)}
{activeTab === 'annual' && (
<View style={styles.monthSwitcher}>
<Pressable onPress={() => setViewYear(y => y - 1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{viewYear}
</Text>
<Pressable onPress={() => setViewYear(y => y + 1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable>
</View>
)}
<ScrollView <ScrollView
ref={scrollRef} ref={scrollRef}
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]} contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
> >
{/* 数据空值判断 */} {/* 数据空值判断 */}
{((activeTab === 'weekly' && weeklyTxs.length === 0) || {rangeTxs.length === 0 ? (
(activeTab === 'monthly' && monthlyTxs.length === 0) || <EmptyState icon="bar-chart-outline" title={t('report.title')} description={t('report.empty')} />
(activeTab === 'annual' && annualTxs.length === 0)) ? (
<Card title={t('report.title')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{t('report.empty')}
</Text>
</Card>
) : ( ) : (
<> <>
{/* 周报表看板 */} {/* 周/月收支看板 */}
{activeTab === 'weekly' && ( {period !== 'annual' && (
<> <>
<Card title={t('report.weeklyTitle')}> <RangeStatsCard
<View style={styles.statsRow}> period={period === 'weekly' ? 'weekly' : 'monthly'}
<View style={styles.statItem}> incomeText={fmtAmount(rangeStats.income)}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyIncome')}</Text> expenseText={fmtAmount(-rangeStats.expense)}
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.income)}</Text> netText={fmtAmount(rangeStats.net)}
</View> count={rangeStats.count}
<View style={styles.statItem}> />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>-{fmtAmount(weeklyStats.expense)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.net)}</Text>
</View>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('report.weeklyStats', { count: weeklyStats.count })}
</Text>
</Card>
<CategoryPie transactions={weeklyTxs} /> <CategoryPie transactions={rangeTxs} />
</>
)}
{/* 月度报表看板 */} {/* 月 Tab 专属:日历 + 选中日期明细 + 净资产趋势 */}
{activeTab === 'monthly' && ( {period === 'monthly' && (
<> <>
<Card title={`${monthLabel} 收支报告`}>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyIncome')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.income)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>-{fmtAmount(monthlyStats.expense)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.net)}</Text>
</View>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('report.monthlyStats', { count: monthlyStats.count })}
</Text>
</Card>
<CategoryPie transactions={monthlyTxs} />
<CalendarView <CalendarView
transactions={transactions} transactions={transactions}
year={viewYear} year={anchorYear}
month={viewMonth} month={anchorMonth}
onDayPress={setSelectedReportDate} onDayPress={setSelectedReportDate}
/> />
{selectedReportDate && selectedReportDate.startsWith(monthLabel) && ( {selectedReportDate && selectedReportDate.startsWith(monthLabel) && (
<Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}> <Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}>
{selectedDayTxs.map(t => ( {selectedDayTxs.map(tx => (
<TransactionCard <TransactionCard
key={t.id} key={tx.id}
transaction={t} transaction={tx}
onPress={() => router.push(`/transaction/${t.id}`)} onPress={() => router.push(`/transaction/${tx.id}`)}
/> />
))} ))}
{selectedDayTxs.length === 0 && ( {selectedDayTxs.length === 0 && (
@@ -400,15 +259,39 @@ export default function ReportScreen() {
<NetWorthChart transactions={transactions} dates={netWorthDates} /> <NetWorthChart transactions={transactions} dates={netWorthDates} />
</> </>
)} )}
</>
)}
{/* 年度报表看板 */} {/* 年度报表看板 */}
{activeTab === 'annual' && ( {period === 'annual' && (
<AnnualReport transactions={transactions} year={viewYear} /> <AnnualReport transactions={transactions} year={anchorYear} />
)} )}
</> </>
)} )}
</ScrollView> </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 总结弹窗 */} {/* AI 总结弹窗 */}
<Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}> <Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}>
<View style={commonStyles.modalOverlay}> <View style={commonStyles.modalOverlay}>
@@ -434,13 +317,7 @@ export default function ReportScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
headerActions: { flexDirection: 'row', gap: 16 }, iconBtn: {},
iconBtn: { padding: 4 },
monthSwitcher: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20, paddingBottom: 12 },
monthBtn: { width: 32, height: 32, borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center' },
content: { padding: 16, paddingBottom: 96 }, content: { padding: 16, paddingBottom: 96 },
tabContainer: { flexDirection: 'row', justifyContent: 'space-around', borderBottomWidth: StyleSheet.hairlineWidth, paddingHorizontal: 16, marginBottom: 8 }, menuRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14 },
tabBtn: { flex: 1, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' },
statItem: { alignItems: 'center', gap: 4 },
}); });
+126 -52
View File
@@ -1,72 +1,114 @@
import React from 'react'; import React, { useMemo } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { SectionList, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, type Href } from 'expo-router'; import { useRouter, type Href } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; 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() { export default function SettingsScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter(); const router = useRouter();
const renderNavLink = (icon: keyof typeof Ionicons.glyphMap, label: string, path: Href) => ( // 使用 useMemo 缓存分组数据,避免每次渲染重新创建
<Pressable const sections: NavSection[] = useMemo(() => [
onPress={() => router.push(path)} {
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" accessibilityRole="link"
accessibilityLabel={label} accessibilityLabel={item.label}
style={({ pressed }) => [ style={[
styles.navRow, styles.navRow,
{ {
borderBottomColor: theme.colors.divider, borderBottomColor: theme.colors.divider,
opacity: pressed ? 0.6 : 1, 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,
}, },
]} ]}
> >
<Ionicons name={icon} size={20} color={theme.colors.accent} /> <View style={styles.navIcon}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 12, fontFamily: theme.typography.body.fontFamily }]}> <Ionicons name={item.icon} size={20} color={theme.colors.accent} />
{label} </View>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{item.label}
</Text> </Text>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} /> <Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
</Pressable> </Touchable>
); );
};
return ( const AboutFooter = () => (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <View style={{ marginTop: theme.spacing.md }}>
<View style={styles.header}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontWeight: '600', letterSpacing: 0.3, marginBottom: 6 }]}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('settings.title')}</Text> {t('settings.aboutTitle')}
</View> </Text>
<View style={[styles.aboutCard, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, borderColor: theme.colors.border }]}>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 数据与基础配置跳转 */}
<Card title={t('settings.dataManagement')}>
<View style={styles.listGroup}>
{renderNavLink('git-branch-outline', t('tab.rules'), '/rules')}
{renderNavLink('wallet-outline', t('account.title'), '/account')}
{renderNavLink('list', t('settings.categories'), '/category')}
{renderNavLink('pricetags', t('settings.tags'), '/tag')}
{renderNavLink('pie-chart', t('settings.budgets'), '/budget')}
{renderNavLink('repeat-outline', t('settings.recurringTitle'), '/recurring')}
{renderNavLink('card-outline', t('settings.creditCards'), '/credit-card' as Href)}
{renderNavLink('text-outline', t('remark.title'), '/remark-template' as Href)}
</View>
</Card>
{/* 系统及高级配置跳转 */}
<Card title={t('settings.systemFeaturesTitle')}>
<View style={styles.listGroup}>
{renderNavLink('cog-outline', t('settings.aiSettingsTitle'), '/settings/ai')}
{renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)}
{renderNavLink('cloud-upload-outline', t('settings.syncSettingsTitle'), '/settings/sync')}
{renderNavLink('phone-portrait-outline', t('settings.preferencesTitle'), '/settings/preferences')}
{renderNavLink('flash-outline', t('automation.title'), '/automation' as Href)}
</View>
</Card>
{/* 关于 */}
<Card title={t('settings.aboutTitle')}>
<View style={styles.aboutRow}> <View style={styles.aboutRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{t('app.name')} {t('app.name')}
@@ -78,8 +120,25 @@ export default function SettingsScreen() {
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, lineHeight: 18 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, lineHeight: 18 }]}>
{t('app.tagline')} {t('app.tagline')}
</Text> </Text>
</Card> </View>
</ScrollView> </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> </SafeAreaView>
); );
} }
@@ -87,8 +146,23 @@ export default function SettingsScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 }, header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 64 }, content: { paddingHorizontal: 16, paddingBottom: 64 },
listGroup: { borderRadius: 6, overflow: 'hidden' }, navRow: {
navRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 12, borderBottomWidth: 1 }, 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' }, aboutRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
}); });
+149 -154
View File
@@ -1,71 +1,121 @@
/** /**
* plan.md1.4 * spec §7.2 + 线
* * chip FilterSheet//
* : * // + /
* - SearchBar (narration/payee) * /settings/diagnostics
* - (///)
* - useSearch
* - TransactionCard +
* -
*/ */
import React, { useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { Pressable, SectionList, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useLedgerStore } from '../../store/ledgerStore'; 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 { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { SearchBar } from '../../components/ui/SearchBar';
import { SearchBar } from '../../components/SearchBar'; import { FilterSheet, type AdvancedFilterValues } from '../../components/form/FilterSheet';
import { TransactionCard } from '../../components/TransactionCard'; import { SwipeableTransactionCard } from '../../components/transaction/SwipeableTransactionCard';
import { Ionicons } from '@expo/vector-icons'; import { groupTransactionsByDate } from '../../components/transaction/transactionGroups';
import { useSearch, type SearchFilters } from '../../hooks/useSearch'; 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'; type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';
const EMPTY_FILTERS: AdvancedFilterValues = { account: '', dateFrom: '', dateTo: '', amountMin: '', amountMax: '' };
export default function TransactionsScreen() { export default function TransactionsScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = createCommonStyles(theme); const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT(); const t = useT();
const { showToast } = useToast();
const router = useRouter(); const router = useRouter();
const ledger = useLedgerStore(s => s.ledger); 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 [keyword, setKeyword] = useState('');
const [direction, setDirection] = useState<DirectionFilter>('all'); const [direction, setDirection] = useState<DirectionFilter>('all');
const [showDiagnostics, setShowDiagnostics] = useState(false); const [filterSheetOpen, setFilterSheetOpen] = useState(false);
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); const [advanced, setAdvanced] = useState<AdvancedFilterValues>(EMPTY_FILTERS);
const [accountFilter, setAccountFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
// 性能说明:每次 ledger 变化时全量重排序 O(n log n)。
// 当前可接受:移动端账本通常 < 5000 笔,排序耗时 < 10ms。
// 若未来数据量增大,可考虑:
// 1. 在 ledgerStore 层维护已排序的 transactions 数组
// 2. 新增交易时使用二分插入而非全量重排
const allTransactions = useMemo(() => { const allTransactions = useMemo(() => {
if (!ledger?.transactions) return []; if (!ledger?.transactions) return [];
return ledger.transactions return ledger.transactions
.map((tx, idx) => ({ tx, idx })) .map((tx, idx) => ({ tx, idx }))
.sort((a, b) => { .sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
const dateCompare = b.tx.date.localeCompare(a.tx.date);
if (dateCompare !== 0) return dateCompare;
return b.idx - a.idx;
})
.map(item => item.tx); .map(item => item.tx);
}, [ledger]); }, [ledger]);
const allAccounts = useMemo(() => {
const accts = new Set<string>();
for (const tx of allTransactions) {
for (const p of tx.postings) accts.add(p.account);
}
return Array.from(accts).sort();
}, [allTransactions]);
const filters: SearchFilters = useMemo(() => ({ const filters: SearchFilters = useMemo(() => ({
keyword: keyword || undefined, keyword: keyword || undefined,
direction: direction === 'all' ? undefined : direction, direction: direction === 'all' ? undefined : direction,
account: accountFilter || undefined, account: advanced.account || undefined,
dateFrom: dateFrom || undefined, dateFrom: advanced.dateFrom || undefined,
dateTo: dateTo || undefined, dateTo: advanced.dateTo || undefined,
}), [keyword, direction, accountFilter, dateFrom, dateTo]); amountMin: advanced.amountMin || undefined,
amountMax: advanced.amountMax || undefined,
}), [keyword, direction, advanced]);
const filtered = useSearch(allTransactions, filters); 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 }[] = [ const directionTabs: { key: DirectionFilter; label: string }[] = [
{ key: 'all', label: t('transactions.filterAll') }, { key: 'all', label: t('transactions.filterAll') },
@@ -80,150 +130,93 @@ export default function TransactionsScreen() {
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.transactions')}</Text> <Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.transactions')}</Text>
</View> </View>
<SearchBar <SearchBar value={keyword} onChangeText={setKeyword} placeholder={t('transactions.searchPlaceholder')} onSubmit={() => addRecentSearch(keyword)} />
value={keyword} {keyword.trim() === '' && recentSearches.length > 0 && (
onChangeText={setKeyword} <View style={[{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 8, paddingHorizontal: 16, marginBottom: 8 }]}>
placeholder={t('transactions.searchPlaceholder')} <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>
<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> </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>
)}
{/* 高级筛选切换 */} {/* 方向筛选 + 高级筛选入口 */}
<View style={[styles.filterRow, { paddingBottom: 0 }]}> <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 <Pressable
onPress={() => setShowAdvancedFilters(!showAdvancedFilters)} onPress={() => setFilterSheetOpen(true)}
style={({ pressed }) => [ style={[commonStyles.chip, advancedActive && commonStyles.chipActive, styles.filterBtn]}
commonStyles.chip, accessibilityRole="button"
showAdvancedFilters && commonStyles.chipActive, accessibilityLabel={t('transactions.filter')}
{ opacity: pressed ? 0.8 : 1 },
]}
> >
<Text style={[ <Ionicons name="options-outline" size={14} color={advancedActive ? theme.colors.fgInverse : theme.colors.fgSecondary} />
commonStyles.chipText, <Text style={[commonStyles.chipText, advancedActive && commonStyles.chipTextActive]}>{t('transactions.filter')}</Text>
showAdvancedFilters && commonStyles.chipTextActive,
{ fontSize: 12 },
]}>
{t('transactions.advancedFilter')}
</Text>
</Pressable> </Pressable>
</View> </View>
{/* 高级筛选面板 */} <SectionList
{showAdvancedFilters && ( sections={sections}
<View style={{ paddingHorizontal: 16, paddingBottom: 8, gap: 6 }}> keyExtractor={(item, index) => `${item.id}-${index}`}
{/* 账户筛选 */}
<TextInput
value={accountFilter}
onChangeText={setAccountFilter}
placeholder={t('transactions.accountFilterPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 日期范围 */}
<View style={{ flexDirection: 'row', gap: 8 }}>
<TextInput
value={dateFrom}
onChangeText={setDateFrom}
placeholder={t('transactions.dateFrom')}
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1 }]}
/>
<TextInput
value={dateTo}
onChangeText={setDateTo}
placeholder={t('transactions.dateTo')}
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1 }]}
/>
</View>
</View>
)}
<FlatList
data={filtered}
keyExtractor={(item) => item.id}
renderItem={({ item: tx }) => ( renderItem={({ item: tx }) => (
<TransactionCard <SwipeableTransactionCard
transaction={tx} transaction={tx}
categoryId={categoryIdFor(tx)}
showDate={false}
highlight={keyword}
onPress={() => router.push(`/transaction/${tx.id}`)} 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={ ListHeaderComponent={
<View> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingBottom: 8 }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingHorizontal: 16, paddingBottom: 8 }]}>
{filtered.length === allTransactions.length {filtered.length === allTransactions.length
? t('transactions.countTotal', { count: filtered.length }) ? t('transactions.countTotal', { count: filtered.length })
: t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })} : t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })}
</Text> </Text>
</View>
} }
ListEmptyComponent={ ListEmptyComponent={
<Card title={t('transactions.noMatchTitle')}> <EmptyState
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}> icon={allTransactions.length === 0 ? 'receipt-outline' : 'search-outline'}
{allTransactions.length === 0 title={t('transactions.noMatchTitle')}
? t('transactions.noMatchEmpty') description={allTransactions.length === 0 ? t('transactions.noMatchEmpty') : t('transactions.noMatchFiltered')}
: t('transactions.noMatchFiltered')}
</Text>
</Card>
}
ListFooterComponent={
<View>
{/* 诊断(折叠) */}
<Pressable
onPress={() => setShowDiagnostics(!showDiagnostics)}
style={[styles.diagToggle, { flexDirection: 'row', alignItems: 'center', gap: 4 }]}
>
<Ionicons
name={showDiagnostics ? 'chevron-down-outline' : 'chevron-forward-outline'}
size={14}
color={theme.colors.fgSecondary}
/> />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('diagnostics.title')}
</Text>
</Pressable>
{showDiagnostics && (
<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 }]}>
{t('diagnostics.hint')}
</Text>
</Card>
)}
</View>
} }
contentContainerStyle={styles.content} contentContainerStyle={styles.content}
stickySectionHeadersEnabled={false}
initialNumToRender={10} initialNumToRender={10}
maxToRenderPerBatch={10} maxToRenderPerBatch={10}
windowSize={5} windowSize={5}
/> />
<FilterSheet
visible={filterSheetOpen}
onClose={() => setFilterSheetOpen(false)}
values={advanced}
onChange={setAdvanced}
onReset={() => setAdvanced(EMPTY_FILTERS)}
/>
</SafeAreaView> </SafeAreaView>
); );
} }
@@ -231,7 +224,9 @@ export default function TransactionsScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 }, header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, 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 }, content: { padding: 16, paddingTop: 8 },
diagToggle: { paddingVertical: 12, alignItems: 'center' }, sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8 },
}); });
+1
View File
@@ -67,6 +67,7 @@ export function ErrorScreen({ error, resetError }: ErrorBoundaryProps) {
</Text> </Text>
{data.stack && ( {data.stack && (
<View style={[styles.stackBox, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}> <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}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]} numberOfLines={10}>
{data.stack} {data.stack}
</Text> </Text>
+166 -151
View File
@@ -3,47 +3,73 @@ import { StatusBar } from 'expo-status-bar';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { AppState, DeviceEventEmitter, Platform, Text, View } from 'react-native'; import { AppState, DeviceEventEmitter, Platform, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { ThemeProvider, useTheme } from '../theme'; import { ThemeProvider, useTheme } from '../theme';
import { useLedgerStore } from '../store/ledgerStore'; import { useLedgerStore } from '../store/ledgerStore';
import { useImportStore } from '../store/importStore'; import { useImportStore } from '../store/importStore';
import { useSettingsStore } from '../store/settingsStore'; import { useSettingsStore } from '../store/settingsStore';
import { useMetadataStore } from '../store/metadataStore'; import { useMetadataStore } from '../store/metadataStore';
import { useAutomationStore } from '../store/automationStore'; import { parseDecimal } from '../domain/core/decimal';
import { FileSystemBackend } from '../services/fileSystemBackend'; import { FileSystemBackend } from '../services/data/fileSystemBackend';
import { useNumpadUiStore } from '../store/numpadUiStore';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { LockScreen } from '../components/LockScreen'; import { LockScreen } from '../components/layout/LockScreen';
import { NumpadSheetHost } from '../components/form/NumpadSheetHost';
import { ToastProvider } from '../components/ui/Toast';
import { OnboardingScreen } from './_onboarding'; import { OnboardingScreen } from './_onboarding';
import { setupDeepLinking } from '../services/deepLink'; import { setupDeepLinking } from '../services/deepLink';
import { initPersistence } from '../store/storePersistence'; import { initPersistence, flushPersistence } from '../store/storePersistence';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { parseNotification } from '../services/notification'; import { parseNotification } from '../services/notification';
import { parseSms } from '../services/sms'; import { parseSms } from '../services/sms';
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts } from '../services/automationPipeline'; import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts, loadProcessedTxKeys, ensureBillingListenerRegistered, removeBillingListener } from '../services/automation/automationPipeline';
import { getAccessibilityBridge } from '../services/accessibilityBridge'; import { getAccessibilityBridge } from '../services/automation/accessibilityBridge';
import { pushFloatingUiConfig } from '../services/automation/floatingUiConfig';
import { ensureOcrModels } from '../services/ocr/modelDownloader';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { import { sanitizeLogText } from '../utils/sanitize';
useFonts, import { expoLogBackend } from '../services/logBackend';
Quicksand_400Regular,
Quicksand_500Medium,
Quicksand_600SemiBold,
Quicksand_700Bold,
} from '@expo-google-fonts/quicksand';
import {
Caveat_400Regular,
Caveat_500Medium,
Caveat_600SemiBold,
Caveat_700Bold,
} from '@expo-google-fonts/caveat';
let lastSignature = ''; let lastSignature = '';
let lastProcessedTime = 0; 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 用内置)。 */ /** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */
const SAMPLE_LEDGER = { const SAMPLE_LEDGER = {
path: 'main.bean', path: 'main.bean',
content: `option "operating_currency" "CNY" content: `option "operating_currency" "CNY"
include "mobile.bean"
`, `,
}; };
@@ -53,27 +79,23 @@ function AppShell() {
const t = useT(); const t = useT();
const router = useRouter(); const router = useRouter();
const [fontsLoaded, fontError] = useFonts({
Quicksand_400Regular,
Quicksand_500Medium,
Quicksand_600SemiBold,
Quicksand_700Bold,
Caveat_400Regular,
Caveat_500Medium,
Caveat_600SemiBold,
Caveat_700Bold,
});
const loadLedger = useLedgerStore(s => s.loadLedger); const loadLedger = useLedgerStore(s => s.loadLedger);
const setContext = useImportStore(s => s.setContext); const setContext = useImportStore(s => s.setContext);
const appLockEnabled = useSettingsStore(s => s.appLockEnabled); const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
const onboardingCompleted = useSettingsStore(s => s.onboardingCompleted);
const setOnboardingCompleted = useSettingsStore(s => s.setOnboardingCompleted); const setOnboardingCompleted = useSettingsStore(s => s.setOnboardingCompleted);
const [phase, setPhase] = useState<'loading' | 'onboarding' | 'locked' | 'ready'>('loading'); const [phase, setPhase] = useState<'loading' | 'onboarding' | 'locked' | 'ready'>('loading');
const [privacyOverlay, setPrivacyOverlay] = useState(false); const [privacyOverlay, setPrivacyOverlay] = useState(false);
const lastMainFileRef = useRef<{ modificationTime?: number; size?: number } | null>(null); const lastMainFileRef = useRef<{ modificationTime?: number; size?: number } | null>(null);
const persistenceInitRef = useRef(false);
const lastCheckTimeRef = useRef(0);
const checkAndReloadLedger = useCallback(async () => { const checkAndReloadLedger = useCallback(async () => {
// 节流:2 秒内不重复检查
const now = Date.now();
if (now - lastCheckTimeRef.current < 2000) return;
lastCheckTimeRef.current = now;
const mainPath = FileSystem.documentDirectory + 'main.bean'; const mainPath = FileSystem.documentDirectory + 'main.bean';
try { try {
const info = await FileSystem.getInfoAsync(mainPath); const info = await FileSystem.getInfoAsync(mainPath);
@@ -105,32 +127,30 @@ function AppShell() {
}, [loadLedger, setContext]); }, [loadLedger, setContext]);
useEffect(() => { useEffect(() => {
if (persistenceInitRef.current) return;
persistenceInitRef.current = true;
// 1. 初始化持久化并还原数据 // 1. 初始化持久化并还原数据
initPersistence().then(async () => { initPersistence().then(async () => {
const mainPath = FileSystem.documentDirectory + 'main.bean'; // 初始化磁盘日志持久化系统
const mobilePath = FileSystem.documentDirectory + 'mobile.bean'; logger.initFileBackend(expoLogBackend).catch(e => {
logger.warn('layout', '磁盘日志初始化失败', e);
});
try { // 加载已处理的交易键(跨会话去重)
// 一次性合并迁移:将旧的 mobile.bean 内容追加到 main.bean 并物理删除它 await loadProcessedTxKeys();
const mobileInfo = await FileSystem.getInfoAsync(mobilePath);
if (mobileInfo.exists) { // OCR 模型改为按需下载(用户触发 OCR 时才下载,不在启动时自动下载 ~30MB)
const mobileContent = await FileSystem.readAsStringAsync(mobilePath); // 若已有模型则静默初始化原生引擎
if (mobileContent.trim()) { const settings = useSettingsStore.getState();
const mainInfo = await FileSystem.getInfoAsync(mainPath); if (settings.ocrModelDir) {
let mainContent = ''; ensureOcrModels().catch((e) => { logger.warn('layout', '[OCR模型] 静默初始化失败', e); });
if (mainInfo.exists) {
mainContent = await FileSystem.readAsStringAsync(mainPath);
}
const mergedContent = `${mainContent.trimEnd()}\n\n${mobileContent.trim()}\n`.trim();
await FileSystem.writeAsStringAsync(mainPath, mergedContent);
logger.info('layout', '已成功将旧 mobile.bean 交易追加合并至 main.bean');
}
await FileSystem.deleteAsync(mobilePath, { idempotent: true });
}
} catch (e) {
logger.error('layout', '合并旧 mobile.bean 数据失败', e);
} }
const mainPath = FileSystem.documentDirectory + 'main.bean';
// 架构说明:main.bean 作为主账本文件,包含所有 Beancount 指令(open/close/option/include 等),
// App 直接对其进行读写与解析。
// 2. 检查并读取本地真实主账本 // 2. 检查并读取本地真实主账本
FileSystem.getInfoAsync(mainPath).then(async (info) => { FileSystem.getInfoAsync(mainPath).then(async (info) => {
let content = SAMPLE_LEDGER.content; let content = SAMPLE_LEDGER.content;
@@ -147,6 +167,14 @@ function AppShell() {
const completed = useSettingsStore.getState().onboardingCompleted; const completed = useSettingsStore.getState().onboardingCompleted;
const locked = useSettingsStore.getState().appLockEnabled; 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) { if (!completed) {
setPhase('onboarding'); setPhase('onboarding');
} else if (locked) { } else if (locked) {
@@ -156,7 +184,7 @@ function AppShell() {
} }
}).catch((e) => { }).catch((e) => {
// 加载失败时仍进入引导流程(而非跳过),让用户有机会重新导入账本 // 加载失败时仍进入引导流程(而非跳过),让用户有机会重新导入账本
console.warn('[layout] Ledger load failed:', e); logger.warn('layout', `Ledger load failed: ${e instanceof Error ? e.message : String(e)}`);
setPhase('onboarding'); setPhase('onboarding');
}); });
}); });
@@ -168,7 +196,7 @@ function AppShell() {
if (phase === 'ready') { if (phase === 'ready') {
const cleanup = setupDeepLinking((action) => { const cleanup = setupDeepLinking((action) => {
if (action.type === 'add-transaction') { if (action.type === 'add-transaction') {
router.push('/transaction/new'); useNumpadUiStore.getState().open();
} else if (action.type === 'open-tab') { } else if (action.type === 'open-tab') {
if (action.tab === 'home') router.push('/(tabs)'); if (action.tab === 'home') router.push('/(tabs)');
else if (action.tab === 'transactions') router.push('/(tabs)/transactions'); else if (action.tab === 'transactions') router.push('/(tabs)/transactions');
@@ -178,7 +206,7 @@ function AppShell() {
} else if (action.type === 'import-csv') { } else if (action.type === 'import-csv') {
router.push('/import'); router.push('/import');
} else if (action.type === 'ocr-camera' || action.type === 'ocr-image') { } else if (action.type === 'ocr-camera' || action.type === 'ocr-image') {
router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } }); useNumpadUiStore.getState().open({ autoOcr: true });
} else if (action.type === 'voice-input') { } else if (action.type === 'voice-input') {
router.push('/(tabs)/settings'); router.push('/(tabs)/settings');
} }
@@ -193,24 +221,29 @@ function AppShell() {
useEffect(() => { useEffect(() => {
if (phase !== 'ready' || Platform.OS !== 'android') return; if (phase !== 'ready' || Platform.OS !== 'android') return;
// 注册全局 billingConfirmed 监听器(浮窗确认入账回调)
ensureBillingListenerRegistered();
const subscriptions = [ const subscriptions = [
DeviceEventEmitter.addListener('billingNotification', (event) => { DeviceEventEmitter.addListener('billingNotification', (event) => {
try { try {
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}, 内容: ${event.text}`); const safeText = sanitizeLogText(event.text || '');
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}`, { raw: safeText });
const bill = parseNotification(event); const bill = parseNotification(event);
if (bill) { if (bill) {
logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${event.text}`); logger.debug('layout', `通知解析成功,进入账单管道`);
handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`); handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`);
} else { } else {
logger.debug('layout', `通知未匹配为账单: [${event.title}] ${event.text}`); logger.debug('layout', `通知未匹配为账单: [${event.title}]`, { raw: safeText });
} }
} catch (e) { } catch (e) {
logger.error('layout', '通知解析失败', e); logger.error('layout', '[通知] 解析失败', e);
} }
}), }),
DeviceEventEmitter.addListener('billingSms', (event) => { DeviceEventEmitter.addListener('billingSms', (event) => {
try { try {
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}, 内容: ${event.body}`); const safeBody = sanitizeLogText(event.body || '');
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}`, { raw: safeBody });
const smsEvent = { const smsEvent = {
sender: event.sender || event.address || '', sender: event.sender || event.address || '',
body: event.body, body: event.body,
@@ -218,139 +251,111 @@ function AppShell() {
}; };
const bill = parseSms(smsEvent); const bill = parseSms(smsEvent);
if (bill) { if (bill) {
logger.debug('layout', `短信解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始短信: ${event.body}`); logger.debug('layout', `短信解析成功,进入账单管道`);
handleIncomingBillEvent('sms', bill, event.body); handleIncomingBillEvent('sms', bill, event.body);
} else { } else {
logger.debug('layout', `短信未匹配为账单: ${event.body}`); logger.debug('layout', `短信未匹配为账单`, { raw: safeBody });
} }
} catch (e) { } catch (e) {
logger.error('layout', '短信解析失败', e); logger.error('layout', '[短信] 解析失败', e);
} }
}), }),
DeviceEventEmitter.addListener('billingScreenshot', (event) => { DeviceEventEmitter.addListener('billingScreenshot', async (event) => {
try {
logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`); logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
processScreenshotEvent(event).catch((e: unknown) => { await processScreenshotEvent(event);
logger.error('layout', '截图 OCR 处理失败', e); } 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', (res) => { DeviceEventEmitter.addListener('billingOpenApp', async (event) => {
try { try {
logger.info('layout', `收到原生悬浮窗跳转 App 请求: ${JSON.stringify(res)}`); const res = event as NativeOpenAppEvent;
if (res.draftId) { 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 { try {
const { pendingDrafts } = require('../services/automationPipeline'); parseDecimal(amt);
pendingDrafts.delete(res.draftId); } catch {
} catch (err) { if (amt.length > 0) {
// 忽略 logger.warn('layout', 'billingOpenApp 金额格式错误', amt);
}
}
const amt = res.amount || '0';
const currency = 'CNY';
let postings: any[] = [];
if (res.direction === 'expense') {
postings = [
{ account: res.account, amount: `-${amt}`, currency },
{ account: res.category, amount: amt, currency }
];
} else if (res.direction === 'income') {
postings = [
{ account: res.account, amount: amt, currency },
{ account: res.category, amount: `-${amt}`, currency }
];
} else { } else {
postings = [ logger.warn('layout', 'billingOpenApp 金额无效', amt);
{ account: res.account, amount: `-${amt}`, currency },
{ account: res.category, amount: amt, currency }
];
} }
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 = { const draft = {
date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0], date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0],
payee: res.merchant || undefined, payee: res.merchant || undefined,
narration: res.narration || '', narration: res.narration || '',
postings postings
}; };
router.push({ useNumpadUiStore.getState().open({ draftJson: JSON.stringify(draft) });
pathname: '/transaction/new',
params: { draftJson: JSON.stringify(draft) }
});
} catch (e) { } catch (e) {
logger.error('layout', '处理悬浮窗跳转 App 失败', e); logger.error('layout', '[悬浮窗跳转] 处理失败', e);
} }
}), }),
DeviceEventEmitter.addListener('billingPageRemembered', (event) => { DeviceEventEmitter.addListener('billingPageRemembered', (event) => {
logger.info('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`); logger.debug('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`, { texts: (event.texts ?? []).map(sanitizeLogText) });
logger.info('layout', `[无障碍调试] 记住该页面时捕获的所有文本内容: ${JSON.stringify(event.texts)}`);
}), }),
DeviceEventEmitter.addListener('billingDebugNodes', async (event) => { DeviceEventEmitter.addListener('billingDebugNodes', async (event) => {
const { package: pkg, activity, texts, isManual } = event; const { package: pkg, activity, texts, isManual } = event;
const sigKey = `${pkg}|${activity}`;
logger.info('layout', `[无障碍监听] 页面特征信号: ${sigKey}`); // 仅处理手动触发(悬浮球点击),自动监听已移除(微信 8.0.52+ 混淆节点文本,自动监听无意义)
logger.info('layout', `[无障碍监听] 页面提取到的文本内容: ${JSON.stringify(texts)}`); if (!isManual) return;
const sigKey = `${pkg}|${activity}`;
logger.info('layout', `[无障碍识别] 手动触发 | 页面: ${sigKey} | 节点数: ${texts?.length ?? 0}`, { texts: (texts ?? []).map(sanitizeLogText) });
try { try {
const bridge = getAccessibilityBridge(); const bridge = getAccessibilityBridge();
if (!isManual) { if (!texts || texts.length === 0) {
if (bridge) { logger.info('layout', '[无障碍识别] 节点文本为空(可能是微信等混淆应用),降级触发 OCR 截图识别');
const signatures = await bridge.getPageSignatures(); bridge?.triggerManualOcr();
const isWhitelisted = signatures.some(s => s.signature === sigKey);
if (!isWhitelisted) {
logger.info('layout', `[无障碍监听] 页面 ${sigKey} 未在自动记账白名单中,拒绝弹窗`);
return; return;
} }
}
// 防抖:3秒内避免对同一页面重复触发记账/截图 // 尝试直接文本解析(传递完整文本,非截断)
if (sigKey === lastSignature && Date.now() - lastProcessedTime < 3000) {
return;
}
lastSignature = sigKey;
lastProcessedTime = Date.now();
}
if (pkg === 'com.tencent.mm') {
// 微信:识别是否是详情页
const isDetail = texts.includes('交易单号') || texts.includes('退款单号') || texts.includes('本服务由财付通提供');
if (isDetail) {
const success = await parseAndProcessAccessibilityTexts(texts, pkg); const success = await parseAndProcessAccessibilityTexts(texts, pkg);
if (!success) { if (!success) {
logger.info('layout', '[无障碍] 微信详情页直接文本解析未成功,降级触发 OCR 识别'); logger.info('layout', '[无障碍识别] 直接文本解析未成功,降级触发 OCR 截图识别');
bridge?.triggerManualOcr();
}
} else {
logger.info('layout', '[无障碍] 微信非详情页面,不触发记账与截图');
}
} else if (pkg === 'com.eg.android.AlipayGphone') {
// 支付宝:识别是否是详情页
const isDetail = texts.includes('创建时间') || texts.includes('账单详情') || texts.includes('对此订单有疑问');
if (isDetail) {
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
if (!success) {
logger.info('layout', '[无障碍] 支付宝详情页直接文本解析未成功,降级触发 OCR 识别');
bridge?.triggerManualOcr();
}
} else {
logger.info('layout', '[无障碍] 支付宝非详情页面,不触发记账与截图');
}
} else {
// 其他白名单应用(如各大银行、QQ、TIM等),直接通过截图 + OCR 识别
logger.info('layout', `[无障碍] 检测到白名单应用 ${pkg},直接触发 OCR 识别`);
bridge?.triggerManualOcr(); bridge?.triggerManualOcr();
} }
} catch (e) { } catch (e) {
logger.error('layout', '无障碍调试及直接解析执行失败', e); logger.error('layout', `[无障碍识别] 执行失败 (页面: ${sigKey})`, e);
} }
}), }),
]; ];
return () => subscriptions.forEach(s => s.remove()); return () => {
subscriptions.forEach(s => s.remove());
removeBillingListener();
};
}, [phase]); }, [phase]);
// 5. 隐私模糊(plan.md「0.4 隐私模糊」):App 进入后台/非活跃时遮盖内容 // 5. 隐私模糊(plan.md「0.4 隐私模糊」):App 进入后台/非活跃时遮盖内容
useEffect(() => { useEffect(() => {
const sub = AppState.addEventListener('change', (state) => { const sub = AppState.addEventListener('change', (state) => {
// 切到后台/非活跃时立即遮盖,回到前台时移除 // 切到后台/非活跃时立即遮盖并同步持久化,回到前台时移除
setPrivacyOverlay(state !== 'active'); setPrivacyOverlay(state !== 'active');
if (state !== 'active') {
flushPersistence().catch(e => logger.error('layout', 'Failed to flush persistence on background', e));
}
if (state === 'active' && phase === 'ready') { if (state === 'active' && phase === 'ready') {
checkAndReloadLedger(); checkAndReloadLedger();
} }
@@ -359,7 +364,7 @@ function AppShell() {
}, [phase, checkAndReloadLedger]); }, [phase, checkAndReloadLedger]);
// loading 状态 // loading 状态
if (phase === 'loading' || (!fontsLoaded && !fontError)) { if (phase === 'loading') {
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary, alignItems: 'center', justifyContent: 'center' }}> <SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: theme.colors.fgSecondary }}>{t('app.name')}</Text> <Text style={{ color: theme.colors.fgSecondary }}>{t('app.name')}</Text>
@@ -390,7 +395,6 @@ function AppShell() {
<StatusBar style={isDark ? 'light' : 'dark'} /> <StatusBar style={isDark ? 'light' : 'dark'} />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}> <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}>
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="transaction/new" options={{ headerShown: true, title: t('home.newTransaction'), headerTintColor: theme.colors.fgPrimary, headerStyle: { backgroundColor: theme.colors.bgPrimary } }} />
<Stack.Screen name="transaction/[id]" options={{ headerShown: false }} /> <Stack.Screen name="transaction/[id]" options={{ headerShown: false }} />
<Stack.Screen name="category/index" options={{ headerShown: false }} /> <Stack.Screen name="category/index" options={{ headerShown: false }} />
<Stack.Screen name="tag/index" options={{ headerShown: false }} /> <Stack.Screen name="tag/index" options={{ headerShown: false }} />
@@ -401,25 +405,36 @@ function AppShell() {
<Stack.Screen name="settings/ai" options={{ headerShown: false }} /> <Stack.Screen name="settings/ai" options={{ headerShown: false }} />
<Stack.Screen name="settings/sync" options={{ headerShown: false }} /> <Stack.Screen name="settings/sync" options={{ headerShown: false }} />
<Stack.Screen name="settings/preferences" 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="recurring/index" options={{ headerShown: false }} />
<Stack.Screen name="ai/chat" options={{ headerShown: false }} /> <Stack.Screen name="ai/chat" options={{ headerShown: false }} />
<Stack.Screen name="remark-template/index" options={{ headerShown: false }} /> <Stack.Screen name="remark-template/index" options={{ headerShown: false }} />
<Stack.Screen name="automation/index" options={{ headerShown: false }} /> <Stack.Screen name="automation/index" options={{ headerShown: false }} />
<Stack.Screen name="credit-card/index" options={{ headerShown: false }} /> <Stack.Screen name="credit-card/index" options={{ headerShown: false }} />
</Stack> </Stack>
{/* 隐私遮罩(plan.md「0.4」):App 切后台时遮盖内容 */} <NumpadSheetHost />
{/* 隐私遮罩(plan.md「0.4」):App 切后台时遮盖内容并拦截所有手势 */}
{privacyOverlay && ( {privacyOverlay && (
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: theme.colors.bgPrimary }} /> <View
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: theme.colors.bgPrimary, zIndex: 99999 }}
pointerEvents="auto"
accessible={false}
/>
)} )}
</View> </View>
); );
} }
/** 根布局:包裹 ThemeProvider。 */ /** 根布局:GestureHandlerRootView(左滑手势)包裹 ThemeProvider。 */
export default function RootLayout() { export default function RootLayout() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider> <ThemeProvider>
<ToastProvider>
<FloatingUiConfigSyncer />
<AppShell /> <AppShell />
</ToastProvider>
</ThemeProvider> </ThemeProvider>
</GestureHandlerRootView>
); );
} }
+121 -3
View File
@@ -12,14 +12,16 @@
* onboarding * onboarding
*/ */
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { AppState, Platform, PermissionsAndroid, Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { useSettingsStore, type Locale, type ThemeMode } from '../store/settingsStore'; import { useSettingsStore, type Locale, type ThemeMode } from '../store/settingsStore';
import { Button } from '../components/Button'; import { Button } from '../components/ui/Button';
import { getAccessibilityBridge } from '../services/automation/accessibilityBridge';
import { Card } from '../components/ui/Card';
export interface OnboardingStep { export interface OnboardingStep {
key: string; key: string;
@@ -52,6 +54,68 @@ export function OnboardingScreen({ onComplete }: OnboardingProps) {
const current = steps[step]; const current = steps[step];
const isLast = step === steps.length - 1; 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 ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<ScrollView contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={styles.content}>
@@ -97,6 +161,55 @@ export function OnboardingScreen({ onComplete }: OnboardingProps) {
</View> </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' && ( {current.key === 'security' && (
<Pressable <Pressable
onPress={() => setAppLockEnabled(!appLockEnabled)} onPress={() => setAppLockEnabled(!appLockEnabled)}
@@ -146,4 +259,9 @@ const styles = StyleSheet.create({
dots: { flexDirection: 'row', justifyContent: 'center', gap: 6, marginBottom: 12 }, dots: { flexDirection: 'row', justifyContent: 'center', gap: 6, marginBottom: 12 },
dot: { width: 8, height: 8, borderRadius: 4 }, dot: { width: 8, height: 8, borderRadius: 4 },
actions: { flexDirection: 'row', gap: 12, justifyContent: 'flex-end' }, 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 },
}); });
+53 -102
View File
@@ -1,22 +1,26 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { AccountCreateModal } from '../../components/account/AccountCreateModal';
import { addDecimals } from '../../domain/decimal'; import { FormModal } from '../../components/form/FormModal';
import { computeAccountBalances } from '../../domain/ledger'; 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'; type TabType = 'assets' | 'liabilities' | 'expenses' | 'income';
export default function AccountScreen() { export default function AccountScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter(); const { showToast } = useToast();
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts); const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
@@ -56,7 +60,7 @@ export default function AccountScreen() {
onPress: async () => { onPress: async () => {
try { try {
await autoCloseAccount(accountName); await autoCloseAccount(accountName);
Alert.alert(t('account.closeSuccess'), t('account.closeSuccessDesc')); showToast(t('account.closeSuccessDesc'), 'success');
} catch (e) { } catch (e) {
Alert.alert(t('account.closeFail'), e instanceof Error ? e.message : String(e)); Alert.alert(t('account.closeFail'), e instanceof Error ? e.message : String(e));
} }
@@ -69,7 +73,6 @@ export default function AccountScreen() {
const handleAddAccount = async (values: Record<string, string>) => { const handleAddAccount = async (values: Record<string, string>) => {
const typeVal = values.type?.trim(); const typeVal = values.type?.trim();
const nameVal = values.name?.trim(); const nameVal = values.name?.trim();
const currencyVal = values.currency?.trim() || 'CNY';
if (!typeVal || !nameVal) { if (!typeVal || !nameVal) {
Alert.alert(t('account.addFail'), t('account.addFailEmpty')); Alert.alert(t('account.addFail'), t('account.addFailEmpty'));
@@ -84,7 +87,7 @@ export default function AccountScreen() {
.map((seg, idx) => { .map((seg, idx) => {
if (idx === 0) return seg; // 保留顶级前缀如 Assets if (idx === 0) return seg; // 保留顶级前缀如 Assets
// 将中英文括号和中括号转换为 - 连字符 // 将中英文括号和中括号转换为 - 连字符
let cleaned = seg.replace(/[\(\)()\[\]]/g, '-'); let cleaned = seg.replace(/[()()[\]]/g, '-');
cleaned = cleaned.replace(/-+/g, '-'); cleaned = cleaned.replace(/-+/g, '-');
cleaned = cleaned.replace(/^-|-$/g, ''); cleaned = cleaned.replace(/^-|-$/g, '');
cleaned = cleaned.replace(/[^\w\u4e00-\u9fa5-]/g, ''); cleaned = cleaned.replace(/[^\w\u4e00-\u9fa5-]/g, '');
@@ -102,7 +105,7 @@ export default function AccountScreen() {
try { try {
await autoOpenAccounts([sanitized]); await autoOpenAccounts([sanitized]);
setIsAdding(false); setIsAdding(false);
Alert.alert(t('account.openSuccess'), t('account.openSuccessDesc', { name: sanitized })); showToast(t('account.openSuccessDesc', { name: sanitized }), 'success');
} catch (e) { } catch (e) {
Alert.alert(t('account.openFail'), e instanceof Error ? e.message : String(e)); Alert.alert(t('account.openFail'), e instanceof Error ? e.message : String(e));
} }
@@ -111,7 +114,7 @@ export default function AccountScreen() {
const handleConfirmAdjust = async (values: Record<string, string>) => { const handleConfirmAdjust = async (values: Record<string, string>) => {
if (!adjustingAccount) return; if (!adjustingAccount) return;
const balanceVal = values.balance?.trim(); const balanceVal = values.balance?.trim();
const dateVal = values.date?.trim() || new Date().toISOString().slice(0, 10); const dateVal = values.date?.trim() || toDateString(new Date());
if (!balanceVal) { if (!balanceVal) {
Alert.alert(t('account.adjustFail'), t('account.adjustFailEmpty')); Alert.alert(t('account.adjustFail'), t('account.adjustFailEmpty'));
@@ -121,7 +124,7 @@ export default function AccountScreen() {
try { try {
await adjustAccountBalance(adjustingAccount, balanceVal, dateVal); await adjustAccountBalance(adjustingAccount, balanceVal, dateVal);
setAdjustingAccount(null); setAdjustingAccount(null);
Alert.alert(t('account.adjustSuccess'), t('account.adjustSuccessDesc', { name: adjustingAccount, balance: balanceVal, currency: 'CNY' })); showToast(t('account.adjustSuccessDesc', { name: adjustingAccount, balance: balanceVal, currency: 'CNY' }), 'success');
} catch (e) { } catch (e) {
Alert.alert(t('account.adjustFail'), e instanceof Error ? e.message : String(e)); Alert.alert(t('account.adjustFail'), e instanceof Error ? e.message : String(e));
} }
@@ -134,90 +137,36 @@ export default function AccountScreen() {
{ key: 'income', label: t('account.tabIncome') }, { key: 'income', label: t('account.tabIncome') },
]; ];
const fields: FormField[] = [
{
key: 'type',
label: t('account.fieldType'),
placeholder: 'Assets / Liabilities / Expenses / Income',
defaultValue: tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income',
},
{
key: 'name',
label: t('account.fieldName'),
placeholder: t('account.fieldNamePlaceholder'),
defaultValue: '',
},
{
key: 'currency',
label: t('account.fieldCurrency'),
placeholder: 'CNY',
defaultValue: 'CNY',
},
];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
{/* 头部导航 */} <ScreenHeader title={t('account.title')} />
<View style={styles.header}>
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('account.title')}
</Text>
</View>
{/* Tab 选项卡 */} {/* Tab 选项卡(统一分段选择器) */}
<View style={[styles.tabBar, { borderBottomColor: theme.colors.border }]}> <SegmentedControl
{tabs.map(item => { options={tabs}
const isActive = tab === item.key; value={tab}
return ( onChange={key => setTab(key as TabType)}
<Pressable />
key={item.key}
accessibilityRole="tab"
accessibilityState={{ selected: isActive }}
accessibilityLabel={item.label}
style={[
styles.tabItem,
isActive && { borderBottomColor: theme.colors.accent, borderBottomWidth: 2 },
]}
onPress={() => setTab(item.key)}
>
<Text
style={[
theme.typography.bodySmall,
{ color: isActive ? theme.colors.accent : theme.colors.fgSecondary, fontWeight: isActive ? '700' : '400' },
]}
>
{item.label}
</Text>
</Pressable>
);
})}
</View>
{/* 账户列表 */} {/* 账户列表 */}
<ScrollView contentContainerStyle={styles.content}> <FlatList
<Pressable onPress={() => setIsAdding(true)} style={styles.addBtn}> data={filteredAccounts}
<Ionicons name="add-circle" size={20} color={theme.colors.accent} /> keyExtractor={account => account}
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}> renderItem={({ item: account }) => {
{t('account.addNew')}
</Text>
</Pressable>
{filteredAccounts.map(account => {
const shortName = account.slice(prefix.length); 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 balance = balances.get(account) ?? '0.00';
const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY'; const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY';
return ( return (
<Card key={account} title={shortName}> <Card title={shortName}>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('account.fullName')} {t('account.fullName')}
</Text> </Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{account} {account} {localizedRoot ? `(${localizedRoot})` : ''}
</Text> </Text>
</View> </View>
<View style={styles.infoRow}> <View style={styles.infoRow}>
@@ -237,7 +186,7 @@ export default function AccountScreen() {
<View style={styles.cardActions}> <View style={styles.cardActions}>
<Pressable <Pressable
onPress={() => setAdjustingAccount(account)} onPress={() => setAdjustingAccount(account)}
style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]} style={[styles.actionBtn, { borderColor: theme.colors.border, borderRadius: theme.radii.sm, marginRight: 8 }]}
> >
<Ionicons name="create-outline" size={14} color={theme.colors.accent} /> <Ionicons name="create-outline" size={14} color={theme.colors.accent} />
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}> <Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
@@ -246,7 +195,7 @@ export default function AccountScreen() {
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => handleCloseAccount(account)} onPress={() => handleCloseAccount(account)}
style={[styles.closeBtn, { borderColor: theme.colors.border }]} style={[styles.closeBtn, { borderColor: theme.colors.border, borderRadius: theme.radii.sm }]}
> >
<Ionicons name="close-circle-outline" size={14} color={theme.colors.error} /> <Ionicons name="close-circle-outline" size={14} color={theme.colors.error} />
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}> <Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
@@ -256,20 +205,25 @@ export default function AccountScreen() {
</View> </View>
</Card> </Card>
); );
})} }}
ListHeaderComponent={
{filteredAccounts.length === 0 && ( <Pressable onPress={() => setIsAdding(true)} style={styles.addBtn}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}> <Ionicons name="add-circle" size={20} color={theme.colors.accent} />
{t('account.empty')} <Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>
{t('account.addNew')}
</Text> </Text>
)} </Pressable>
</ScrollView> }
ListEmptyComponent={
<EmptyState icon="wallet-outline" title={t('account.empty')} />
}
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
/>
{/* 新开户对话框 */} {/* 新开户对话框 */}
<FormModal <AccountCreateModal
visible={isAdding} visible={isAdding}
title={t('account.addModalTitle')} defaultType={tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income'}
fields={fields}
onConfirm={handleAddAccount} onConfirm={handleAddAccount}
onCancel={() => setIsAdding(false)} onCancel={() => setIsAdding(false)}
/> />
@@ -289,7 +243,7 @@ export default function AccountScreen() {
key: 'date', key: 'date',
label: t('account.fieldDate'), label: t('account.fieldDate'),
placeholder: 'YYYY-MM-DD', placeholder: 'YYYY-MM-DD',
defaultValue: new Date().toISOString().slice(0, 10), defaultValue: toDateString(new Date()),
}, },
]} ]}
onConfirm={handleConfirmAdjust} onConfirm={handleConfirmAdjust}
@@ -301,13 +255,10 @@ export default function AccountScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 }, content: { padding: 16 },
tabBar: { flexDirection: 'row', borderBottomWidth: 1, paddingHorizontal: 8 },
tabItem: { flex: 1, alignItems: 'center', paddingVertical: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 }, addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' }, infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' },
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 }, cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 }, actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, paddingHorizontal: 8, paddingVertical: 4 },
closeBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 }, closeBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, paddingHorizontal: 8, paddingVertical: 4 },
}); });
+32 -30
View File
@@ -13,12 +13,14 @@ import React, { useState, useCallback, useRef } from 'react';
import { FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai'; 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 { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
import { SkeletonText } from '../../components/ui/Skeleton';
interface UiMessage { interface UiMessage {
role: 'user' | 'assistant'; role: 'user' | 'assistant';
@@ -43,9 +45,6 @@ function buildProvider(get: ReturnType<typeof useSettingsStore.getState>): AiPro
export default function AIChatScreen() { export default function AIChatScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const aiEnabled = useSettingsStore(s => s.aiEnabled);
const aiApiKey = useSettingsStore(s => s.aiApiKey);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<UiMessage[]>([ const [messages, setMessages] = useState<UiMessage[]>([
@@ -107,71 +106,75 @@ export default function AIChatScreen() {
{ {
backgroundColor: isUser ? theme.colors.accent : theme.colors.bgTertiary, backgroundColor: isUser ? theme.colors.accent : theme.colors.bgTertiary,
borderColor: theme.colors.border, borderColor: theme.colors.border,
borderRadius: theme.radii.md,
}, },
]}> ]}>
<Text style={{ color: isUser ? theme.colors.fgInverse : theme.colors.fgPrimary, fontSize: 15, lineHeight: 20 }}> <Text style={[theme.typography.bodySmall, { color: isUser ? theme.colors.fgInverse : theme.colors.fgPrimary }]}>
{item.content} {item.content}
</Text> </Text>
{/* 账单卡片 */} {/* 账单卡片 */}
{item.billCards && item.billCards.map((card, i) => ( {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 <Pressable
key={i} key={i}
onPress={() => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } })} onPress={() => useNumpadUiStore.getState().open({ autoOcr: true })}
style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent }]} style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent, borderRadius: theme.radii.sm }]}
> >
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}> <Text style={[theme.typography.bodySmall, { color: dirColor, fontWeight: '700' }]}>
{card.type === 'income' ? '💰' : card.type === 'transfer' ? '🔄' : '💸'} {card.amount} {card.currency} {dirSign} {card.amount} {card.currency}
</Text> </Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{card.counterparty} · {card.narration} {card.counterparty} · {card.narration}
</Text> </Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontFamily: theme.typography.caption.fontFamily }]}> <Text style={[theme.typography.caption, { color: theme.colors.accent }]}>
{t('ai.tapToRecord')} {t('ai.tapToRecord')}
</Text> </Text>
<Ionicons name="arrow-forward" size={12} color={theme.colors.accent} /> <Ionicons name="arrow-forward" size={12} color={theme.colors.accent} />
</View> </View>
</Pressable> </Pressable>
))} );
})}
</View> </View>
</View> </View>
); );
}; };
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}> <ScreenHeader title={t('ai.chatTitle')} />
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('ai.chatTitle')}
</Text>
</View>
<FlatList <FlatList
ref={listRef} ref={listRef}
data={messages} data={messages}
keyExtractor={(item, index) => `${index}`} keyExtractor={(item, index) => `${index}`}
renderItem={renderMessage} renderItem={renderMessage}
contentContainerStyle={{ padding: 16, gap: 8 }} contentContainerStyle={{ padding: 16, gap: theme.spacing.md }}
onContentSizeChange={() => listRef.current?.scrollToEnd()} onContentSizeChange={() => listRef.current?.scrollToEnd()}
/> />
{loading && ( {loading && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', paddingBottom: 4 }]}> <View style={{ paddingHorizontal: 16, paddingBottom: 4 }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: theme.spacing.xs }]}>
{t('ai.thinking')} {t('ai.thinking')}
</Text> </Text>
<SkeletonText lines={2} lineHeight={12} />
</View>
)} )}
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}> <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
<View style={[styles.inputRow, { backgroundColor: theme.colors.bgSecondary, borderTopColor: theme.colors.border }]}> <View style={[styles.inputRow, { backgroundColor: theme.colors.bgSecondary, borderTopColor: theme.colors.border }]}>
<TextInput <TextInput
value={input} value={input}
onChangeText={setInput} onChangeText={setInput}
placeholder={t('ai.inputPlaceholder')} placeholder={t('ai.inputPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
style={[styles.input, { color: theme.colors.fgPrimary }]} style={[styles.input, { color: theme.colors.fgPrimary, fontSize: theme.typography.body.fontSize }]}
multiline multiline
/> />
<Pressable <Pressable
@@ -192,11 +195,10 @@ export default function AIChatScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, borderBottomWidth: 1 },
msgRow: { flexDirection: 'row', maxWidth: '100%' }, msgRow: { flexDirection: 'row', maxWidth: '100%' },
bubble: { maxWidth: '85%', borderRadius: 12, padding: 12, borderWidth: 1 }, bubble: { maxWidth: '85%', padding: 12, borderWidth: 1 },
billCard: { marginTop: 8, padding: 10, borderRadius: 8, borderWidth: 1 }, billCard: { marginTop: 8, padding: 10, borderWidth: 1 },
inputRow: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingVertical: 8, borderTopWidth: 1, gap: 8 }, inputRow: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingVertical: 8, borderTopWidth: 1, gap: 8 },
input: { flex: 1, maxHeight: 100, fontSize: 15, paddingVertical: 8 }, input: { flex: 1, maxHeight: 100, paddingVertical: 8 },
sendBtn: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, sendBtn: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
}); });
+334 -118
View File
@@ -5,7 +5,7 @@
* - 5 ///OCR/ * - 5 ///OCR/
* - * -
* - "处理全部" BillPipeline 稿 * - "处理全部" BillPipeline 稿
* - /稿 mobile.bean * - /稿 main.bean
* - ScreenshotMonitor * - ScreenshotMonitor
* - * -
* - / * - /
@@ -15,47 +15,47 @@
* - * -
* - App * - App
*/ */
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View } from 'react-native'; 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 { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useAutomationStore } from '../../store/automationStore'; import { useRouter } from 'expo-router';
import { useLedgerStore } from '../../store/ledgerStore'; import { useSettingsStore } from '../../store/settingsStore';
import { useMetadataStore } from '../../store/metadataStore'; import { Card } from '../../components/ui/Card';
import { Card } from '../../components/Card'; import { Button } from '../../components/ui/Button';
import { Button } from '../../components/Button'; import { ScreenHeader } from '../../components/layout/ScreenHeader';
import type { AutomationSource } from '../../store/automationStore';
import { import {
getAccessibilityBridge, getAccessibilityBridge,
getPackageLabel, getPackageLabel,
type PageSignature, type PageSignature,
} from '../../services/accessibilityBridge'; } from '../../services/automation/accessibilityBridge';
import { ensureOcrModels, downloadOcrModels } from '../../services/ocr/modelDownloader';
const SOURCE_LABELS: Record<AutomationSource, string> = { import { logger } from '../../utils/logger';
notification: 'automation.sourceNotification',
sms: 'automation.sourceSms',
screenshot: 'automation.sourceScreenshot',
ocr: 'automation.sourceOcr',
manual: 'automation.sourceManual',
};
export default function AutomationScreen() { export default function AutomationScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter(); const router = useRouter();
const detected = useAutomationStore(s => s.detected); const floatingBallEnabled = useSettingsStore(s => s.floatingBallEnabled);
const drafts = useAutomationStore(s => s.drafts); const setFloatingBallEnabled = useSettingsStore(s => s.setFloatingBallEnabled);
const stats = useAutomationStore(s => s.stats);
const processAll = useAutomationStore(s => s.processAll);
const confirmDraft = useAutomationStore(s => s.confirmDraft);
const rejectDraft = useAutomationStore(s => s.rejectDraft);
const ledger = useLedgerStore(s => s.ledger); // 自动记账层级
const addTransaction = useLedgerStore(s => s.addTransaction); 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 [serviceRunning, setServiceRunning] = useState(false);
@@ -64,7 +64,62 @@ export default function AutomationScreen() {
const [screenshotActive, setScreenshotActive] = useState(false); const [screenshotActive, setScreenshotActive] = useState(false);
const [topApp, setTopApp] = useState<{ package: string; activity: string } | null>(null); const [topApp, setTopApp] = useState<{ package: string; activity: string } | null>(null);
const sourceEntries = useMemo(() => Object.entries(stats) as [AutomationSource, number][], [stats]); // 权限状态
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 refreshAccessibilityState = useCallback(async () => {
@@ -81,8 +136,8 @@ export default function AutomationScreen() {
setPageSignatures(sigs); setPageSignatures(sigs);
setPaymentPackages(pkgs.map(pkg => ({ package: pkg, label: getPackageLabel(pkg) }))); setPaymentPackages(pkgs.map(pkg => ({ package: pkg, label: getPackageLabel(pkg) })));
setTopApp(top && top.package ? top : null); setTopApp(top && top.package ? top : null);
} catch { } catch (e) {
// 静默 logger.debug('automation', '[刷新无障碍状态] 失败', e);
} }
}, []); }, []);
@@ -100,27 +155,56 @@ export default function AutomationScreen() {
}; };
}, [refreshAccessibilityState]); }, [refreshAccessibilityState]);
const handleProcess = async () => { // 检查权限状态(挂载 + 从系统设置返回前台时重新检查)
if (!ledger) return; useEffect(() => {
const rules = useMetadataStore.getState().rules; if (Platform.OS !== 'android') return;
const categories = useMetadataStore.getState().categories;
const history = ledger.transactions; const checkPermissions = () => {
try { const bridge = getAccessibilityBridge();
await processAll(ledger, rules, categories, history); if (bridge && typeof bridge.isNotificationListenerEnabled === 'function') {
} catch (e) { bridge.isNotificationListenerEnabled().then(setNotifEnabled).catch(() => setNotifEnabled(false));
Alert.alert(String(e));
} }
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));
}; };
const handleConfirm = async (index: number) => { checkPermissions();
const draft = confirmDraft(index);
if (!draft) return; const sub = AppState.addEventListener('change', (state) => {
try { if (state === 'active') checkPermissions();
await addTransaction(draft.draft); });
Alert.alert(t('automation.confirmed')); return () => sub.remove();
} catch (e) { }, []);
Alert.alert(String(e));
} 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 = () => { const handleScreenshotToggle = () => {
@@ -215,8 +299,8 @@ export default function AutomationScreen() {
try { try {
await bridge.removePageSignature(sig); await bridge.removePageSignature(sig);
refreshAccessibilityState(); refreshAccessibilityState();
} catch { } catch (e) {
// 静默 logger.warn('automation', `[删除页面签名] 失败: ${sig}`, e);
} }
}; };
@@ -235,8 +319,8 @@ export default function AutomationScreen() {
try { try {
await bridge.clearPageSignatures(); await bridge.clearPageSignatures();
refreshAccessibilityState(); refreshAccessibilityState();
} catch { } catch (e) {
// 静默 logger.warn('automation', '[清空页面签名] 失败', e);
} }
}, },
}, },
@@ -245,47 +329,11 @@ export default function AutomationScreen() {
}; };
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<View style={styles.header}> <ScreenHeader title={t('automation.title')} />
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('automation.title')}
</Text>
</View>
<FlatList <ScrollView ref={scrollRef} contentContainerStyle={styles.content}>
data={drafts}
keyExtractor={(item, index) => item.draft.sourceEventId ?? `${item.draft.date}-${index}`}
renderItem={({ item, index }) => (
<Card title={`${item.draft.date} · ${item.draft.narration}`}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{item.draft.postings.map(p => `${p.account} ${p.amount} ${p.currency ?? ''}`).join('\n')}
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 8 }}>
<Button label={t('automation.confirmDraft')} onPress={() => handleConfirm(index)} />
<Button label={t('automation.rejectDraft')} onPress={() => rejectDraft(index)} variant="secondary" />
</View>
</Card>
)}
ListHeaderComponent={
<View style={{ gap: 12, marginBottom: 12 }}> <View style={{ gap: 12, marginBottom: 12 }}>
{/* 通道统计 */}
<Card title={t('automation.channelStats')}>
<View style={styles.statsRow}>
{sourceEntries.map(([source, count]) => (
<View key={source} style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t(SOURCE_LABELS[source])}
</Text>
<Text style={[theme.typography.h3, { color: count > 0 ? theme.colors.accent : theme.colors.fgSecondary }]}>
{count}
</Text>
</View>
))}
</View>
</Card>
{/* 无障碍服务状态与控制 */} {/* 无障碍服务状态与控制 */}
{Platform.OS === 'android' && ( {Platform.OS === 'android' && (
@@ -303,6 +351,25 @@ export default function AutomationScreen() {
</Text> </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 }}> <View style={{ gap: 8 }}>
{!serviceRunning && ( {!serviceRunning && (
@@ -382,51 +449,200 @@ export default function AutomationScreen() {
</Card> </Card>
)} )}
{/* 检测到的事件 */} {/* --- Card A: 自动记账层级 --- */}
<Card title={t('automation.detectedEvents', { count: detected.length })}> <Card title={t('automation.autoBookkeeping')}>
{detected.length === 0 ? ( {/* 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 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.noEvents')} {t('automation.layer1RuleDesc')}
</Text> </Text>
) : (
<View style={{ gap: 4 }}>
{detected.map(ev => (
<Text key={ev.id} style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
[{t(SOURCE_LABELS[ev.source])}] {ev.event.counterparty} · {ev.event.amount} {ev.event.currency}
</Text>
))}
<View style={{ marginTop: 8 }}>
<Button label={t('automation.process')} onPress={handleProcess} />
</View> </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> </View>
)} )}
</Card> </Card>
{/* 草稿标题 */} {/* --- Card C: 管道设置 --- */}
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}> <Card title={t('settings.algorithmConfig')}>
{t('automation.drafts', { count: drafts.length })} <View style={[styles.switchRow, { marginBottom: 8 }]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.dedupLabel')}
</Text> </Text>
</View> <Switch
} value={dedupEnabled}
ListEmptyComponent={ onValueChange={setDedupEnabled}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}> trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
{t('automation.noDrafts')} thumbColor={theme.colors.fgInverse}
</Text>
}
contentContainerStyle={styles.content}
/> />
</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> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 64 }, content: { padding: 16, paddingBottom: 64 },
statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
statItem: { alignItems: 'center', gap: 4 },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
statusDot: { width: 8, height: 8, borderRadius: 4 }, 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 }, 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 }, sigRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 4 },
permRow: { flexDirection: 'row', alignItems: 'center' },
}); });
+53 -93
View File
@@ -3,27 +3,23 @@
* *
* () + // * () + //
* calculateBudgetProgress ,// * calculateBudgetProgress ,//
* P5 ManagementScreen //
*/ */
import React, { useState } from 'react'; import React from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, Pressable, 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 { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/layout/ManagementScreen';
import { calculateBudgetProgress } from '../../domain/budgets'; import { calculateBudgetProgress } from '../../domain/finance/budgets';
import type { Budget } from '../../domain/budgets'; import { toDateString } from '../../domain/core/decimal';
import type { Budget } from '../../domain/finance/budgets';
type ModalMode = { type: 'add' } | { type: 'edit'; budget: Budget } | null;
export default function BudgetScreen() { export default function BudgetScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const budgets = useMetadataStore(s => s.budgets); const budgets = useMetadataStore(s => s.budgets);
const addBudget = useMetadataStore(s => s.addBudget); const addBudget = useMetadataStore(s => s.addBudget);
@@ -31,68 +27,23 @@ export default function BudgetScreen() {
const removeBudget = useMetadataStore(s => s.removeBudget); const removeBudget = useMetadataStore(s => s.removeBudget);
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const [modal, setModal] = useState<ModalMode>(null);
const transactions = ledger?.transactions ?? []; const transactions = ledger?.transactions ?? [];
const today = new Date().toISOString().slice(0, 10); // 用本地时区格式化,避免 toISOString 的 UTC 偏移导致负时区日期错位
const today = toDateString(new Date());
const handleDelete = (budget: Budget) => {
Alert.alert(t('budget.deleteTitle'), t('budget.deleteConfirm', { name: budget.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeBudget(budget.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
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;
}
if (modal?.type === 'add') {
addBudget({ id: generateId('bud'), name, amount, period, categoryAccount, startDate });
} else if (modal?.type === 'edit') {
updateBudget(modal.budget.id, { name, amount, period, categoryAccount, startDate });
}
setModal(null);
};
const periodLabel = (p: string) => p === 'monthly' ? t('budget.periodMonthly') : p === 'weekly' ? t('budget.periodWeekly') : t('budget.periodYearly'); const periodLabel = (p: string) => p === 'monthly' ? t('budget.periodMonthly') : p === 'weekly' ? t('budget.periodWeekly') : t('budget.periodYearly');
const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder'), defaultValue: modal.budget.name },
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), defaultValue: modal.budget.amount, keyboardType: 'decimal-pad' },
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly', defaultValue: modal.budget.period },
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder'), defaultValue: modal.budget.categoryAccount ?? '' },
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01', defaultValue: modal.budget.startDate },
] : [];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <ManagementScreen<Budget>
<View style={styles.header}> title={t('budget.title')}
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable> items={budgets}
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('budget.title')}</Text> keyExtractor={budget => budget.id}
</View> addLabel={t('budget.add')}
<ScrollView contentContainerStyle={styles.content}> emptyText={t('budget.empty')}
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}> renderItem={(budget, { openEdit, confirmDelete }) => {
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('budget.add')}</Text>
</Pressable>
{budgets.map(budget => {
const progress = calculateBudgetProgress(budget, transactions, today); const progress = calculateBudgetProgress(budget, transactions, today);
return ( return (
<Pressable <Pressable onPress={openEdit} onLongPress={confirmDelete}>
key={budget.id}
onPress={() => setModal({ type: 'edit', budget })}
onLongPress={() => handleDelete(budget)}
>
<Card title={budget.name}> <Card title={budget.name}>
<View style={styles.budgetRow}> <View style={styles.budgetRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
@@ -122,39 +73,48 @@ export default function BudgetScreen() {
</Card> </Card>
</Pressable> </Pressable>
); );
})} }}
{budgets.length === 0 && ( formTitle={editing => (editing ? t('budget.editTitle') : t('budget.add'))}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}> formFields={editing => [
{t('budget.empty')} { key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder'), defaultValue: editing?.name },
</Text> { 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 },
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> { 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')} {t('common.clickEditLongDelete')}
</Text> </Text>
</ScrollView> }
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('budget.editTitle') : t('budget.add')}
fields={modal?.type === 'edit' ? editFields : [
{ key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder') },
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), keyboardType: 'decimal-pad' },
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly' },
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder') },
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01' },
]}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/> />
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center' },
budgetRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, budgetRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
barWrap: { height: 8, borderRadius: 4, overflow: 'hidden', marginTop: 8 }, barWrap: { height: 8, borderRadius: 4, overflow: 'hidden', marginTop: 8 },
bar: { height: '100%', borderRadius: 4 }, bar: { height: '100%', borderRadius: 4 },
+68 -101
View File
@@ -1,135 +1,102 @@
/** /**
* plan.md1.1 category/index+ 1 * plan.md1.1 category/index+ 1
* *
* /+ // * / chips + //
* metadataStorelinkedAccount Beancount * metadataStorelinkedAccount Beancount
* P5 ManagementScreen headerContent / chips
*/ */
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { useTheme, createCommonStyles } from '../../theme';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/layout/ManagementScreen';
import type { Category } from '../../domain/categories'; import { Touchable } from '../../components/ui/Touchable';
import type { Category } from '../../domain/taxonomy/categories';
type ModalMode = { type: 'add'; catType: 'expense' | 'income' } | { type: 'edit'; category: Category } | null;
export default function CategoryScreen() { export default function CategoryScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter(); const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const categories = useMetadataStore(s => s.categories); const categories = useMetadataStore(s => s.categories);
const addCategory = useMetadataStore(s => s.addCategory); const addCategory = useMetadataStore(s => s.addCategory);
const updateCategory = useMetadataStore(s => s.updateCategory); const updateCategory = useMetadataStore(s => s.updateCategory);
const removeCategory = useMetadataStore(s => s.removeCategory); const removeCategory = useMetadataStore(s => s.removeCategory);
const [modal, setModal] = useState<ModalMode>(null); /** 当前展示的分类类型(支出/收入),新增分支按此落库。 */
const [catType, setCatType] = useState<'expense' | 'income'>('expense');
const expenseCats = categories.filter(c => c.type === 'expense'); return (
const incomeCats = categories.filter(c => c.type === 'income'); <ManagementScreen<Category>
title={t('category.title')}
const handleDelete = (cat: Category) => { items={categories.filter(c => c.type === catType)}
Alert.alert(t('category.deleteTitle'), t('category.deleteConfirm', { name: cat.name }), [ keyExtractor={cat => cat.id}
{ text: t('common.cancel'), style: 'cancel' }, addLabel={catType === 'income' ? t('category.addIncome') : t('category.addExpense')}
{ text: t('common.delete'), style: 'destructive', onPress: () => removeCategory(cat.id) }, headerContent={
]); <View style={styles.chipRow}>
}; {(['expense', 'income'] as const).map(tp => (
<Touchable
const handleConfirm = (values: Record<string, string>) => { key={tp}
if (modal?.type === 'add') { onPress={() => setCatType(tp)}
addCategory({ style={[commonStyles.chip, catType === tp && commonStyles.chipActive]}
id: generateId('cat'),
name: values.name || t('common.untitled'),
type: modal.catType,
linkedAccount: values.linkedAccount || 'Expenses:Uncategorized',
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
} else if (modal?.type === 'edit') {
updateCategory(modal.category.id, {
name: values.name || modal.category.name,
linkedAccount: values.linkedAccount || modal.category.linkedAccount,
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
}
setModal(null);
};
const renderRow = (cat: Category) => (
<Pressable
key={cat.id}
onPress={() => setModal({ type: 'edit', category: cat })}
onLongPress={() => handleDelete(cat)}
style={({ pressed }) => [styles.row, { borderTopColor: theme.colors.divider, opacity: pressed ? 0.6 : 1 }]}
> >
<View style={{ flex: 1 }}> <Text style={[commonStyles.chipText, catType === tp && commonStyles.chipTextActive]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{cat.name}</Text> {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> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
{cat.keywords.length > 0 && ( {cat.keywords.length > 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{t('category.keywordsLabel')}: {cat.keywords.join(', ')} {t('category.keywordsLabel')}: {cat.keywords.join(', ')}
</Text> </Text>
)} )}
</View>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
</Pressable>
);
const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder'), defaultValue: modal.category.name },
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder'), defaultValue: modal.category.linkedAccount },
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder'), defaultValue: modal.category.keywords.join(', ') },
] : [];
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('category.title')}</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Card title={t('category.expense')}>
<Pressable onPress={() => setModal({ type: 'add', catType: 'expense' })} 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('category.addExpense')}</Text>
</Pressable>
{expenseCats.map(renderRow)}
</Card> </Card>
<Card title={t('category.income')}> )}
<Pressable onPress={() => setModal({ type: 'add', catType: 'income' })} style={styles.addBtn}> formTitle={editing => (editing
<Ionicons name="add-circle" size={20} color={theme.colors.accent} /> ? t('category.editTitle')
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('category.addIncome')}</Text> : catType === 'income' ? t('category.addIncome') : t('category.addExpense'))}
</Pressable> formFields={editing => [
{incomeCats.map(renderRow)} { key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder'), defaultValue: editing?.name },
</Card> { key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder'), defaultValue: editing?.linkedAccount },
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> { 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')} {t('common.clickEditLongDelete')}
</Text> </Text>
</ScrollView> }
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('category.editTitle') : t('category.addTitle', { type: modal?.catType === 'income' ? t('category.income') : t('category.expense') })}
fields={modal?.type === 'edit' ? editFields : [
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder') },
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder') },
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder') },
]}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/> />
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, chipRow: { flexDirection: 'row', gap: 8 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
row: { flexDirection: 'row', alignItems: 'center', borderTopWidth: 1, paddingTop: 10 },
addBtn: { flexDirection: 'row', alignItems: 'center', paddingBottom: 10 },
}); });
+147 -92
View File
@@ -1,29 +1,26 @@
/** /**
* plan.md1.1 credit-card/index+ 1 * plan.md1.1 credit-card/index+ 1
* *
* (////) + // * (/////) + //
* linkedAccount Beancount Liabilities * linkedAccount Beancount Liabilities
* P5 ManagementScreen renderItem
*/ */
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { 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 { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/layout/ManagementScreen';
import type { CreditCard } from '../../domain/creditCards'; import { AccountCreateModal } from '../../components/account/AccountCreateModal';
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards'; import { Touchable } from '../../components/ui/Touchable';
import type { CreditCard } from '../../domain/finance/creditCards';
type ModalMode = { type: 'add' } | { type: 'edit'; card: CreditCard } | null; import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/finance/creditCards';
export default function CreditCardScreen() { export default function CreditCardScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const creditCards = useMetadataStore(s => s.creditCards); const creditCards = useMetadataStore(s => s.creditCards);
const addCreditCard = useMetadataStore(s => s.addCreditCard); const addCreditCard = useMetadataStore(s => s.addCreditCard);
@@ -31,83 +28,64 @@ export default function CreditCardScreen() {
const removeCreditCard = useMetadataStore(s => s.removeCreditCard); const removeCreditCard = useMetadataStore(s => s.removeCreditCard);
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const transactions = ledger?.transactions ?? []; const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger?.transactions]);
const [modal, setModal] = useState<ModalMode>(null); const [isQuickAddingAccount, setIsQuickAddingAccount] = useState(false);
/** 计算某账户的当前余额(从交易过账汇总)。 */ /** 过滤出所有以 Liabilities 开头的信用负债账户。 */
const getAccountBalance = (account: string): string => { const liabilityAccounts = useMemo(() => {
let balance = 0; 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 tx of transactions) {
for (const p of tx.postings) { for (const p of tx.postings) {
if (p.account === account && p.amount) { if (p.account && p.amount) {
balance += parseFloat(p.amount); const prev = parseFloat(map.get(p.account) ?? '0');
map.set(p.account, (prev + parseFloat(p.amount)).toFixed(2));
} }
} }
} }
return String(balance.toFixed(2)); return map;
}; }, [transactions]);
const handleDelete = (card: CreditCard) => { const getAccountBalance = (account: string): string => accountBalances.get(account) ?? '0.00';
Alert.alert(t('creditCard.deleteTitle'), t('creditCard.deleteConfirm', { name: card.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeCreditCard(card.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => { const handleQuickAddAccount = (values: Record<string, string>) => {
const card: Omit<CreditCard, 'id'> = { const type = values.type || 'Liabilities';
name: values.name?.trim() || t('creditCard.unnamed'), const name = values.name?.trim() || 'CreditCard';
lastFour: values.lastFour?.trim() || '0000', const fullAccountName = `${type}:${name}`;
bankName: values.bankName?.trim() || 'UNKNOWN', autoOpenAccounts([fullAccountName]);
billingDay: parseInt(values.billingDay, 10) || 1, setIsQuickAddingAccount(false);
paymentDay: parseInt(values.paymentDay, 10) || 1,
creditLimit: values.creditLimit?.trim() || '0',
currency: values.currency?.trim() || 'CNY',
linkedAccount: values.linkedAccount?.trim() || 'Liabilities:CreditCard',
}; };
if (modal?.type === 'add') {
addCreditCard({ ...card, id: generateId('cc') });
} else if (modal?.type === 'edit') {
updateCreditCard(modal.card.id, card);
}
setModal(null);
};
const fieldsFor = (card?: CreditCard): FormField[] => [
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: card?.name },
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: 'CMB', defaultValue: card?.bankName },
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: card?.lastFour, keyboardType: 'numeric' },
{ key: 'billingDay', label: t('creditCard.fieldBillingDay'), placeholder: '5', defaultValue: card ? String(card.billingDay) : '', keyboardType: 'numeric' },
{ key: 'paymentDay', label: t('creditCard.fieldPaymentDay'), placeholder: '25', defaultValue: card ? String(card.paymentDay) : '', keyboardType: 'numeric' },
{ key: 'creditLimit', label: t('creditCard.fieldLimit'), placeholder: '10000', defaultValue: card?.creditLimit, keyboardType: 'decimal-pad' },
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: card?.currency },
{ key: 'linkedAccount', label: t('creditCard.fieldLinkedAccount'), placeholder: 'Liabilities:CreditCard:CMB', defaultValue: card?.linkedAccount },
];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <>
<View style={styles.header}> <ManagementScreen<CreditCard>
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable> title={t('creditCard.title')}
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('creditCard.title')}</Text> items={creditCards}
</View> keyExtractor={card => card.id}
<ScrollView contentContainerStyle={styles.content}> addLabel={t('creditCard.add')}
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}> emptyText={t('creditCard.empty')}
<Ionicons name="add-circle" size={20} color={theme.colors.accent} /> renderItem={(card, { openEdit, confirmDelete }) => {
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('creditCard.add')}</Text>
</Pressable>
{creditCards.map(card => {
const period = calculateBillingPeriod(card, new Date()); const period = calculateBillingPeriod(card, new Date());
const statementAmount = calculateStatementAmount(transactions, card, period); const statementAmount = calculateStatementAmount(transactions, card, period);
const currentBalance = getAccountBalance(card.linkedAccount); const currentBalance = getAccountBalance(card.linkedAccount);
const availableCredit = calculateAvailableCredit(card, currentBalance); const availableCredit = calculateAvailableCredit(card, currentBalance);
return ( return (
<Pressable <Card title={card.name} onPress={openEdit} onLongPress={confirmDelete}>
key={card.id}
onPress={() => setModal({ type: 'edit', card })}
onLongPress={() => handleDelete(card)}
>
<Card title={card.name}>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text> <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> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
@@ -146,35 +124,112 @@ export default function CreditCardScreen() {
</View> </View>
</View> </View>
</Card> </Card>
</Pressable>
); );
})} }}
{creditCards.length === 0 && ( formTitle={editing => (editing ? t('creditCard.editTitle') : t('creditCard.add'))}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}> onValuesChange={(changedKey: string, newValue: string, currentValues: Record<string, string>) => {
{t('creditCard.empty')} 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> </Text>
</Touchable>
)} )}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> 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')} {t('common.clickEditLongDelete')}
</Text> </Text>
</ScrollView> }
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('creditCard.editTitle') : t('creditCard.add')}
fields={fieldsFor(modal?.type === 'edit' ? modal.card : undefined)}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/> />
</SafeAreaView>
{/* 快捷新建负债账户弹窗 */}
<AccountCreateModal
visible={isQuickAddingAccount}
defaultType="Liabilities"
onConfirm={handleQuickAddAccount}
onCancel={() => setIsQuickAddingAccount(false)}
/>
</>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center' },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 }, infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 }, billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
quickAddBtn: { paddingVertical: 6, alignItems: 'center', marginTop: 4 },
}); });
+105 -39
View File
@@ -1,39 +1,42 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { DuplicateDetail, DedupConfidence } from '../../domain/transaction/dedup';
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native'; import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker'; import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
// @ts-ignore
// 实例化纯 JS GBK 解码器(Hermes 原生 TextDecoder 仅支持 UTF-8,需强迫 text-encoding-gbk 返回其纯 JS 实现)
const GbkTextDecoder = (() => { const GbkTextDecoder = (() => {
// @ts-ignore const g = globalThis as Record<string, unknown>;
const origDecoder = global.TextDecoder; const origDecoder = g.TextDecoder;
// @ts-ignore const origEncoder = g.TextEncoder;
const origEncoder = global.TextEncoder; try {
// @ts-ignore g.TextDecoder = undefined;
global.TextDecoder = undefined; g.TextEncoder = undefined;
// @ts-ignore
global.TextEncoder = undefined;
const dec = require('text-encoding-gbk').TextDecoder; const dec = require('text-encoding-gbk').TextDecoder;
// @ts-ignore
global.TextDecoder = origDecoder;
// @ts-ignore
global.TextEncoder = origEncoder;
return dec; return dec;
} catch {
return origDecoder;
} finally {
g.TextDecoder = origDecoder;
g.TextEncoder = origEncoder;
}
})(); })();
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useImportStore } from '../../store/importStore'; import { useImportStore } from '../../store/importStore';
import { useMetadataStore } from '../../store/metadataStore'; import { useMetadataStore } from '../../store/metadataStore';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/ui/Button';
import { DedupBanner } from '../../components/DedupBanner'; import { DedupBanner } from '../../components/transaction/DedupBanner';
import { classifyWithCategories } from '../../domain/rules'; import { ScreenHeader } from '../../components/layout/ScreenHeader';
import { validateTransaction } from '../../domain/ledger'; import { classifyWithCategories } from '../../domain/rules/rules';
import type { ImportedEvent } from '../../domain/types'; import { validateTransaction } from '../../domain/core/ledger';
import type { ImportedEvent } from '../../domain/core/types';
// 纯 JS 实现 Base64 解码为字节数组 // 纯 JS 实现 Base64 解码为字节数组
@@ -67,7 +70,6 @@ function base64ToBytes(base64: string): Uint8Array {
export default function ImportScreen() { export default function ImportScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction); const addTransaction = useLedgerStore(s => s.addTransaction);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts); const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
@@ -117,7 +119,7 @@ export default function ImportScreen() {
try { try {
const utf8Decoder = new TextDecoder('utf-8', { fatal: true }); const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
content = utf8Decoder.decode(bytes); content = utf8Decoder.decode(bytes);
} catch (e) { } catch {
// UTF-8 校验失败,回退到国标 GBK 解码(利用 text-encoding-gbk 保证 Hermes 兼容) // UTF-8 校验失败,回退到国标 GBK 解码(利用 text-encoding-gbk 保证 Hermes 兼容)
try { try {
const gbkDecoder = new GbkTextDecoder('gbk'); const gbkDecoder = new GbkTextDecoder('gbk');
@@ -143,7 +145,7 @@ export default function ImportScreen() {
// 6. 自动执行 Pipeline,用户无需手动查找运行按钮 // 6. 自动执行 Pipeline,用户无需手动查找运行按钮
if (ledger) { if (ledger) {
setStatus(t('importFlow.pipelineRunning')); setStatus(t('importFlow.pipelineRunning'));
processEvents(ledger, []).then(result => { processEvents(ledger, ledger.transactions || []).then(result => {
setManualDuplicates(result.duplicates); setManualDuplicates(result.duplicates);
setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length })); setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length }));
}).catch(e => { }).catch(e => {
@@ -158,7 +160,7 @@ export default function ImportScreen() {
const runPipeline = () => { const runPipeline = () => {
if (!ledger) return; if (!ledger) return;
processEvents(ledger, []).then(result => { processEvents(ledger, ledger.transactions || []).then(result => {
setManualDuplicates(result.duplicates); setManualDuplicates(result.duplicates);
setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount })); setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount }));
}).catch(e => setStatus(String(e))); }).catch(e => setStatus(String(e)));
@@ -245,7 +247,7 @@ export default function ImportScreen() {
confirmDrafts(indices); confirmDrafts(indices);
const successCount = drafts.length; const successCount = drafts.length;
const failCount = pendingDrafts.length - successCount; const failCount = failedPayees.length;
if (failCount === 0) { if (failCount === 0) {
setStatus(t('importFlow.batchAllSuccess', { count: successCount })); setStatus(t('importFlow.batchAllSuccess', { count: successCount }));
@@ -269,11 +271,59 @@ export default function ImportScreen() {
const rules = useMetadataStore.getState().rules; const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories; const categories = useMetadataStore.getState().categories;
const classification = classifyWithCategories(event, rules, categories, ledger); const classification = classifyWithCategories(event, rules, categories, ledger);
await addTransaction(classification.draft); await addTransaction(classification.draft, undefined, true);
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id)); setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
setStatus(t('importFlow.forcedImport')); setStatus(t('importFlow.forcedImport'));
Alert.alert('记账成功', `已成功将「${event.counterparty || event.memo || '交易'}」单独记入账本`);
} catch (err) { } catch (err) {
setStatus(String(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));
} }
}; };
@@ -283,16 +333,11 @@ export default function ImportScreen() {
}; };
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<View style={styles.header}> <ScreenHeader title={t('tab.import')} />
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, marginLeft: 8 }]}>{t('tab.import')}</Text>
</View>
<FlatList <FlatList
data={pendingDrafts} data={pendingDrafts}
keyExtractor={(item, index) => `${item.draft.sourceEventId || index}-${index}`} keyExtractor={(item, index) => `${item.draft.sourceEventIds?.[0] || index}-${index}`}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
<Card title={`${item.draft.date} · ${item.isTransfer ? t('importFlow.transfer') : t('importFlow.trade')} · ${item.draft.postings[0]?.amount ?? ''}`}> <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.body, { color: theme.colors.fgPrimary }]}>{item.draft.narration}</Text>
@@ -349,23 +394,45 @@ export default function ImportScreen() {
{showDuplicates && ( {showDuplicates && (
<View style={styles.duplicatesList}> <View style={styles.duplicatesList}>
{manualDuplicates.map((item) => { {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 = { const result = {
isDuplicate: true, isDuplicate: true,
confidence: 'medium' as const, confidence: (detail?.confidence ?? 'medium') as DedupConfidence,
reason: t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') }) reason,
}; };
return ( return (
<Card key={item.id} title={`${item.occurredAt} · ${item.counterparty}`}> <Card
key={item.id}
title={`${item.occurredAt} · ${item.counterparty || t('importFlow.unknownSource')}`}
onPress={() => handleLinkDuplicate(item)}
>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{item.memo || t('importFlow.noDesc')} {item.memo || t('importFlow.noDesc')}
</Text> </Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })} {t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })}
</Text> </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 <DedupBanner
result={result} result={result}
onAccept={() => handleAcceptDuplicate(item)} onAccept={() => handleAcceptDuplicate(item)}
onReject={() => handleRejectDuplicate(item)} onReject={() => handleRejectDuplicate(item)}
onLink={() => handleLinkDuplicate(item)}
/> />
</Card> </Card>
); );
@@ -390,10 +457,9 @@ export default function ImportScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 64 }, content: { padding: 16, paddingBottom: 64 },
buttonCol: { gap: 8, marginTop: 4 }, buttonCol: { gap: 8, marginTop: 4 },
mono: { fontFamily: 'monospace', lineHeight: 20, marginTop: 8 }, mono: { fontVariant: ['tabular-nums'], lineHeight: 20, marginTop: 8 },
collapseHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 }, collapseHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
duplicatesList: { gap: 12, marginTop: 8 }, duplicatesList: { gap: 12, marginTop: 8 },
}); });
+83 -119
View File
@@ -1,121 +1,47 @@
import React, { useState } from 'react'; /**
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; *
import { SafeAreaView } from 'react-native-safe-area-context'; *
import { useRouter } from 'expo-router'; * (///) + //
* 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 { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/layout/ManagementScreen';
import type { RecurringTransaction } from '../../domain/recurring'; import { toDateString } from '../../domain/core/decimal';
import type { RecurringTransaction } from '../../domain/finance/recurring';
type ModalMode = { type: 'add' } | { type: 'edit'; item: RecurringTransaction } | null;
export default function RecurringScreen() { export default function RecurringScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const recurringTransactions = useMetadataStore(s => s.recurringTransactions); const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const addRecurring = useMetadataStore(s => s.addRecurringTransaction); const addRecurring = useMetadataStore(s => s.addRecurringTransaction);
const updateRecurring = useMetadataStore(s => s.updateRecurringTransaction); const updateRecurring = useMetadataStore(s => s.updateRecurringTransaction);
const removeRecurring = useMetadataStore(s => s.removeRecurringTransaction); const removeRecurring = useMetadataStore(s => s.removeRecurringTransaction);
const [modal, setModal] = useState<ModalMode>(null); // 用本地时区格式化,避免 toISOString 的 UTC 偏移导致负时区日期错位
const today = toDateString(new Date());
const handleDelete = (item: RecurringTransaction) => {
Alert.alert(t('recurring.deleteTitle'), t('recurring.deleteConfirm', { name: item.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: () => removeRecurring(item.id),
},
]);
};
const handleConfirm = (values: Record<string, string>) => {
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 any;
const dateVal = values.nextDueDate?.trim() || new Date().toISOString().slice(0, 10);
if (!nameVal || !fromAccount || !toAccount || !amountVal) {
Alert.alert(t('recurring.inputError'), t('recurring.inputErrorDesc'));
return;
}
const draft = {
date: dateVal,
narration: nameVal,
postings: [
{ account: fromAccount, amount: `-${amountVal}`, currency: 'CNY' },
{ account: toAccount, amount: amountVal, currency: 'CNY' },
],
};
if (modal?.type === 'add') {
addRecurring({
id: 'rec_' + generateId(),
name: nameVal,
draft,
frequency: freq,
interval: 1,
nextDueDate: dateVal,
enabled: true,
});
setModal(null);
Alert.alert(t('recurring.addSuccess'), t('recurring.addSuccessDesc', { name: nameVal }));
} else if (modal?.type === 'edit') {
updateRecurring(modal.item.id, {
name: nameVal,
draft,
frequency: freq,
nextDueDate: dateVal,
});
setModal(null);
Alert.alert(t('recurring.editSuccess'), t('recurring.editSuccessDesc', { name: nameVal }));
}
};
const fieldsFor = (item?: RecurringTransaction): FormField[] => [
{ key: 'name', label: t('recurring.fieldName'), placeholder: t('recurring.fieldNamePlaceholder'), defaultValue: item?.name },
{ key: 'fromAccount', label: t('recurring.fieldFromAccount'), placeholder: '例如: Assets:支付宝余额', defaultValue: item?.draft.postings[0]?.account?.replace(/^-/, '') || 'Assets:支付宝余额' },
{ key: 'toAccount', label: t('recurring.fieldToAccount'), placeholder: '例如: Expenses:Shopping', defaultValue: item?.draft.postings[1]?.account || 'Expenses:Shopping' },
{ key: 'amount', label: t('recurring.fieldAmount'), placeholder: '例如: 6.00', defaultValue: item?.draft.postings[1]?.amount },
{ key: 'frequency', label: t('recurring.fieldFrequency'), placeholder: 'monthly', defaultValue: item?.frequency || 'monthly' },
{ key: 'nextDueDate', label: t('recurring.fieldNextDue'), placeholder: 'YYYY-MM-DD', defaultValue: item?.nextDueDate || new Date().toISOString().slice(0, 10) },
];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <ManagementScreen<RecurringTransaction>
<View style={styles.header}> title={t('recurring.title')}
<Pressable onPress={() => router.back()}> items={recurringTransactions}
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /> keyExtractor={item => item.id}
</Pressable> addLabel={t('recurring.add')}
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}> emptyText={t('recurring.empty')}
{t('recurring.title')} renderItem={(item, { openEdit, confirmDelete }) => {
</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Pressable onPress={() => setModal({ type: 'add' })} 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('recurring.add')}
</Text>
</Pressable>
{recurringTransactions.map(item => {
const amount = item.draft.postings[1]?.amount || '0.00'; const amount = item.draft.postings[1]?.amount || '0.00';
const fromAcc = item.draft.postings[0]?.account || ''; const fromAcc = item.draft.postings[0]?.account || '';
const toAcc = item.draft.postings[1]?.account || ''; const toAcc = item.draft.postings[1]?.account || '';
return ( return (
<Card key={item.id} title={item.name}> <Card title={item.name}>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
@@ -137,9 +63,10 @@ export default function RecurringScreen() {
<Text style={[theme.typography.body, { color: theme.colors.financial.expense, fontWeight: '700' }]}>{amount} CNY</Text> <Text style={[theme.typography.body, { color: theme.colors.financial.expense, fontWeight: '700' }]}>{amount} CNY</Text>
</View> </View>
{/* 显式编辑/删除按钮(本页不用长按手势,交互更显式) */}
<View style={styles.cardActions}> <View style={styles.cardActions}>
<Pressable <Pressable
onPress={() => setModal({ type: 'edit', item })} onPress={openEdit}
style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]} style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]}
> >
<Ionicons name="create-outline" size={14} color={theme.colors.accent} /> <Ionicons name="create-outline" size={14} color={theme.colors.accent} />
@@ -148,8 +75,8 @@ export default function RecurringScreen() {
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => handleDelete(item)} onPress={confirmDelete}
style={[styles.closeBtn, { borderColor: theme.colors.border }]} style={[styles.actionBtn, { borderColor: theme.colors.border }]}
> >
<Ionicons name="trash-outline" size={14} color={theme.colors.error} /> <Ionicons name="trash-outline" size={14} color={theme.colors.error} />
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}> <Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
@@ -159,33 +86,70 @@ export default function RecurringScreen() {
</View> </View>
</Card> </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;
{recurringTransactions.length === 0 && ( if (!nameVal || !fromAccount || !toAccount || !amountVal) {
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}> Alert.alert(t('recurring.inputError'), t('recurring.inputErrorDesc'));
{t('recurring.empty')} return false;
</Text> }
)}
</ScrollView>
<FormModal const draft = {
visible={modal !== null} date: dateVal,
title={modal?.type === 'edit' ? t('recurring.editTitle') : t('recurring.addTitle')} narration: nameVal,
fields={fieldsFor(modal?.type === 'edit' ? modal.item : undefined)} postings: [
onConfirm={handleConfirm} { account: fromAccount, amount: `-${amountVal}`, currency: 'CNY' },
onCancel={() => setModal(null)} { 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')}
/> />
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' }, infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' },
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 }, cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 }, actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
closeBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
}); });
+40 -76
View File
@@ -1,106 +1,70 @@
/** /**
* *
* ${placeholder} * ${placeholder}
* P5 ManagementScreen //
*/ */
import React, { useState } from 'react'; import React from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/layout/ManagementScreen';
type ModalMode = { type: 'add' } | { type: 'edit'; tpl: { id: string; name: string; template: string } } | null; interface RemarkTemplate {
id: string;
name: string;
template: string;
}
export default function RemarkTemplateScreen() { export default function RemarkTemplateScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const templates = useMetadataStore(s => s.remarkTemplates); const templates = useMetadataStore(s => s.remarkTemplates);
const addTemplate = useMetadataStore(s => s.addRemarkTemplate); const addTemplate = useMetadataStore(s => s.addRemarkTemplate);
const updateTemplate = useMetadataStore(s => s.updateRemarkTemplate); const updateTemplate = useMetadataStore(s => s.updateRemarkTemplate);
const removeTemplate = useMetadataStore(s => s.removeRemarkTemplate); const removeTemplate = useMetadataStore(s => s.removeRemarkTemplate);
const [modal, setModal] = useState<ModalMode>(null);
const handleDelete = (tpl: { id: string; name: string }) => {
Alert.alert(t('remark.deleteTitle'), t('remark.deleteConfirm', { name: tpl.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeTemplate(tpl.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const name = values.name?.trim() || t('common.untitled');
const template = values.template?.trim() || '';
if (modal?.type === 'add') {
addTemplate({ id: generateId('tpl'), name, template });
} else if (modal?.type === 'edit') {
updateTemplate(modal.tpl.id, { name, template });
}
setModal(null);
};
const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮', defaultValue: modal.tpl.name },
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}', defaultValue: modal.tpl.template },
] : [];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <ManagementScreen<RemarkTemplate>
<View style={styles.header}> title={t('remark.title')}
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable> items={templates}
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('remark.title')}</Text> keyExtractor={tpl => tpl.id}
</View> addLabel={t('remark.add')}
<ScrollView contentContainerStyle={styles.content}> emptyText={t('remark.empty')}
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}> renderItem={(tpl, { openEdit, confirmDelete }) => (
<Ionicons name="add-circle" size={20} color={theme.colors.accent} /> <Pressable onPress={openEdit} onLongPress={confirmDelete}>
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('remark.add')}</Text>
</Pressable>
{templates.map(tpl => (
<Pressable
key={tpl.id}
onPress={() => setModal({ type: 'edit', tpl })}
onLongPress={() => handleDelete(tpl)}
>
<Card title={tpl.name}> <Card title={tpl.name}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
{tpl.template} {tpl.template}
</Text> </Text>
</Card> </Card>
</Pressable> </Pressable>
))}
{templates.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
{t('remark.empty')}
</Text>
)} )}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> 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')} {t('common.clickEditLongDelete')}
</Text> </Text>
</ScrollView> }
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('remark.editTitle') : t('remark.add')}
fields={modal?.type === 'edit' ? editFields : [
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮' },
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}' },
]}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/> />
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 4 },
});
+51 -89
View File
@@ -3,71 +3,27 @@
* *
* ( ) + // * ( ) + //
* BillPipeline ( AutoAccounting RuleGenerator) * BillPipeline ( AutoAccounting RuleGenerator)
* P5 ManagementScreen //
*/ */
import React, { useState } from 'react'; import React from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, 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 { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/layout/ManagementScreen';
import type { Rule } from '../../domain/types'; import type { Rule } from '../../domain/core/types';
type ModalMode = { type: 'add' } | { type: 'edit'; rule: Rule } | null;
export default function RulesScreen() { export default function RulesScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const rules = useMetadataStore(s => s.rules); const rules = useMetadataStore(s => s.rules);
const addRule = useMetadataStore(s => s.addRule); const addRule = useMetadataStore(s => s.addRule);
const updateRule = useMetadataStore(s => s.updateRule); const updateRule = useMetadataStore(s => s.updateRule);
const removeRule = useMetadataStore(s => s.removeRule); const removeRule = useMetadataStore(s => s.removeRule);
const [modal, setModal] = useState<ModalMode>(null);
// 按 priority 降序排列
const sorted = [...rules].sort((a, b) => b.priority - a.priority);
const handleDelete = (rule: Rule) => {
Alert.alert(t('rules.deleteTitle'), t('rules.deleteConfirm', { name: rule.id }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeRule(rule.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
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:Uncategorized',
narration: values.narration?.trim() || undefined,
tags: values.tags ? values.tags.split(/[,\s]+/).filter(Boolean) : [],
};
if (modal?.type === 'add') {
addRule({ ...rule, id: generateId('rule'), hits: 0 });
} else if (modal?.type === 'edit') {
updateRule(modal.rule.id, rule);
}
setModal(null);
};
const fieldsFor = (rule?: Rule): FormField[] => [
{ key: 'priority', label: t('rules.fieldPriority'), placeholder: '100', defaultValue: rule ? String(rule.priority) : '', keyboardType: 'numeric' },
{ key: 'counterpartyContains', label: t('rules.fieldCounterparty'), placeholder: '咖啡', defaultValue: rule?.counterpartyContains ?? '' },
{ key: 'memoContains', label: t('rules.fieldMemo'), placeholder: '', defaultValue: rule?.memoContains ?? '' },
{ key: 'sourceAccount', label: t('rules.fieldSourceAccount'), placeholder: 'Assets:支付宝余额', defaultValue: rule?.sourceAccount ?? '' },
{ key: 'categoryAccount', label: t('rules.fieldCategoryAccount'), placeholder: 'Expenses:餐饮', defaultValue: rule?.categoryAccount ?? '' },
{ key: 'narration', label: t('rules.fieldNarration'), placeholder: '咖啡', defaultValue: rule?.narration ?? '' },
{ key: 'tags', label: t('rules.fieldTags'), placeholder: 'food', defaultValue: rule?.tags?.join(', ') ?? '' },
];
const formatCondition = (rule: Rule): string => { const formatCondition = (rule: Rule): string => {
const parts: string[] = []; const parts: string[] = [];
if (rule.counterpartyContains) parts.push(t('rules.condCounterparty', { val: rule.counterpartyContains })); if (rule.counterpartyContains) parts.push(t('rules.condCounterparty', { val: rule.counterpartyContains }));
@@ -76,30 +32,19 @@ export default function RulesScreen() {
}; };
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <ManagementScreen<Rule>
<View style={styles.header}> title={t('tab.rules')}
<Pressable onPress={() => router.back()}> items={[...rules].sort((a, b) => b.priority - a.priority)}
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /> keyExtractor={rule => rule.id}
</Pressable> addLabel={t('rules.add')}
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, marginLeft: 8 }]}>{t('tab.rules')}</Text> emptyText={t('rules.empty')}
</View> renderItem={(rule, { openEdit, confirmDelete }) => (
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}> <Card>
<Pressable onPress={() => setModal({ type: 'add' })} 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('rules.add')}</Text>
</Pressable>
<Card title={t('rules.title')}>
{sorted.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('rules.empty')}</Text>
)}
{sorted.map(rule => (
<Pressable <Pressable
key={rule.id} onPress={openEdit}
onPress={() => setModal({ type: 'edit', rule })} onLongPress={confirmDelete}
onLongPress={() => handleDelete(rule)} style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}
style={({ pressed }) => [styles.row, { borderTopColor: theme.colors.divider, opacity: pressed ? 0.6 : 1 }]}
> >
<View style={{ flex: 1 }}>
<View style={styles.ruleHeader}> <View style={styles.ruleHeader}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}>{rule.narration || rule.id}</Text> <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> <Text style={[theme.typography.caption, { color: theme.colors.accent }]}>P{rule.priority}</Text>
@@ -109,35 +54,52 @@ export default function RulesScreen() {
</Text> </Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}>
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} /> <Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })}) {rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
</Text> </Text>
</View> </View>
</View>
</Pressable> </Pressable>
))}
</Card> </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' }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')} {t('common.clickEditLongDelete')}
</Text> </Text>
</ScrollView> }
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('rules.editTitle') : t('rules.add')}
fields={fieldsFor(modal?.type === 'edit' ? modal.rule : undefined)}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/> />
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16 },
addBtn: { flexDirection: 'row', alignItems: 'center' },
row: { borderTopWidth: 1, paddingTop: 10, gap: 3 },
ruleHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, ruleHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
}); });
+115 -113
View File
@@ -1,156 +1,158 @@
/**
* LLM / AI P8
*
*
* - AI openai / gemini / deepseek
* - API Key
* - Base URL
* -
* - AI
*/
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Switch, Alert } from 'react-native'; import { KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { createCommonStyles } from '../../theme/commonStyles';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { Button } from '../../components/Button'; import { ScreenHeader } from '../../components/layout/ScreenHeader';
import { FormModal, type FormField } from '../../components/FormModal';
export default function AISettingsScreen() { 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 { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT(); const t = useT();
const router = useRouter();
// Settings State
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 aiEnabled = useSettingsStore(s => s.aiEnabled); const aiEnabled = useSettingsStore(s => s.aiEnabled);
const aiProviderId = useSettingsStore(s => s.aiProviderId); const aiProviderId = useSettingsStore(s => s.aiProviderId);
const aiApiKey = useSettingsStore(s => s.aiApiKey) || ''; const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || 'https://api.openai.com/v1'; const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || '';
const aiModel = useSettingsStore(s => s.aiModel) || 'gpt-4o-mini'; const aiModel = useSettingsStore(s => s.aiModel) || '';
const updateAiConfig = useSettingsStore(s => s.updateAiConfig); const updateAiConfig = useSettingsStore(s => s.updateAiConfig);
const [aiModalVisible, setAiModalVisible] = useState(false); const [localKey, setLocalKey] = useState(aiApiKey);
const [localUrl, setLocalUrl] = useState(aiBaseUrl);
const [localModel, setLocalModel] = useState(aiModel);
const aiFields: FormField[] = [ const inputStyle = {
{ key: 'apiKey', label: t('settings.aiFieldApiKey'), placeholder: 'sk-xxxxxx', defaultValue: aiApiKey }, backgroundColor: theme.colors.bgTertiary,
{ key: 'baseUrl', label: t('settings.aiFieldBaseUrl'), placeholder: 'https://api.openai.com/v1', defaultValue: aiBaseUrl }, color: theme.colors.fgPrimary,
{ key: 'model', label: t('settings.aiFieldModel'), placeholder: 'gpt-4o-mini', defaultValue: aiModel }, borderRadius: theme.radii.sm,
]; padding: 12,
fontSize: 14,
const saveAI = (values: Record<string, string>) => { marginTop: 6,
updateAiConfig({
aiApiKey: values.apiKey,
aiBaseUrl: values.baseUrl,
aiModel: values.model,
});
setAiModalVisible(false);
}; };
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<View style={styles.header}> <ScreenHeader title={t('settings.llmTitle')} />
<Pressable onPress={() => router.back()}> <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /> <ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]} keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive">
</Pressable> {/* AI 总开关 */}
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}> <Card>
{t('settings.aiSettingsTitle')} <View style={styles.row}>
</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
{/* 自动记账与算法开关 */}
<Card title={t('settings.algorithmConfig')}>
<View style={styles.switchRow}>
<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, { marginTop: 12 }]}>
<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>
{/* AI 智能助理配置 */}
<Card title={t('settings.aiAssistantTitle')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.enableAiHint')} {t('settings.enableAiHint')}
</Text> </Text>
<Switch <Switch
value={aiEnabled} value={aiEnabled}
onValueChange={(val) => updateAiConfig({ aiEnabled: val })} onValueChange={val => updateAiConfig({ aiEnabled: val })}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }} trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse} thumbColor={theme.colors.fgInverse}
/> />
</View> </View>
{aiEnabled && ( </Card>
<View style={{ marginTop: 12, gap: 8 }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> {/* 服务商选择 */}
{t('settings.aiProvider')} <Card title={t('automation.aiProviderLabel')}>
</Text> <View style={styles.chipRow}>
<View style={styles.optionRow}> {PROVIDERS.map(p => (
{['openai', 'gemini', 'deepseek'].map((provider) => {
const active = aiProviderId === provider;
return (
<Pressable <Pressable
key={provider} key={p.key}
onPress={() => updateAiConfig({ aiProviderId: provider as any })} onPress={() => updateAiConfig({ aiProviderId: p.key as 'openai' | 'gemini' | 'deepseek' })}
style={[ style={[
styles.option, commonStyles.chip,
{ aiProviderId === p.key && commonStyles.chipActive,
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : theme.colors.border,
},
]} ]}
> >
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontWeight: active ? '700' : '400' }}> <Text style={[commonStyles.chipText, aiProviderId === p.key && commonStyles.chipTextActive]}>
{provider.toUpperCase()} {p.label}
</Text> </Text>
</Pressable> </Pressable>
); ))}
})}
</View> </View>
<View style={{ marginTop: 4 }}> </Card>
<Button label={t('settings.aiConfigBtn')} onPress={() => setAiModalVisible(true)} variant="secondary" />
</View> {/* API Key */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}> <Card title={t('settings.aiFieldApiKey')}>
{t('settings.aiCurrentModel', { model: aiModel, url: aiBaseUrl })} <TextInput
</Text> style={inputStyle}
</View> 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> </Card>
</ScrollView> </ScrollView>
</KeyboardAvoidingView>
{/* AI 配置模态框 */}
<FormModal
visible={aiModalVisible}
title={t('settings.aiConfigTitle')}
fields={aiFields}
onConfirm={saveAI}
onCancel={() => setAiModalVisible(false)}
/>
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 }, content: { padding: 16, paddingBottom: 64 },
content: { padding: 16, gap: 16 }, row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, chipRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' },
optionRow: { flexDirection: 'row', gap: 8 },
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 },
}); });
+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 },
});
+55 -31
View File
@@ -1,17 +1,17 @@
import React from 'react'; import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View, Switch } from 'react-native'; import { Alert, Pressable, ScrollView, StyleSheet, Text, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { TimePicker } from '../../components/ui/TimePicker';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useSettingsStore, type Locale, type ThemeMode } from '../../store/settingsStore'; import { useSettingsStore, type Locale, type ThemeMode } from '../../store/settingsStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
export default function PreferencesScreen() { export default function PreferencesScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
// Settings State // Settings State
const themeMode = useSettingsStore(s => s.themeMode); const themeMode = useSettingsStore(s => s.themeMode);
@@ -24,6 +24,9 @@ export default function PreferencesScreen() {
const reminderHour = useSettingsStore(s => s.reminderHour); const reminderHour = useSettingsStore(s => s.reminderHour);
const reminderMinute = useSettingsStore(s => s.reminderMinute); const reminderMinute = useSettingsStore(s => s.reminderMinute);
const updateReminderConfig = useSettingsStore(s => s.updateReminderConfig); 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 }[] = [ const THEME_OPTIONS: { mode: ThemeMode; labelKey: string }[] = [
{ mode: 'light', labelKey: 'settings.themeLight' }, { mode: 'light', labelKey: 'settings.themeLight' },
@@ -37,17 +40,10 @@ export default function PreferencesScreen() {
]; ];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<View style={styles.header}> <ScreenHeader title={t('settings.preferencesTitle')} />
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('settings.preferencesTitle')}
</Text>
</View>
<ScrollView contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 外观设置 */} {/* 外观设置 */}
<Card title={t('settings.appearance')}> <Card title={t('settings.appearance')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, marginBottom: 8 }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
@@ -65,6 +61,7 @@ export default function PreferencesScreen() {
{ {
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary, backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : theme.colors.border, borderColor: active ? theme.colors.accent : theme.colors.border,
borderRadius: theme.radii.sm,
}, },
]} ]}
> >
@@ -94,6 +91,7 @@ export default function PreferencesScreen() {
{ {
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary, backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : theme.colors.border, borderColor: active ? theme.colors.accent : theme.colors.border,
borderRadius: theme.radii.sm,
}, },
]} ]}
> >
@@ -106,6 +104,24 @@ export default function PreferencesScreen() {
</View> </View>
</Card> </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')}> <Card title={t('settings.security')}>
<View style={styles.switchRow}> <View style={styles.switchRow}>
@@ -117,7 +133,7 @@ export default function PreferencesScreen() {
onValueChange={async (val) => { onValueChange={async (val) => {
if (val) { if (val) {
// 开启:需要设置 PIN // 开启:需要设置 PIN
const { hasPinSet, setPin } = await import('../../components/LockScreen'); const { hasPinSet } = await import('../../components/layout/LockScreen');
const hasPin = await hasPinSet(); const hasPin = await hasPinSet();
if (!hasPin) { if (!hasPin) {
// Alert.prompt 仅 iOS 可用;Android 直接开启(用户首次锁屏时设置) // Alert.prompt 仅 iOS 可用;Android 直接开启(用户首次锁屏时设置)
@@ -143,7 +159,7 @@ export default function PreferencesScreen() {
} }
} else { } else {
// 关闭:清除 PIN // 关闭:清除 PIN
const { clearPin } = await import('../../components/LockScreen'); const { clearPin } = await import('../../components/layout/LockScreen');
await clearPin(); await clearPin();
setAppLockEnabled(false); setAppLockEnabled(false);
} }
@@ -185,34 +201,42 @@ export default function PreferencesScreen() {
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.reminderTime')} {t('settings.reminderTime')}
</Text> </Text>
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700' }]}> <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')} {String(reminderHour).padStart(2, '0')}:{String(reminderMinute).padStart(2, '0')}
</Text> </Text>
<Pressable <Ionicons name="chevron-forward" size={16} color={theme.colors.accent} />
onPress={async () => {
// 简单的时间调整:小时 +1 循环
const newHour = (reminderHour + 1) % 24;
updateReminderConfig({ reminderHour: newHour });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
}}
style={{ marginLeft: 12, padding: 4 }}
>
<Ionicons name="time-outline" size={20} color={theme.colors.accent} />
</Pressable> </Pressable>
</View> </View>
)} )}
</Card> </Card>
</ScrollView> </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> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 }, content: { padding: 16 },
content: { padding: 16, gap: 16 },
optionRow: { flexDirection: 'row', gap: 8 }, optionRow: { flexDirection: 'row', gap: 8 },
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 }, option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1 },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
}); });
+23 -32
View File
@@ -1,30 +1,29 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Platform } from 'react-native'; import { ScrollView, StyleSheet, Text, View, Alert, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker'; import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing'; import * as Sharing from 'expo-sharing';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore, type PersistableSettings } from '../../store/settingsStore';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useImportStore } from '../../store/importStore'; import { useImportStore } from '../../store/importStore';
import { useMetadataStore } from '../../store/metadataStore'; import { useMetadataStore, type PersistableMetadata } from '../../store/metadataStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/ui/Button';
import { FormModal, type FormField } from '../../components/FormModal'; import { FormModal, type FormField } from '../../components/form/FormModal';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
import { syncWithWebDAV, WebDAVClient, type FetchImpl } from '../../services/sync/webdavSync'; import { syncWithWebDAV, WebDAVClient, type FetchImpl } from '../../services/sync/webdavSync';
import { syncWithGit, ExpoGitBackend } from '../../services/sync/gitSync'; import { syncWithGit, ExpoGitBackend } from '../../services/sync/gitSync';
import { syncWithICloud, MockICloudFileSystem } from '../../services/sync/icloudSync'; import { syncWithICloud, MockICloudFileSystem } from '../../services/sync/icloudSync';
import { createBackupBundle, serializeBundle, deserializeBundle, restoreFiles } from '../../services/backup'; import { createBackupBundle, serializeBundle, deserializeBundle, restoreFiles } from '../../services/data/backup';
import { runMaintenance, ExpoMaintenanceFs, ExpoMaintenanceDb } from '../../services/maintenance'; import type { ExcelWorkbook } from '../../services/data/exportToExcel';
import { runMaintenance, ExpoMaintenanceFs, ExpoMaintenanceDb } from '../../services/data/maintenance';
export default function SyncSettingsScreen() { export default function SyncSettingsScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
// Sync Configuration // Sync Configuration
const webdavUrl = useSettingsStore(s => s.webdavUrl) || ''; const webdavUrl = useSettingsStore(s => s.webdavUrl) || '';
@@ -235,13 +234,13 @@ export default function SyncSettingsScreen() {
const content = await FileSystem.readAsStringAsync(file.uri); const content = await FileSystem.readAsStringAsync(file.uri);
const bundle = deserializeBundle(content); const bundle = deserializeBundle(content);
const restoredFiles = restoreFiles(bundle); const restoredFiles = restoreFiles(bundle);
// 用恢复的 content 替换当前内容,支持新版 main.bean 并且兼容旧版 mobile.bean // 用恢复的 content 替换当前内容
const restoredMobileBean = restoredFiles.find(f => f.path === 'main.bean')?.content || restoredFiles.find(f => f.path === 'mobile.bean')?.content || ''; const restoredMainBean = restoredFiles.find(f => f.path === 'main.bean')?.content || '';
await replaceMobileBean(restoredMobileBean); await replaceMobileBean(restoredMainBean);
// 恢复设置(v2 // 恢复设置(v2
if (bundle.settings) { if (bundle.settings) {
useSettingsStore.getState().hydrate(bundle.settings as any); useSettingsStore.getState().hydrate(bundle.settings as Partial<PersistableSettings>);
// 同步持久化到文件 // 同步持久化到文件
const settingsPath = FileSystem.documentDirectory + 'settings.json'; const settingsPath = FileSystem.documentDirectory + 'settings.json';
await FileSystem.writeAsStringAsync(settingsPath, JSON.stringify(bundle.settings, null, 2)); await FileSystem.writeAsStringAsync(settingsPath, JSON.stringify(bundle.settings, null, 2));
@@ -249,7 +248,7 @@ export default function SyncSettingsScreen() {
// 恢复元数据(v2 // 恢复元数据(v2
if (bundle.metadata) { if (bundle.metadata) {
useMetadataStore.getState().hydrate(bundle.metadata as any); useMetadataStore.getState().hydrate(bundle.metadata as Partial<PersistableMetadata>);
// 同步持久化到文件 // 同步持久化到文件
const metadataPath = FileSystem.documentDirectory + 'metadata.json'; const metadataPath = FileSystem.documentDirectory + 'metadata.json';
await FileSystem.writeAsStringAsync(metadataPath, JSON.stringify(bundle.metadata, null, 2)); await FileSystem.writeAsStringAsync(metadataPath, JSON.stringify(bundle.metadata, null, 2));
@@ -286,7 +285,7 @@ export default function SyncSettingsScreen() {
const categories = useMetadataStore.getState().categories; const categories = useMetadataStore.getState().categories;
const loadLedger = useLedgerStore.getState().loadLedger; const loadLedger = useLedgerStore.getState().loadLedger;
const setContext = useImportStore.getState().setContext; const setContext = useImportStore.getState().setContext;
const { FileSystemBackend } = await import('../../services/fileSystemBackend'); const { FileSystemBackend } = await import('../../services/data/fileSystemBackend');
await loadLedger([{ path: 'main.bean', content }], new FileSystemBackend()); await loadLedger([{ path: 'main.bean', content }], new FileSystemBackend());
setContext({ rules, categories }); setContext({ rules, categories });
@@ -340,7 +339,7 @@ export default function SyncSettingsScreen() {
const handleExportExcel = async () => { const handleExportExcel = async () => {
try { try {
const XLSX = (await import('xlsx')).default; const XLSX = (await import('xlsx')).default;
const { exportToExcel } = await import('../../services/exportToExcel'); const { exportToExcel } = await import('../../services/data/exportToExcel');
const { transactions } = ledger!; const { transactions } = ledger!;
const workbook = XLSX.utils.book_new(); const workbook = XLSX.utils.book_new();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -348,7 +347,7 @@ export default function SyncSettingsScreen() {
let base64Content = ''; let base64Content = '';
// 用 xlsx 库创建 workbook wrapper // 用 xlsx 库创建 workbook wrapper
const wbWrapper = { const wbWrapper = {
addSheet: (name: string, rows: any[]) => { addSheet: (name: string, rows: Record<string, unknown>[]) => {
const ws = XLSX.utils.json_to_sheet(rows); const ws = XLSX.utils.json_to_sheet(rows);
XLSX.utils.book_append_sheet(workbook, ws, name); XLSX.utils.book_append_sheet(workbook, ws, name);
}, },
@@ -357,7 +356,7 @@ export default function SyncSettingsScreen() {
return FileSystem.writeAsStringAsync(path, base64Content, { encoding: FileSystem.EncodingType.Base64 }); return FileSystem.writeAsStringAsync(path, base64Content, { encoding: FileSystem.EncodingType.Base64 });
}, },
}; };
await exportToExcel(transactions, wbWrapper as any, exportPath); await exportToExcel(transactions, wbWrapper as unknown as ExcelWorkbook, exportPath);
// Android 特殊处理:使用 SAF 直接保存到本地公开目录 // Android 特殊处理:使用 SAF 直接保存到本地公开目录
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
@@ -455,17 +454,10 @@ export default function SyncSettingsScreen() {
const isAndroid = Platform.OS === 'android'; const isAndroid = Platform.OS === 'android';
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<View style={styles.header}> <ScreenHeader title={t('sync.title')} />
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('sync.title')}
</Text>
</View>
<ScrollView contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 云同步配置与云端操作 */} {/* 云同步配置与云端操作 */}
<Card title={t('sync.cloudTitle')}> <Card title={t('sync.cloudTitle')}>
<View style={styles.syncBtnRow}> <View style={styles.syncBtnRow}>
@@ -539,8 +531,7 @@ export default function SyncSettingsScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 }, content: { padding: 16 },
content: { padding: 16, gap: 16 },
syncBtnRow: { flexDirection: 'row', gap: 8 }, syncBtnRow: { flexDirection: 'row', gap: 8 },
buttonRow: { flexDirection: 'row', gap: 8 }, buttonRow: { flexDirection: 'row', gap: 8 },
}); });
+41 -90
View File
@@ -1,104 +1,55 @@
/** /**
* plan.md1.1 tag/index+ 1 * ManagementScreen P2
* *
* () + // * () + //
* .bean #tag , [\w-] * .bean #tag , [\w-]
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, Pressable, 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 { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { TAG_COLORS } from '../../theme/palette';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { ManagementScreen } from '../../components/layout/ManagementScreen';
import { FormModal, type FormField } from '../../components/FormModal'; import { isValidTagName } from '../../domain/taxonomy/tags';
import { isValidTagName } from '../../domain/tags'; import type { Tag } from '../../domain/taxonomy/tags';
import type { Tag } from '../../domain/tags';
type ModalMode = { type: 'add' } | { type: 'edit'; tag: Tag } | null;
const COLORS = ['#F44336', '#E91E63', '#9C27B0', '#2196F3', '#00BCD4', '#4CAF50', '#FF9800', '#795548'];
export default function TagScreen() { export default function TagScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const tags = useMetadataStore(s => s.tags); const tags = useMetadataStore(s => s.tags);
const addTag = useMetadataStore(s => s.addTag); const addTag = useMetadataStore(s => s.addTag);
const updateTag = useMetadataStore(s => s.updateTag); const updateTag = useMetadataStore(s => s.updateTag);
const removeTag = useMetadataStore(s => s.removeTag); const removeTag = useMetadataStore(s => s.removeTag);
const [modal, setModal] = useState<ModalMode>(null); const [selectedColor, setSelectedColor] = useState<string>(TAG_COLORS[0]);
const [selectedColor, setSelectedColor] = useState(COLORS[0]);
const handleDelete = (tag: Tag) => {
Alert.alert(t('tag.deleteTitle'), t('tag.deleteConfirm', { name: tag.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeTag(tag.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const name = values.name?.trim() ?? '';
if (!isValidTagName(name)) {
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
return;
}
if (modal?.type === 'add') {
addTag({ id: generateId('tag'), name, color: selectedColor });
} else if (modal?.type === 'edit') {
updateTag(modal.tag.id, { name, color: selectedColor });
}
setModal(null);
};
const openModal = (mode: ModalMode) => {
if (mode?.type === 'edit') setSelectedColor(mode.tag.color);
else setSelectedColor(COLORS[0]);
setModal(mode);
};
const editFields: FormField[] = modal?.type === 'edit'
? [{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: modal.tag.name }]
: [{ key: 'name', label: t('tag.fieldName'), placeholder: 'food' }];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <ManagementScreen<Tag>
<View style={styles.header}> title={t('tag.title')}
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable> items={tags}
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('tag.title')}</Text> keyExtractor={tag => tag.id}
</View> addLabel={t('tag.add')}
<ScrollView contentContainerStyle={styles.content}> emptyText={t('tag.empty')}
<Card title={t('tag.count', { count: tags.length })}> listStyle={styles.chipGrid}
<Pressable onPress={() => openModal({ type: 'add' })} style={styles.addBtn}> onOpenForm={editing => setSelectedColor(editing?.color ?? TAG_COLORS[0])}
<Ionicons name="add-circle" size={20} color={theme.colors.accent} /> renderItem={(tag, { openEdit, confirmDelete }) => (
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('tag.add')}</Text>
</Pressable>
<View style={[styles.grid, { gap: 8, paddingTop: 4 }]}>
{tags.map(tag => (
<Pressable <Pressable
key={tag.id} onPress={openEdit}
onPress={() => openModal({ type: 'edit', tag })} onLongPress={confirmDelete}
onLongPress={() => handleDelete(tag)}
style={[styles.chip, { backgroundColor: tag.color }]} style={[styles.chip, { backgroundColor: tag.color }]}
> >
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text> <Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
</Pressable> </Pressable>
))}
{tags.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('tag.empty')}</Text>
)} )}
</View> formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))}
</Card> formFields={editing => [
{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name },
{/* 颜色选择器(模态打开时显示) */} ]}
{modal && ( formExtra={() => (
<Card title={t('tag.selectColor')}>
<View style={styles.colorRow}> <View style={styles.colorRow}>
{COLORS.map(c => ( {TAG_COLORS.map(c => (
<Pressable <Pressable
key={c} key={c}
onPress={() => setSelectedColor(c)} onPress={() => setSelectedColor(c)}
@@ -110,32 +61,32 @@ export default function TagScreen() {
/> />
))} ))}
</View> </View>
</Card>
)} )}
onSubmit={(values, editing) => {
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> 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')} {t('common.clickEditLongDelete')}
</Text> </Text>
</ScrollView> }
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('tag.editTitle') : t('tag.add')}
fields={editFields}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/> />
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, chipGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
grid: { flexDirection: 'row', flexWrap: 'wrap' },
chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 }, chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
addBtn: { flexDirection: 'row', alignItems: 'center', paddingBottom: 8 },
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 }, colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
colorDot: { width: 36, height: 36, borderRadius: 18 }, colorDot: { width: 36, height: 36, borderRadius: 18 },
}); });
+169 -43
View File
@@ -8,20 +8,23 @@
* - .bean * - .bean
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Modal, TextInput, FlatList } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Modal, TextInput, FlatList, KeyboardAvoidingView, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../../theme'; import { useTheme, createCommonStyles } from '../../theme';
import { Card } from '../../components/Card'; import { Card } from '../../components/ui/Card';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { hash } from '../../domain/ledger'; import { useNumpadUiStore } from '../../store/numpadUiStore';
import { hash } from '../../domain/core/ledger';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { TransactionCard } from '../../components/TransactionCard'; import { TransactionCard } from '../../components/transaction/TransactionCard';
import { ScreenHeader } from '../../components/layout/ScreenHeader';
import { logger } from '../../utils/logger';
export default function TransactionDetailScreen() { export default function TransactionDetailScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = createCommonStyles(theme); const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT(); const t = useT();
const router = useRouter(); const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
@@ -43,12 +46,12 @@ export default function TransactionDetailScreen() {
return ledger.transactions.filter( return ledger.transactions.filter(
t => t.id !== id && t.links.some(l => tx.links.includes(l)) t => t.id !== id && t.links.some(l => tx.links.includes(l))
); );
}, [ledger, id, tx?.links]); }, [ledger, id, tx]);
// 筛选可以用来关联的其他账单 // 筛选可以用来关联的其他账单
const linkableTransactions = useMemo(() => { const linkableTransactions = useMemo(() => {
if (!ledger) return []; if (!ledger) return [];
return ledger.transactions const list = ledger.transactions
.filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l))) .filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l)))
.filter(t => { .filter(t => {
if (!searchQuery) return true; if (!searchQuery) return true;
@@ -59,17 +62,19 @@ export default function TransactionDetailScreen() {
t.postings.some(p => p.account.toLowerCase().includes(q)) t.postings.some(p => p.account.toLowerCase().includes(q))
); );
}) })
.reverse() // 解决截断 Bug:将顺序反转,优先展示最新的交易 .sort((a, b) => b.date.localeCompare(a.date)); // 显式按交易日期【由新到旧 (最新 ➔ 最旧)】严格倒序排列
.slice(0, 30);
// 有搜索条件时展示最多 100 条匹配结果,无搜索词时默认展示最新 50 条
return searchQuery ? list.slice(0, 100) : list.slice(0, 50);
}, [ledger, id, tx?.links, searchQuery]); }, [ledger, id, tx?.links, searchQuery]);
const handleLinkTransaction = async (targetTx: typeof tx) => { const handleLinkTransaction = async (targetTx: typeof tx) => {
if (!tx || !targetTx) return; if (!tx || !targetTx) return;
let linkTag = tx.links[0] || targetTx.links[0] || '';
try { try {
const { mobileBean, replaceMobileBean } = useLedgerStore.getState(); const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
// 取出已有链接标签,若没有则生成一个随机哈希标签 // 取出已有链接标签,若没有则生成一个随机哈希标签
let linkTag = tx.links[0] || targetTx.links[0];
if (!linkTag) { if (!linkTag) {
linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8); linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8);
} }
@@ -97,10 +102,33 @@ export default function TransactionDetailScreen() {
const newTargetRaw = addLinkToRaw(targetTx.raw, linkTag); const newTargetRaw = addLinkToRaw(targetTx.raw, linkTag);
let newContent = mobileBean; let newContent = mobileBean;
newContent = newContent.replace(tx.raw, newCurrentRaw); newContent = newContent.replaceAll(tx.raw, newCurrentRaw);
newContent = newContent.replace(targetTx.raw, newTargetRaw); 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); 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); setLinkModalVisible(false);
// 解决 race condition:直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏 // 解决 race condition:直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏
@@ -110,6 +138,7 @@ export default function TransactionDetailScreen() {
} }
Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag })); Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag }));
} catch (e) { } catch (e) {
logger.error('ledgerStore', `关联交易失败 (Tag: ^${linkTag})`, e);
Alert.alert(t('transaction.linkFail'), String(e)); Alert.alert(t('transaction.linkFail'), String(e));
} }
}; };
@@ -132,13 +161,31 @@ export default function TransactionDetailScreen() {
lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), ''); lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), '');
const newRaw = lines.join('\n'); const newRaw = lines.join('\n');
newContent = newContent.replace(companion.raw, newRaw); newContent = newContent.replaceAll(companion.raw, newRaw);
if (companion.id === tx.id) { if (companion.id === tx.id) {
newCurrentRaw = newRaw; 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); 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 // 解决 race condition:直接使用 hash() 算法计算新 ID
const newId = hash(`main.bean:${newCurrentRaw}`); const newId = hash(`main.bean:${newCurrentRaw}`);
@@ -147,6 +194,7 @@ export default function TransactionDetailScreen() {
} }
Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag })); Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag }));
} catch (e) { } catch (e) {
logger.error('ledgerStore', `解除交易关联失败 (Tag: ^${linkTag})`, e);
Alert.alert(t('transaction.unlinkFail'), String(e)); Alert.alert(t('transaction.unlinkFail'), String(e));
} }
}; };
@@ -154,10 +202,7 @@ export default function TransactionDetailScreen() {
if (!tx) { if (!tx) {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<View style={styles.header}> <ScreenHeader title={t('common.notFound')} />
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('common.notFound')}</Text>
</View>
<View style={styles.content}> <View style={styles.content}>
<Card title={t('transaction.notFoundTitle')}> <Card title={t('transaction.notFoundTitle')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
@@ -178,11 +223,8 @@ export default function TransactionDetailScreen() {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<View style={styles.header}> <ScreenHeader title={t('transaction.detailTitle')} />
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable> <ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('transaction.detailTitle')}</Text>
</View>
<ScrollView contentContainerStyle={[styles.content, { gap: 12 }]}>
{/* 摘要卡片 */} {/* 摘要卡片 */}
<Card title={t('transaction.summary')}> <Card title={t('transaction.summary')}>
<View style={styles.summaryRow}> <View style={styles.summaryRow}>
@@ -200,39 +242,111 @@ export default function TransactionDetailScreen() {
</View> </View>
</Card> </Card>
{/* Postings */} {/* 资金流向 */}
<Card title={`${t('transaction.postings')} (${tx.postings.length})`}> <Card title={`${t('transaction.postings')} (${tx.postings.length})`}>
{tx.postings.map((p, i) => ( <View style={{ gap: 8 }}>
<View key={i} style={[styles.postingRow, { borderTopColor: theme.colors.divider, borderTopWidth: i > 0 ? 1 : 0 }]}> {tx.postings.map((p, i) => {
<View style={{ flex: 1 }}> const parts = p.account.split(':');
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}> const rootType = parts[0];
{p.account} 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> </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]) => ( {p.metadata && Object.entries(p.metadata).map(([k, v]) => (
<Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{k}: {v} {k}: {v}
</Text> </Text>
))} ))}
</View> </View>
<View style={{ alignItems: 'flex-end' }}>
<View style={{ alignItems: 'flex-end', justifyContent: 'center' }}>
{p.amount && ( {p.amount && (
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}> <Text
style={[
theme.typography.body,
{
color: isNegative ? theme.colors.financial.expense : theme.colors.fgPrimary,
fontWeight: '700',
},
]}
>
{p.amount} {p.currency ?? ''} {p.amount} {p.currency ?? ''}
</Text> </Text>
)} )}
{p.cost && ( {p.cost && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{p.cost} : {p.cost}
</Text> </Text>
)} )}
{p.price && ( {p.price && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
@ {p.price} : @ {p.price}
</Text> </Text>
)} )}
</View> </View>
</View> </View>
))} );
})}
</View>
</Card> </Card>
{/* 标签 */} {/* 标签 */}
@@ -297,9 +411,13 @@ export default function TransactionDetailScreen() {
</Card> </Card>
)} )}
{/* 元数据 */} {/* 元数据 / 附加信息 */}
{tx.metadata && Object.keys(tx.metadata).length > 0 && ( {tx.metadata && Object.keys(tx.metadata).length > 0 && (
<Card title={t('transaction.metadata')}> <Card
title={`${t('transaction.metadata')} (${Object.keys(tx.metadata).length})`}
collapsible
defaultCollapsed
>
{Object.entries(tx.metadata).map(([k, v]) => ( {Object.entries(tx.metadata).map(([k, v]) => (
<View key={k} style={styles.metaRow}> <View key={k} style={styles.metaRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{k}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{k}</Text>
@@ -310,8 +428,12 @@ export default function TransactionDetailScreen() {
)} )}
{/* 原始 .bean */} {/* 原始 .bean */}
<Card title={t('transaction.rawBean')}> <Card
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontFamily: 'monospace', lineHeight: 20 }]}> title={t('transaction.rawBean')}
collapsible
defaultCollapsed
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'], lineHeight: 20 }]}>
{tx.raw} {tx.raw}
</Text> </Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
@@ -323,7 +445,7 @@ export default function TransactionDetailScreen() {
{isEditable && ( {isEditable && (
<View style={styles.editActions}> <View style={styles.editActions}>
<Pressable <Pressable
onPress={() => router.push({ pathname: '/transaction/new', params: { editId: id } })} onPress={() => useNumpadUiStore.getState().open({ editId: id })}
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]} style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
> >
<Ionicons name="create-outline" size={16} color={theme.colors.accent} /> <Ionicons name="create-outline" size={16} color={theme.colors.accent} />
@@ -367,7 +489,7 @@ export default function TransactionDetailScreen() {
transparent={false} transparent={false}
onRequestClose={() => setLinkModalVisible(false)} onRequestClose={() => setLinkModalVisible(false)}
> >
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
<View style={styles.header}> <View style={styles.header}>
<Pressable onPress={() => setLinkModalVisible(false)}> <Pressable onPress={() => setLinkModalVisible(false)}>
<Ionicons name="close" size={24} color={theme.colors.fgPrimary} /> <Ionicons name="close" size={24} color={theme.colors.fgPrimary} />
@@ -377,9 +499,10 @@ export default function TransactionDetailScreen() {
</Text> </Text>
</View> </View>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
<View style={styles.searchBarContainer}> <View style={styles.searchBarContainer}>
<TextInput <TextInput
style={[commonStyles.input, { height: 40 }]} style={[commonStyles.input, { minHeight: 40 }]}
placeholder={t('transaction.searchTxPlaceholder')} placeholder={t('transaction.searchTxPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
value={searchQuery} value={searchQuery}
@@ -390,6 +513,7 @@ export default function TransactionDetailScreen() {
<FlatList <FlatList
data={linkableTransactions} data={linkableTransactions}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => ( renderItem={({ item }) => (
<View style={{ paddingHorizontal: 16, paddingVertical: 4 }}> <View style={{ paddingHorizontal: 16, paddingVertical: 4 }}>
<TransactionCard <TransactionCard
@@ -404,6 +528,7 @@ export default function TransactionDetailScreen() {
</Text> </Text>
} }
/> />
</KeyboardAvoidingView>
</SafeAreaView> </SafeAreaView>
</Modal> </Modal>
</SafeAreaView> </SafeAreaView>
@@ -416,14 +541,15 @@ const styles = StyleSheet.create({
content: { padding: 16 }, content: { padding: 16 },
summaryRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, summaryRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
directionBadge: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: 12 }, directionBadge: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: 12 },
postingRow: { flexDirection: 'row', paddingTop: 10, gap: 8 },
tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
tag: { paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 }, tag: { paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
tagContainer: { flexDirection: 'row', alignItems: 'center', 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 }, linkBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 8 },
searchBarContainer: { paddingHorizontal: 16, paddingBottom: 8 }, searchBarContainer: { paddingHorizontal: 16, paddingBottom: 8 },
searchInput: { height: 40, borderWidth: StyleSheet.hairlineWidth, borderRadius: 8, paddingHorizontal: 12, fontSize: 14 },
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 }, metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' }, editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 }, 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 },
}); });
-792
View File
@@ -1,792 +0,0 @@
import React, { useMemo, useState, useEffect, useRef } from 'react';
import { ScrollView, StyleSheet, Text, TextInput, View, Switch, Alert, Pressable, LayoutAnimation, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system/legacy';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import { CategoryPicker } from '../../components/CategoryPicker';
import { TagPicker } from '../../components/TagPicker';
import { PostingEditor } from '../../components/PostingEditor';
import { tagsToBeanSyntax } from '../../domain/tags';
import { serializeTransaction } from '../../domain/ledger';
import { buildAndSaveTransaction } from '../../domain/transactionBuilder';
import { matchCategory } from '../../domain/categories';
import { getNativeOcrModule, getNativeOcrBridge } from '../../services/ocrBridge';
import { OcrProcessor } from '../../domain/ocrProcessor';
import type { Category } from '../../domain/categories';
import type { Tag } from '../../domain/tags';
import type { Posting } from '../../domain/types';
type Direction = 'expense' | 'income' | 'transfer';
export default function NewTransactionScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT();
const router = useRouter();
const { mode, editId, draftJson } = useLocalSearchParams<{ mode?: string; editId?: string; draftJson?: string }>();
const addTransaction = useLedgerStore(s => s.addTransaction);
const editTransaction = useLedgerStore(s => s.editTransaction);
const ledger = useLedgerStore(s => s.ledger);
const categories = useMetadataStore(s => s.categories);
const tags = useMetadataStore(s => s.tags);
const today = new Date().toISOString().slice(0, 10);
// 基础表单状态
const [date, setDate] = useState(today);
const [narration, setNarration] = useState('');
const [payee, setPayee] = useState('');
const [currency, setCurrency] = useState('CNY');
const [amount, setAmount] = useState('');
const [direction, setDirection] = useState<Direction>('expense');
const [sourceAccount, setSourceAccount] = useState('Assets:支付宝余额');
const [targetAccount, setTargetAccount] = useState('Assets:微信零钱');
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
const [status, setStatus] = useState('');
// 高级模式状态
const [isAdvanced, setIsAdvanced] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [postings, setPostings] = useState<Posting[]>([
{ account: 'Assets:支付宝余额', amount: '', currency: 'CNY' },
{ account: 'Expenses:餐饮', amount: '', currency: 'CNY' }
]);
const [isSubmitted, setIsSubmitted] = useState(false);
const navigation = useNavigation();
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
const hasChanges =
amount.trim() !== '' ||
payee.trim() !== '' ||
narration.trim() !== '' ||
selectedTags.length > 0 ||
(isAdvanced && postings.some(p => p.account.trim() !== '' || (p.amount || '').trim() !== ''));
if (!hasChanges || isSubmitted) {
return;
}
e.preventDefault();
Alert.alert(
t('transaction.unsavedTitle'),
t('transaction.unsavedMessage'),
[
{ text: t('common.cancel'), style: 'cancel', onPress: () => {} },
{
text: t('common.discard'),
style: 'destructive',
onPress: () => navigation.dispatch(e.data.action),
},
]
);
});
return unsubscribe;
}, [navigation, amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted]);
// === 草稿预填模式 ===
useEffect(() => {
if (draftJson) {
try {
const draft = JSON.parse(draftJson);
setDate(draft.date ? draft.date.slice(0, 10) : today);
setNarration(draft.narration || '');
setPayee(draft.payee || '');
const expensePosting = draft.postings.find((p: any) => p.account.startsWith('Expenses'));
const incomePosting = draft.postings.find((p: any) => p.account.startsWith('Income'));
const assetsPostings = draft.postings.filter((p: any) => p.account.startsWith('Assets') || p.account.startsWith('Liabilities'));
if (expensePosting) {
setDirection('expense');
setCurrency(expensePosting.currency || 'CNY');
setAmount((expensePosting.amount || '').replace(/^-/, ''));
const matchedCat = categories.find(c => c.linkedAccount === expensePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
if (assetsPostings.length > 0) {
setSourceAccount(assetsPostings[0].account);
}
} else if (incomePosting) {
setDirection('income');
setCurrency(incomePosting.currency || 'CNY');
setAmount((incomePosting.amount || '').replace(/^-/, ''));
const matchedCat = categories.find(c => c.linkedAccount === incomePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
if (assetsPostings.length > 0) {
setSourceAccount(assetsPostings[0].account);
}
} else {
setDirection('transfer');
if (draft.postings.length >= 2) {
setCurrency(draft.postings[0].currency || 'CNY');
setAmount((draft.postings[0].amount || '').replace(/^-/, ''));
setSourceAccount(draft.postings[0].account);
setTargetAccount(draft.postings[1].account);
}
}
if (draft.postings && draft.postings.length > 0) {
setPostings(draft.postings.map((p: any) => ({
account: p.account,
amount: p.amount,
currency: p.currency,
cost: p.cost,
price: p.price,
metadata: p.metadata,
})));
}
} catch (e) {
console.error('Failed to parse draftJson', e);
}
}
}, [draftJson, categories]);
// === 编辑模式:预填表单 ===
const editingTx = useMemo(
() => editId ? ledger?.transactions.find(txn => txn.id === editId) : undefined,
[editId, ledger],
);
const [oldRaw, setOldRaw] = useState('');
const [editFlag, setEditFlag] = useState<string>('*');
const [editLinks, setEditLinks] = useState<string[]>([]);
const [editMetadata, setEditMetadata] = useState<Record<string, string> | undefined>(undefined);
useEffect(() => {
if (editingTx) {
setOldRaw(editingTx.raw);
setDate(editingTx.date.slice(0, 10));
setNarration(editingTx.narration || '');
setPayee(editingTx.payee || '');
setCurrency(editingTx.postings[0]?.currency || 'CNY');
// 保留编辑前的 flag/links/metadata
setEditFlag(editingTx.flag || '*');
setEditLinks(editingTx.links || []);
setEditMetadata(editingTx.metadata);
// 从 postings 推断方向和金额
const expensePosting = editingTx.postings.find(p => p.account.startsWith('Expenses'));
const incomePosting = editingTx.postings.find(p => p.account.startsWith('Income'));
if (expensePosting) {
setDirection('expense');
setAmount(expensePosting.amount?.replace(/^-/, '') || '');
setSourceAccount(editingTx.postings.find(p => p.account.startsWith('Assets'))?.account || 'Assets:Alipay');
// 预填分类:匹配 Expenses 账户对应的 category
const matchedCat = categories.find(c => c.linkedAccount === expensePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
} else if (incomePosting) {
setDirection('income');
setAmount(incomePosting.amount?.replace(/^-/, '') || '');
setSourceAccount(editingTx.postings.find(p => p.account.startsWith('Assets'))?.account || 'Assets:Alipay');
// 预填分类
const matchedCat = categories.find(c => c.linkedAccount === incomePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
} else {
setDirection('transfer');
setAmount(editingTx.postings[0]?.amount?.replace(/^-/, '') || '');
setSourceAccount(editingTx.postings[0]?.account || '');
setTargetAccount(editingTx.postings[1]?.account || '');
}
// 预填 postings(高级模式,含 cost/price/metadata
setPostings(editingTx.postings.map(p => ({
account: p.account,
amount: p.amount,
currency: p.currency,
cost: p.cost,
price: p.price,
metadata: p.metadata,
})));
// 预填标签
if (editingTx.tags.length > 0) {
setSelectedTags(editingTx.tags.map(name => ({ id: name, name, color: '#2196F3' })));
}
}
}, [editingTx, categories]);
// 按方向过滤分类
const availableCategories = useMemo(
() => categories.filter(c => c.type === (direction === 'income' ? 'income' : 'expense')),
[categories, direction],
);
// 来源账户列表(从 ledger 提取 Assets 账户)
const assetAccounts = useMemo(() => {
if (!ledger) return ['Assets:支付宝余额', 'Assets:微信零钱', 'Assets:兴业银行储蓄卡-2586'];
const accts = Array.from(ledger.accounts.keys()).filter(a => a.startsWith('Assets'));
return accts.length > 0 ? accts : ['Assets:支付宝余额'];
}, [ledger]);
const toggleTag = (tag: Tag) => {
setSelectedTags(prev =>
prev.some(t => t.name === tag.name)
? prev.filter(t => t.name !== tag.name)
: [...prev, tag],
);
};
// === OCR 拍照识账 ===
const handleOcrScan = async () => {
try {
// 1. 选图(相册或拍照)
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: false,
quality: 0.8,
base64: true,
});
if (result.canceled || !result.assets?.[0]?.base64) return;
const base64 = result.assets[0].base64;
// 2. 检查 OCR 引擎是否就绪
const nativeModule = getNativeOcrModule();
if (nativeModule) {
const ready = await nativeModule.isReady();
if (!ready) {
Alert.alert(t('ocr.notReady'));
return;
}
}
setStatus(t('ocr.scanning'));
// 3. 走 OcrProcessor 管线 (Layer1 regex → Layer2 → Layer3 AI)
const ocrBridge = getNativeOcrBridge();
const processor = new OcrProcessor(ocrBridge);
const ocrResult = await processor.process(base64);
if (ocrResult.event) {
// 4. 预填表单
const event = ocrResult.event;
if (event.amount) {
const absAmount = event.amount.replace(/^-/, '');
setAmount(absAmount);
}
if (event.counterparty || event.memo) {
setNarration([event.counterparty, event.memo].filter(Boolean).join(' - '));
}
if (event.direction === 'income') {
setDirection('income');
} else if (event.direction === 'transfer') {
setDirection('transfer');
} else {
setDirection('expense');
}
// 5. 自动分类:根据商户名/备注匹配分类(与 BillPipeline 分类逻辑一致)
const matchText = [event.counterparty, event.memo].filter(Boolean).join(' ');
if (matchText) {
const type = event.direction === 'income' ? 'income' : 'expense';
const candidates = categories.filter(c => c.type === type);
const match = matchCategory(matchText, candidates);
if (match.category) {
setSelectedCategory(match.category);
}
}
setStatus(t('ocr.success'));
} else {
setStatus(t('ocr.failed'));
}
} catch (e) {
setStatus(t('ocr.failed') + ': ' + (e instanceof Error ? e.message : String(e)));
}
};
// 从 SpeedDial OCR 入口进入时自动触发
useEffect(() => {
if (mode === 'ocr') {
handleOcrScan();
}
}, [mode]);
const submit = () => {
const tagNames = selectedTags.map(tg => tg.name);
if (isAdvanced) {
// 高级模式:校验多 postings
const validPostings = postings.filter(p => p.account.trim());
if (validPostings.length < 2) {
setStatus(t('transaction.atLeast2Postings'));
return;
}
// 实时借贷平衡验证
const currencySum: Record<string, number> = {};
for (const p of validPostings) {
if (p.amount && p.currency) {
const val = parseFloat(p.amount);
if (!isNaN(val)) {
currencySum[p.currency] = (currencySum[p.currency] || 0) + val;
}
}
}
const isBalanced = Object.values(currencySum).every(sum => Math.abs(sum) < 0.001);
if (!isBalanced) {
setStatus(t('transaction.unbalanced'));
return;
}
const draft = {
date,
flag: editId ? editFlag : undefined,
narration: narration.trim() || t('transaction.defaultAdvancedNarration'),
postings: validPostings,
tags: tagNames,
links: editId && editLinks.length > 0 ? editLinks : undefined,
metadata: editId ? editMetadata : undefined,
};
if (editId && oldRaw) {
// 编辑模式:序列化新内容并替换旧块
const newRaw = serializeTransaction(draft);
editTransaction(oldRaw, newRaw).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.editSuccess'));
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
} else {
addTransaction(draft).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.appended'));
setNarration('');
setSelectedTags([]);
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
}
} else {
// 简单模式:调用统一的 buildAndSaveTransaction 构建复式 postings 并保存
const amt = amount.trim();
if (!amt) {
setStatus(t('transaction.amountRequired'));
return;
}
if (direction === 'transfer' && sourceAccount === targetAccount) {
setStatus(t('transaction.sameAccountError'));
return;
}
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
if (editId && oldRaw) {
// 编辑模式:由于编辑模式需要保留原始元数据并替换原始代码块,我们仍然就地拼装 draft 并执行 editTransaction
let finalPostings: Posting[];
if (direction === 'expense') {
finalPostings = [
{ account: sourceAccount, amount: `-${amt}`, currency },
{ account: categoryAccount, amount: amt, currency },
];
} else if (direction === 'income') {
finalPostings = [
{ account: sourceAccount, amount: amt, currency },
{ account: categoryAccount, amount: `-${amt}`, currency },
];
} else {
finalPostings = [
{ account: sourceAccount, amount: `-${amt}`, currency },
{ account: targetAccount, amount: amt, currency },
];
}
const draft = {
date,
payee: payee.trim() || undefined,
flag: editFlag,
narration: narration.trim() || (
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
direction === 'income' ? t('transaction.narrationDefaultIncome') :
t('transaction.narrationDefaultTransfer')
),
postings: finalPostings,
tags: tagNames,
links: editLinks.length > 0 ? editLinks : undefined,
metadata: editMetadata,
};
const newRaw = serializeTransaction(draft);
editTransaction(oldRaw, newRaw).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.editSuccess'));
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
} else {
// 新建交易:直接调用统一的共享逻辑函数
buildAndSaveTransaction({
date,
amount: amt,
payee: payee.trim() || undefined,
narration: narration.trim() || (
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
direction === 'income' ? t('transaction.narrationDefaultIncome') :
t('transaction.narrationDefaultTransfer')
),
categoryAccount: direction === 'transfer' ? targetAccount : categoryAccount,
sourceAccount,
direction,
currency,
tags: tagNames,
links: editLinks.length > 0 ? editLinks : undefined,
metadata: editMetadata,
}).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.appended'));
setAmount('');
setNarration('');
setSelectedCategory(null);
setSelectedTags([]);
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
}
}
};
const directions: { key: Direction; label: string; color: string }[] = [
{ key: 'expense', label: t('transaction.directionExpense'), color: theme.colors.financial.expense },
{ key: 'income', label: t('transaction.directionIncome'), color: theme.colors.financial.income },
{ key: 'transfer', label: t('transaction.directionTransfer'), color: theme.colors.financial.transfer },
];
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['bottom', 'left', 'right']}>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 高级模式模式切换开关 */}
<View style={commonStyles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700', fontFamily: theme.typography.body.fontFamily }]}>
{t('transaction.advancedMode')}
</Text>
<Switch
value={isAdvanced}
onValueChange={(val) => {
setIsAdvanced(val);
// 同步初始化当前分录
if (val) {
const amt = amount.trim();
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
if (direction === 'expense') {
setPostings([
{ account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
{ account: categoryAccount, amount: amt || '', currency: 'CNY' },
]);
} else if (direction === 'income') {
setPostings([
{ account: sourceAccount, amount: amt || '', currency: 'CNY' },
{ account: categoryAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
]);
} else {
setPostings([
{ account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
{ account: targetAccount, amount: amt || '', currency: 'CNY' },
]);
}
}
}}
trackColor={{ false: theme.colors.bgPrimary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* 简单模式下的方向选择 */}
{!isAdvanced && (
<View style={styles.directionRow}>
{directions.map(d => (
<Pressable
key={d.key}
onPress={() => { setDirection(d.key); setSelectedCategory(null); }}
style={[
commonStyles.chip,
direction === d.key && commonStyles.chipActive,
{ flex: 1, alignItems: 'center' }
]}
>
<Text style={[
commonStyles.chipText,
direction === d.key && commonStyles.chipTextActive,
{ fontFamily: theme.typography.caption.fontFamily }
]}>
{d.label}
</Text>
</Pressable>
))}
<Pressable
onPress={handleOcrScan}
style={[
commonStyles.chip,
{ alignItems: 'center', minWidth: 60, borderColor: theme.colors.accent }
]}
>
<Text style={[commonStyles.chipText, { color: theme.colors.accent, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.ocrScan')}
</Text>
</Pressable>
</View>
)}
{/* 主输入区域:金额与账户 (简单模式) 或 分录编辑器 (高级模式) */}
{!isAdvanced ? (
<Card title={t('transaction.newTitle')}>
{/* 金额 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.amountPlaceholder')}
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
<TextInput
value={amount}
onChangeText={setAmount}
autoFocus={editId ? false : true}
keyboardType="decimal-pad"
placeholder={t('transaction.amountPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace', fontSize: 16 }]}
/>
<TextInput
value={currency}
onChangeText={setCurrency}
placeholder="CNY"
maxLength={3}
autoCapitalize="characters"
style={[commonStyles.input, { width: 70, textAlign: 'center' }]}
/>
</View>
{/* 来源账户 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formSourceAccount')}
</Text>
<TextInput
value={sourceAccount}
onChangeText={setSourceAccount}
placeholder="Assets:支付宝余额"
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 快捷来源账户选择 */}
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{assetAccounts.map(acct => {
const active = sourceAccount === acct;
const shortName = acct.split(':').pop() || acct;
return (
<Pressable
key={`src-${acct}`}
onPress={() => setSourceAccount(acct)}
style={[
commonStyles.chip,
active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent },
{ paddingVertical: 4, paddingHorizontal: 8 }
]}
>
<Text style={[
commonStyles.chipText,
{ fontSize: 11 },
active && { color: theme.colors.accent, fontWeight: '700' }
]}>
{shortName}
</Text>
</Pressable>
);
})}
</View>
{/* 目标账户 (仅在转账时显示) */}
{direction === 'transfer' && (
<View style={{ marginTop: 10 }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.targetAccount')}
</Text>
<TextInput
value={targetAccount}
onChangeText={setTargetAccount}
placeholder="Assets:WeChat"
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 快捷目标账户选择 */}
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{assetAccounts.map(acct => {
const active = targetAccount === acct;
const shortName = acct.split(':').pop() || acct;
return (
<Pressable
key={`tgt-${acct}`}
onPress={() => setTargetAccount(acct)}
style={[
commonStyles.chip,
active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent },
{ paddingVertical: 4, paddingHorizontal: 8 }
]}
>
<Text style={[
commonStyles.chipText,
{ fontSize: 11 },
active && { color: theme.colors.accent, fontWeight: '700' }
]}>
{shortName}
</Text>
</Pressable>
);
})}
</View>
</View>
)}
</Card>
) : (
<Card title={t('transaction.newTitle')}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.postingsList')}
</Text>
<PostingEditor
postings={postings}
onChange={(p) => setPostings(p)}
/>
<View style={styles.postingBtnRow}>
<View style={{ flex: 1 }}>
<Button
label={t('transaction.addPosting')}
onPress={() => setPostings([...postings, { account: '', amount: '', currency: 'CNY' }])}
variant="secondary"
/>
</View>
<View style={{ flex: 1 }}>
<Button
label={t('transaction.deleteLastPosting')}
onPress={() => postings.length > 2 && setPostings(postings.slice(0, -1))}
variant="secondary"
/>
</View>
</View>
</Card>
)}
{/* 简单模式下的分类选择 */}
{!isAdvanced && direction !== 'transfer' && (
<Card title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
<CategoryPicker
categories={availableCategories}
selectedId={selectedCategory?.id}
onSelect={(cat) => setSelectedCategory(cat)}
/>
{selectedCategory && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 8 }}>
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{selectedCategory.linkedAccount}
</Text>
</View>
)}
</Card>
)}
{/* 详情与高级字段收纳区 */}
<Pressable
onPress={() => {
LayoutAnimation.easeInEaseOut();
setShowDetails(!showDetails);
}}
style={({ pressed }) => [
styles.detailsToggle,
{
borderColor: theme.colors.border,
backgroundColor: theme.colors.bgSecondary,
opacity: pressed ? 0.8 : 1,
flexDirection: 'row',
gap: 6
}
]}
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontFamily: theme.typography.bodySmall.fontFamily }]}>
{showDetails ? t('transaction.hideDetails') : t('transaction.addDetails')}
</Text>
<Ionicons
name={showDetails ? 'chevron-up-outline' : 'chevron-down-outline'}
size={16}
color={theme.colors.accent}
/>
</Pressable>
{showDetails && (
<View style={{ gap: theme.spacing.md }}>
<Card title={t('transaction.extraDetails')}>
{/* 日期 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formDate')}
</Text>
<TextInput
value={date}
onChangeText={setDate}
placeholder="YYYY-MM-DD"
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 摘要 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formNarration')}
</Text>
<TextInput
value={narration}
onChangeText={setNarration}
placeholder={t('transaction.formNarration')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 收款方(简单模式才显示) */}
{!isAdvanced && (
<>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formPayee')}
</Text>
<TextInput
value={payee}
onChangeText={setPayee}
placeholder={t('transaction.formPayeePlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
</>
)}
</Card>
{/* 标签选择 */}
<Card title={t('transaction.formTags')}>
<TagPicker
tags={tags}
selectedNames={selectedTags.map(tg => tg.name)}
onToggle={toggleTag}
/>
{selectedTags.length > 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formTagsWillWrite')}: {tagsToBeanSyntax(selectedTags)}
</Text>
)}
</Card>
</View>
)}
<Button label={t('transaction.submit')} onPress={submit} />
{status ? <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8, fontFamily: theme.typography.bodySmall.fontFamily }]}>{status}</Text> : null}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16, paddingBottom: 40 },
directionRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
postingBtnRow: { flexDirection: 'row', gap: 8, marginTop: 8 },
detailsToggle: { paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center', marginTop: 4 },
});
-118
View File
@@ -1,118 +0,0 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import type { Category } from '../domain/categories';
interface CategoryPickerProps {
categories: Category[];
selectedId?: string;
onSelect: (cat: Category) => void;
}
const CATEGORY_ICONS: Record<string, string> = {
food: '🍔',
transport: '🚗',
shopping: '🛍️',
housing_utility: '💧',
housing_rent: '🏠',
housing_communication: '📞',
entertainment: '🎮',
services: '⚙️',
personal_care: '💅',
clothing: '👕',
health: '🏥',
learning: '📚',
salary: '💰',
income_activity: '🧧',
income_investment: '📈',
fallback: '🏷️',
};
const CATEGORY_COLORS: Record<string, string> = {
food: '#F59E0B', // Amber
transport: '#3B82F6', // Blue
shopping: '#EC4899', // Pink
housing_utility: '#06B6D4',// Cyan
housing_rent: '#6366F1', // Indigo
housing_communication: '#8B5CF6', // Purple
entertainment: '#10B981', // Emerald
services: '#64748B', // Slate
personal_care: '#F43F5E', // Rose
clothing: '#14B8A6', // Teal
health: '#EF4444', // Red
learning: '#84CC16', // Lime
salary: '#22C55E', // Green
income_activity: '#EF4444',// Red/Orange
income_investment: '#F59E0B', // Amber
fallback: '#64748B',
};
/** 分类选择器(网格卡片化布局)。 */
export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPickerProps) {
const { theme } = useTheme();
return (
<View style={styles.grid}>
{categories.map(cat => {
const active = cat.id === selectedId;
const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback;
const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;
return (
<Pressable
key={cat.id}
onPress={() => onSelect(cat)}
style={({ pressed }) => [
styles.item,
{
backgroundColor: active ? color + '15' : theme.colors.bgTertiary,
borderColor: active ? color : theme.colors.border,
opacity: pressed ? 0.75 : 1,
}
]}
>
<Text style={[styles.icon, { color }]}>{icon}</Text>
<Text
style={[
styles.label,
{
color: active ? theme.colors.fgPrimary : theme.colors.fgSecondary,
fontFamily: theme.typography.caption.fontFamily,
fontWeight: active ? '700' : '500',
}
]}
numberOfLines={1}
>
{cat.name}
</Text>
</Pressable>
);
})}
</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,
},
icon: {
fontSize: 22,
},
label: {
fontSize: 12,
},
});
-148
View File
@@ -1,148 +0,0 @@
/**
* plan.md CRUD
*
* , + /
* /////
*
* :
* <FormModal
* visible={true}
* title="添加分类"
* fields={[
* { key: 'name', label: '名称', placeholder: '餐饮' },
* { key: 'linkedAccount', label: '关联账户', placeholder: 'Expenses:餐饮' },
* ]}
* initialValues={{ name: '', linkedAccount: '' }}
* onConfirm={(values) => { /* *\/ }}
* onCancel={() => { /* *\/ }}
* />
*/
import React, { useEffect, useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n';
import { Button } from './Button';
export interface FormField {
/** 字段 key,对应 values 对象的属性名。 */
key: string;
/** 显示标签。 */
label: string;
/** 占位提示。 */
placeholder?: string;
/** 默认值(新增时为空,编辑时预填)。 */
defaultValue?: string;
/** 键盘类型。 */
keyboardType?: 'default' | 'numeric' | 'decimal-pad' | 'phone-pad';
/** 多行输入。 */
multiline?: boolean;
}
interface FormModalProps {
visible: boolean;
title: string;
fields: FormField[];
onConfirm: (values: Record<string, string>) => void;
onCancel: () => void;
/** 确认按钮文本(默认「确定」)。 */
confirmLabel?: string;
}
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel }: FormModalProps) {
const { theme } = useTheme();
const t = useT();
const commonStyles = createCommonStyles(theme);
const [values, setValues] = useState<Record<string, string>>({});
// 每次 visible 变为 true 时,用 fields 的 defaultValue 初始化
useEffect(() => {
if (visible) {
const init: Record<string, string> = {};
for (const f of fields) {
init[f.key] = f.defaultValue ?? '';
}
setValues(init);
}
}, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onCancel}>
<Pressable
style={[styles.sheet, {
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border,
}]}
onPress={(e) => e.stopPropagation()}
>
<View style={[styles.header, { borderBottomColor: theme.colors.divider }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{title}</Text>
<Pressable onPress={onCancel} hitSlop={8}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}></Text>
</Pressable>
</View>
<ScrollView style={styles.body}>
{fields.map(f => (
<View key={f.key} style={styles.field}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4 }]}>
{f.label}
</Text>
<TextInput
value={values[f.key] ?? ''}
onChangeText={(text) => setValues(prev => ({ ...prev, [f.key]: text }))}
placeholder={f.placeholder}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType={f.keyboardType ?? 'default'}
multiline={f.multiline}
style={commonStyles.input}
/>
</View>
))}
</ScrollView>
<View style={styles.actions}>
<Button label={t('common.cancel')} onPress={onCancel} variant="secondary" />
<View style={{ flex: 1 }} />
<Button label={confirmLabel ?? t('common.confirm')} onPress={() => onConfirm(values)} />
</View>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
sheet: {
maxHeight: '80%',
padding: 20,
paddingBottom: 36,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: 12,
borderBottomWidth: 1,
marginBottom: 12,
},
body: {
maxHeight: 400,
},
field: {
marginBottom: 12,
},
actions: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 8,
},
});
-195
View File
@@ -1,195 +0,0 @@
import React, { useState } from 'react';
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '../theme';
import { useT } from '../i18n';
/** 快捷操作项。 */
export interface SpeedDialAction {
key: string;
icon: keyof typeof Ionicons.glyphMap;
label: string;
onPress: () => void;
}
interface SpeedDialProps {
/** 展开式快捷操作列表。不传时保持单按钮行为(记一笔)。 */
actions?: SpeedDialAction[];
}
/**
*
* - actions "+"
* - actions mini-FAB //
*/
export function SpeedDial({ actions }: SpeedDialProps) {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const [open, setOpen] = useState(false);
const [spin] = useState(new Animated.Value(0));
const hasActions = actions && actions.length > 0;
const toggle = () => {
const next = !open;
setOpen(next);
Animated.spring(spin, {
toValue: next ? 1 : 0,
useNativeDriver: true,
tension: 80,
friction: 8,
}).start();
};
const handleAction = (action: SpeedDialAction) => {
setOpen(false);
Animated.spring(spin, {
toValue: 0,
useNativeDriver: true,
tension: 80,
friction: 8,
}).start();
action.onPress();
};
const mainRotation = spin.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '45deg'],
});
const mainIcon = hasActions ? (
<Animated.View style={{ transform: [{ rotate: mainRotation }] }}>
<Ionicons name="add" size={28} color={theme.colors.fgInverse} />
</Animated.View>
) : (
<Ionicons name="add" size={28} color={theme.colors.fgInverse} />
);
return (
<>
{/* 遮罩层:展开时点击空白收起 */}
{hasActions && open && (
<Pressable
onPress={toggle}
style={[styles.overlay, { backgroundColor: theme.colors.overlay }]}
/>
)}
{/* 展开的快捷操作列表 */}
{hasActions && (
<View style={styles.actionsContainer} pointerEvents={open ? 'auto' : 'none'}>
{actions!.map((action, i) => {
const offset = 72 + 56 * (actions!.length - 1 - i);
const translateY = spin.interpolate({
inputRange: [0, 1],
outputRange: [0, -offset],
});
const opacity = spin.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
const scale = spin.interpolate({
inputRange: [0, 1],
outputRange: [0.5, 1],
});
return (
<Animated.View
key={action.key}
style={[
styles.actionRow,
{ transform: [{ translateY }, { scale }], opacity },
]}
>
<View style={[styles.actionLabel, { backgroundColor: theme.colors.bgSecondary }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary }]}>
{action.label}
</Text>
</View>
<Pressable
onPress={() => handleAction(action)}
accessibilityRole="button"
accessibilityLabel={action.label}
style={({ pressed }) => [
styles.miniFab,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
opacity: pressed ? 0.7 : 1,
},
]}
>
<Ionicons name={action.icon} size={20} color={theme.colors.accent} />
</Pressable>
</Animated.View>
);
})}
</View>
)}
{/* 主按钮 */}
<Pressable
onPress={hasActions ? toggle : () => router.push('/transaction/new')}
style={({ pressed }) => [
styles.fab,
{
backgroundColor: theme.colors.accent,
opacity: pressed ? 0.85 : 1,
...theme.shadows.lg,
},
]}
accessibilityRole="button"
accessibilityLabel={t('home.newTransaction')}
accessibilityHint={hasActions ? t('speedDial.expandHint') : undefined}
>
{mainIcon}
</Pressable>
</>
);
}
const styles = StyleSheet.create({
overlay: {
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
zIndex: 9998,
},
fab: {
position: 'absolute',
right: 20,
bottom: 20,
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
},
actionsContainer: {
position: 'absolute',
right: 28,
bottom: 28,
zIndex: 9999,
},
actionRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 8,
marginBottom: 8,
},
actionLabel: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 6,
},
miniFab: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
});
-69
View File
@@ -1,69 +0,0 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import type { Transaction } from '../domain/types';
interface TransactionCardProps {
transaction: Transaction;
onPress?: (t: Transaction) => void;
}
/** 交易卡片(主题化,展示日期/摘要/金额)。 */
export function TransactionCard({ transaction, onPress }: TransactionCardProps) {
const { theme } = useTheme();
const t = useT();
const isExpense = transaction.postings.some(p => p.account.startsWith('Expenses'));
const isIncome = transaction.postings.some(p => p.account.startsWith('Income'));
const color = isExpense ? theme.colors.financial.expense : isIncome ? theme.colors.financial.income : theme.colors.financial.transfer;
const primaryAmount = transaction.postings.find(p => p.amount)?.amount ?? '';
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${transaction.narration || transaction.payee || ''} ${primaryAmount} ${transaction.date.slice(0, 10)}`}
onPress={() => onPress?.(transaction)}
style={({ pressed }) => [
styles.card,
{
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.lg, // 升级为大圆角
padding: theme.spacing.md,
borderWidth: StyleSheet.hairlineWidth, // 添加极细透白边框
borderColor: theme.colors.border,
opacity: pressed ? 0.8 : 1,
},
]}
>
<View style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]} numberOfLines={1}>
{transaction.narration || transaction.payee || t('transaction.noSummary')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{transaction.date.slice(0, 10)}
</Text>
</View>
<Text style={[theme.typography.body, { color, fontWeight: '700', fontFamily: 'monospace', fontSize: 15 }]}>
{primaryAmount}
</Text>
</View>
{transaction.tags.length > 0 && (
<View style={styles.tags}>
{transaction.tags.map(tag => (
<Text key={tag} style={[styles.tag, { backgroundColor: theme.colors.accentLight, color: theme.colors.accent, fontWeight: '600' }]}>
#{tag}
</Text>
))}
</View>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
card: { marginBottom: 8 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
tags: { flexDirection: 'row', gap: 4, marginTop: 6, flexWrap: 'wrap' },
tag: { fontSize: 11, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, overflow: 'hidden' },
});
@@ -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}
/>
);
}
@@ -1,7 +1,8 @@
import React from 'react'; import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../../theme';
import { calculateAccountTotalBalance, type AccountNode } from '../domain/accountTree'; import { useT } from '../../i18n';
import { calculateAccountTotalBalance, type AccountNode } from '../../domain/taxonomy/accountTree';
interface AccountTreeProps { interface AccountTreeProps {
nodes: AccountNode[]; nodes: AccountNode[];
@@ -11,7 +12,6 @@ interface AccountTreeProps {
/** 账户树展示(主题化,缩进显示层级 + 余额)。 */ /** 账户树展示(主题化,缩进显示层级 + 余额)。 */
export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) { export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
const { theme } = useTheme();
return ( return (
<View style={{ gap: 4 }}> <View style={{ gap: 4 }}>
{nodes.map(node => ( {nodes.map(node => (
@@ -21,15 +21,25 @@ export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
); );
} }
function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) { const AccountTreeNode = React.memo(function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) {
const { theme } = useTheme(); const { theme } = useTheme();
const total = calculateAccountTotalBalance(node); const t = useT();
const total = useMemo(() => calculateAccountTotalBalance(node), [node]);
const isLeaf = node.children.length === 0; 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 ( return (
<View> <View>
<View style={[styles.row, { paddingLeft: depth * 16 }]}> <View style={[styles.row, { paddingLeft: depth * 16 }]}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: depth === 0 ? '700' : '400' }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: depth === 0 ? '700' : '400' }]}>
{isLeaf ? '• ' : ''}{node.name} {isLeaf ? '• ' : ''}{displayName}
</Text> </Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{total} {total}
@@ -40,7 +50,7 @@ function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth:
))} ))}
</View> </View>
); );
} });
const styles = StyleSheet.create({ const styles = StyleSheet.create({
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 4 }, 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,
},
});
@@ -1,7 +1,7 @@
import React from 'react'; import React, { useMemo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme, createCommonStyles } from '../theme'; import { useTheme, createCommonStyles } from '../../theme';
import type { Tag } from '../domain/tags'; import type { Tag } from '../../domain/taxonomy/tags';
interface TagPickerProps { interface TagPickerProps {
tags: Tag[]; tags: Tag[];
@@ -12,7 +12,7 @@ interface TagPickerProps {
/** 标签选择器(主题化,多选 chip)。 */ /** 标签选择器(主题化,多选 chip)。 */
export function TagPicker({ tags, selectedNames, onToggle }: TagPickerProps) { export function TagPicker({ tags, selectedNames, onToggle }: TagPickerProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = createCommonStyles(theme); const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
return ( return (
<View style={styles.container}> <View style={styles.container}>
+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;
}
+34 -10
View File
@@ -1,23 +1,27 @@
/** /**
* plan.md5.2 * plan.md5.2 P4
* // * //
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { generateAnnualReport } from '../../domain/annualReport'; import { generateAnnualReport } from '../../domain/stats/annualReport';
import { MonthlyReport } from './MonthlyReport'; import type { Transaction } from '../../domain/core/types';
import type { Transaction } from '../../domain/types';
export function AnnualReport({ transactions, year }: { transactions: Transaction[]; year: number }) { export function AnnualReport({ transactions, year }: { transactions: Transaction[]; year: number }) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const report = generateAnnualReport(transactions, year); const report = useMemo(() => generateAnnualReport(transactions, year), [transactions, year]);
// 展示层比率计算,允许 parseFloat(非记账金额)
const maxExpense = useMemo(
() => Math.max(...report.monthlyTrend.map(m => parseFloat(m.expense) || 0)),
[report],
);
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}> <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> <Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{t('report.annualTitle', { year })}</Text>
<View style={styles.statsRow}> <View style={styles.statsRow}>
<View style={styles.statItem}> <View style={styles.statItem}>
@@ -39,7 +43,7 @@ export function AnnualReport({ transactions, year }: { transactions: Transaction
</View> </View>
{report.topCategories.length > 0 && ( {report.topCategories.length > 0 && (
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}> <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> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.topCategory')}</Text>
{report.topCategories.slice(0, 5).map(c => ( {report.topCategories.slice(0, 5).map(c => (
<View key={c.category} style={[styles.catRow, { borderTopColor: theme.colors.progressBg }]}> <View key={c.category} style={[styles.catRow, { borderTopColor: theme.colors.progressBg }]}>
@@ -50,7 +54,23 @@ export function AnnualReport({ transactions, year }: { transactions: Transaction
</View> </View>
)} )}
<MonthlyReport transactions={transactions.filter(t => t.date.startsWith(String(year)))} /> <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> </View>
); );
} }
@@ -61,4 +81,8 @@ const styles = StyleSheet.create({
statsRow: { flexDirection: 'row', justifyContent: 'space-around' }, statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
statItem: { alignItems: 'center', gap: 4 }, statItem: { alignItems: 'center', gap: 4 },
catRow: { flexDirection: 'row', borderTopWidth: 1, paddingTop: 8 }, 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%' },
}); });
-73
View File
@@ -1,73 +0,0 @@
/**
* plan.md5.2
*
*/
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { getMonthGrid } from '../../domain/calendarGrid';
import { dailyExpenseMap } from '../../domain/chartStats';
import type { Transaction } from '../../domain/types';
/** 根据强度返回颜色(0-1)。使用 opacity 而非字符串替换,兼容 hex 和 rgb。 */
function heatColor(intensity: number): { opacity: number } {
if (intensity <= 0) return { opacity: 0 };
return { opacity: Math.min(0.2 + intensity * 0.8, 1) };
}
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
export function CalendarHeatmap({ transactions, year, month }: { transactions: Transaction[]; year: number; month: number }) {
const { theme } = useTheme();
const t = useT();
const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]);
const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]);
const maxDaily = Math.max(...dailyMap.values(), 1);
return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.sm }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, marginBottom: 4 }]}>
{t('report.heatmap')}
</Text>
<View style={styles.weekdayRow}>
{WEEKDAYS.map(d => (
<Text key={d} style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', flex: 1, fontSize: 10 }]}>
{d}
</Text>
))}
</View>
{weeks.map((week, wi) => (
<View key={wi} style={styles.weekRow}>
{week.map(cell => {
const expense = dailyMap.get(cell.date) ?? 0;
const intensity = expense / maxDaily;
return (
<View key={cell.date} style={[
styles.cell,
{
backgroundColor: cell.isCurrentMonth && expense > 0
? theme.colors.financial.expense
: 'transparent',
borderColor: theme.colors.border,
opacity: cell.isCurrentMonth ? (expense > 0 ? heatColor(intensity).opacity : 1) : 0.3,
},
]}>
<Text style={[theme.typography.caption, { fontSize: 10, color: theme.colors.fgSecondary }]}>
{cell.day}
</Text>
</View>
);
})}
</View>
))}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 3 },
weekdayRow: { flexDirection: 'row', marginBottom: 2 },
weekRow: { flexDirection: 'row', gap: 2 },
cell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', borderRadius: 3, borderWidth: 0.5 },
});
+12 -11
View File
@@ -2,37 +2,38 @@
* plan.md5.2 * plan.md5.2
* *
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { groupByCategory } from '../../domain/chartStats'; import { groupByCategory } from '../../domain/stats/chartStats';
import type { Transaction } from '../../domain/types'; import type { Transaction } from '../../domain/core/types';
export function CategoryPie({ transactions }: { transactions: Transaction[] }) { export function CategoryPie({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const data = groupByCategory(transactions); const data = useMemo(() => groupByCategory(transactions), [transactions]);
const maxAmount = Math.max(...data.map(d => d.amount), 1); const maxAmount = Math.max(...data.map(d => d.amount), 1);
return ( return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}> <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, fontFamily: theme.typography.h3.fontFamily }]}> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{t('report.categoryTitle')} {t('report.categoryTitle')}
</Text> </Text>
<View style={styles.list}> <View style={styles.list}>
{data.map(d => ( {data.map(d => (
<View key={d.category} style={styles.row}> <View key={d.category} style={styles.row}>
<Text <Text
style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1, fontWeight: '600', fontFamily: theme.typography.bodySmall.fontFamily }]} style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1, maxWidth: '38%', fontWeight: '600' }]}
numberOfLines={1} numberOfLines={1}
> >
{d.category.replace('Expenses:', '').replace('Income:', '')} {d.category.replace('Expenses:', '').replace('Income:', '')}
</Text> </Text>
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, borderWidth: StyleSheet.hairlineWidth }]}> {/* 进度条圆角使用 theme.radii.sm / 2,避免硬编码 */}
<View style={[styles.bar, { width: `${(d.amount / maxAmount) * 100}%`, backgroundColor: theme.colors.accent }]} /> <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> </View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 55, textAlign: 'right', fontFamily: 'monospace', fontSize: 13 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 55, textAlign: 'right', fontVariant: ['tabular-nums'], fontSize: theme.typography.bodySmall.fontSize - 1 }]}>
{d.percentage}% {d.percentage}%
</Text> </Text>
</View> </View>
@@ -51,6 +52,6 @@ const styles = StyleSheet.create({
container: { gap: 10 }, container: { gap: 10 },
list: { gap: 8 }, list: { gap: 8 },
row: { flexDirection: 'row', alignItems: 'center', gap: 10 }, row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
barWrap: { width: 120, height: 8, borderRadius: 4, overflow: 'hidden' }, barWrap: { flex: 1, height: 8, overflow: 'hidden' },
bar: { height: '100%', borderRadius: 4 }, bar: { height: '100%' },
}); });
-45
View File
@@ -1,45 +0,0 @@
/**
* plan.md5.2
* RN
*/
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { groupByMonth } from '../../domain/chartStats';
import type { Transaction } from '../../domain/types';
export function MonthlyReport({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme();
const t = useT();
const data = groupByMonth(transactions);
const maxExpense = Math.max(...data.map(d => d.expense), 1);
return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.monthlyTitle')}</Text>
{data.map(d => (
<View key={d.month} style={styles.row}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 60 }]}>{d.month.slice(5)}</Text>
<View style={[styles.barWrap, { backgroundColor: theme.colors.progressBg }]}>
<View style={[styles.bar, {
width: `${(d.expense / maxExpense) * 100}%`,
backgroundColor: theme.colors.financial.expense,
}]} />
</View>
<Text style={[theme.typography.caption, { color: theme.colors.financial.expense }]}>{d.expense.toFixed(0)}</Text>
</View>
))}
{data.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('common.noData')}</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 6 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
barWrap: { flex: 1, height: 12, borderRadius: 6, overflow: 'hidden' },
bar: { height: '100%', borderRadius: 6 },
});
+16 -10
View File
@@ -1,32 +1,38 @@
/** /**
* plan.md5.2 * plan.md5.2
*/ */
import React from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { Animated, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { calculateNetWorthTrend } from '../../domain/netWorth'; import { calculateNetWorthTrend } from '../../domain/stats/netWorth';
import type { Transaction } from '../../domain/types'; import type { Transaction } from '../../domain/core/types';
export function NetWorthChart({ transactions, dates }: { transactions: Transaction[]; dates: string[] }) { export function NetWorthChart({ transactions, dates }: { transactions: Transaction[]; dates: string[] }) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const points = calculateNetWorthTrend(transactions, dates); const points = useMemo(() => calculateNetWorthTrend(transactions, dates), [transactions, dates]);
const maxAbs = Math.max(...points.map(p => Math.abs(parseFloat(p.netWorth))), 1); 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 ( return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}> <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> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.netWorthTitle')}</Text>
<View style={styles.chart}> <View style={styles.chart}>
{points.map(p => ( {points.map(p => (
<View key={p.date} style={styles.col}> <View key={p.date} style={styles.col}>
<View style={styles.barWrap}> <View style={styles.barWrap}>
<View style={[styles.bar, { {/* 柱状条圆角使用 theme.radii.sm / 2,避免硬编码 */}
height: `${(Math.abs(parseFloat(p.netWorth)) / maxAbs) * 100}%`, <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, backgroundColor: parseFloat(p.netWorth) >= 0 ? theme.colors.accent : theme.colors.error,
borderRadius: theme.radii.sm / 2,
}]} /> }]} />
</View> </View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: 9 }]}>{p.date.slice(5)}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: theme.typography.caption.fontSize - 3 }]}>{p.date.slice(5)}</Text>
</View> </View>
))} ))}
</View> </View>
@@ -45,5 +51,5 @@ const styles = StyleSheet.create({
chart: { flexDirection: 'row', alignItems: 'flex-end', height: 120, gap: 4 }, chart: { flexDirection: 'row', alignItems: 'flex-end', height: 120, gap: 4 },
col: { flex: 1, alignItems: 'center', gap: 4 }, col: { flex: 1, alignItems: 'center', gap: 4 },
barWrap: { height: 100, justifyContent: 'flex-end', width: '100%', alignItems: 'center' }, barWrap: { height: 100, justifyContent: 'flex-end', width: '100%', alignItems: 'center' },
bar: { width: 12, borderRadius: 3 }, bar: { width: 12 },
}); });
+24 -20
View File
@@ -2,21 +2,25 @@
* 线plan.md5.2 * 线plan.md5.2
* react-native-svg 线 * react-native-svg 线
*/ */
import React from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { Animated, Dimensions, StyleSheet, Text, View } from 'react-native';
import Svg, { Path, Circle, Defs, LinearGradient, Stop } from 'react-native-svg'; import Svg, { Path, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import type { Transaction } from '../../domain/types'; import type { Transaction } from '../../domain/core/types';
import { groupByMonth } from '../../domain/chartStats'; import { groupByMonth } from '../../domain/stats/chartStats';
export function TrendLine({ transactions }: { transactions: Transaction[] }) { export function TrendLine({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const data = groupByMonth(transactions).slice(-6); 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. 数据配置 // 1. 数据配置 —— 自适应宽度:初始取屏幕宽减去页面 padding,onLayout 后更新为容器实际宽度
const width = 300; const [chartWidth, setChartWidth] = useState(() => Dimensions.get('window').width - 64);
const height = 120; const height = 120;
const paddingX = 20; const paddingX = 20;
const paddingY = 15; const paddingY = 15;
@@ -26,16 +30,16 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
// 2. 坐标转换计算 // 2. 坐标转换计算
const pointsIncome = data.map((d, i) => { const pointsIncome = data.map((d, i) => {
const x = data.length > 1 const x = data.length > 1
? paddingX + (i * (width - 2 * paddingX)) / (data.length - 1) ? paddingX + (i * (chartWidth - 2 * paddingX)) / (data.length - 1)
: width / 2; : chartWidth / 2;
const y = height - paddingY - (d.income / maxVal) * (height - 2 * paddingY); const y = height - paddingY - (d.income / maxVal) * (height - 2 * paddingY);
return { x, y }; return { x, y };
}); });
const pointsExpense = data.map((d, i) => { const pointsExpense = data.map((d, i) => {
const x = data.length > 1 const x = data.length > 1
? paddingX + (i * (width - 2 * paddingX)) / (data.length - 1) ? paddingX + (i * (chartWidth - 2 * paddingX)) / (data.length - 1)
: width / 2; : chartWidth / 2;
const y = height - paddingY - (d.expense / maxVal) * (height - 2 * paddingY); const y = height - paddingY - (d.expense / maxVal) * (height - 2 * paddingY);
return { x, y }; return { x, y };
}); });
@@ -72,14 +76,14 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
const expenseClosedPath = getClosedBezierPath(pointsExpense); const expenseClosedPath = getClosedBezierPath(pointsExpense);
return ( return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}> <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, fontFamily: theme.typography.h3.fontFamily }]}> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{t('report.trendTitle')} {t('report.trendTitle')}
</Text> </Text>
{data.length > 0 ? ( {data.length > 0 ? (
<View style={styles.chartWrapper}> <View style={styles.chartWrapper} onLayout={(e) => setChartWidth(e.nativeEvent.layout.width)}>
<Svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`}> <Svg width="100%" height={height} viewBox={`0 0 ${chartWidth} ${height}`}>
<Defs> <Defs>
<LinearGradient id="incomeGrad" x1="0" y1="0" x2="0" y2="1"> <LinearGradient id="incomeGrad" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0%" stopColor={theme.colors.financial.income} stopOpacity={0.25} /> <Stop offset="0%" stopColor={theme.colors.financial.income} stopOpacity={0.25} />
@@ -93,7 +97,7 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
{/* 网格线(只绘制一条底线和中线) */} {/* 网格线(只绘制一条底线和中线) */}
<Path <Path
d={`M ${paddingX} ${height / 2} L ${width - paddingX} ${height / 2} M ${paddingX} ${height - paddingY} L ${width - paddingX} ${height - paddingY}`} d={`M ${paddingX} ${height / 2} L ${chartWidth - paddingX} ${height / 2} M ${paddingX} ${height - paddingY} L ${chartWidth - paddingX} ${height - paddingY}`}
stroke={theme.colors.divider} stroke={theme.colors.divider}
strokeWidth={1} strokeWidth={1}
strokeDasharray="4 4" strokeDasharray="4 4"
@@ -153,7 +157,7 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
{data.map((d, i) => ( {data.map((d, i) => (
<Text <Text
key={`x-label-${i}`} key={`x-label-${i}`}
style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: 10, fontFamily: theme.typography.caption.fontFamily }]} style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: theme.typography.caption.fontSize - 2 }]}
> >
{d.month.slice(5)} {d.month.slice(5)}
</Text> </Text>
@@ -168,15 +172,15 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
<View style={styles.legend}> <View style={styles.legend}>
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.income }]} /> <View style={[styles.legendDot, { backgroundColor: theme.colors.financial.income }]} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('home.income')} {t('home.income')}
</Text> </Text>
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.expense }]} /> <View style={[styles.legendDot, { backgroundColor: theme.colors.financial.expense }]} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('home.expense')} {t('home.expense')}
</Text> </Text>
</View> </View>
</View> </Animated.View>
); );
} }
+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 },
});
+249
View File
@@ -0,0 +1,249 @@
/**
* FormModal
* - TextField: 文本输入
* - SelectField: 单选卡片组
* - DropdownField: 下拉选择触发器 FormModal overlay
*/
import React from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Touchable } from '../ui/Touchable';
import type { ThemeTokens } from '../../theme';
import { createCommonStyles } from '../../theme';
type CommonStyles = ReturnType<typeof createCommonStyles>;
import type { FormField } from './FormModal';
// ---------- TextField ----------
interface TextFieldProps {
field: FormField;
value: string;
onChangeText: (text: string) => void;
isFocused: boolean;
onFocus: () => void;
onBlur: () => void;
theme: ThemeTokens;
commonStyles: CommonStyles;
}
export function TextField({ field: f, value, onChangeText, isFocused, onFocus, onBlur, theme, commonStyles }: TextFieldProps) {
return (
<View style={[fieldStyles.field, f.flex ? { flex: f.flex } : undefined]}>
<Text style={[theme.typography.caption, fieldStyles.fieldLabel, { color: theme.colors.fgSecondary }]}>
{f.label}
</Text>
<TextInput
value={value}
onChangeText={onChangeText}
onFocus={onFocus}
onBlur={onBlur}
placeholder={f.placeholder}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType={f.keyboardType ?? 'default'}
multiline={f.multiline}
secureTextEntry={f.secureTextEntry}
style={[
commonStyles.input,
f.flex ? { minHeight: 44 } : undefined,
{
backgroundColor: isFocused ? theme.colors.bgPrimary : theme.colors.bgTertiary,
borderColor: isFocused ? theme.colors.accent : theme.colors.border,
borderWidth: isFocused ? 1.5 : StyleSheet.hairlineWidth,
},
]}
/>
</View>
);
}
// ---------- SelectField ----------
interface SelectFieldProps {
field: FormField;
value: string;
onSelect: (value: string) => void;
theme: ThemeTokens;
}
export function SelectField({ field: f, value, onSelect, theme }: SelectFieldProps) {
return (
<View style={[fieldStyles.field, f.flex ? { flex: f.flex } : undefined]}>
<Text style={[theme.typography.caption, fieldStyles.fieldLabel, { color: theme.colors.fgSecondary }]}>
{f.label}
</Text>
<View style={fieldStyles.optionContainer}>
{f.options?.map(opt => {
const isSelected = value === opt.value;
return (
<Touchable
key={opt.value}
style={[
fieldStyles.optionChip,
{
backgroundColor: isSelected ? theme.colors.accent + '15' : theme.colors.bgPrimary,
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
},
]}
onPress={() => onSelect(opt.value)}
>
<View style={fieldStyles.optionTextWrap}>
<Text style={[fieldStyles.optionValueText, { color: theme.colors.fgSecondary }]}>
{opt.value}
</Text>
<Text
style={[
fieldStyles.optionLabelText,
{
color: isSelected ? theme.colors.accent : theme.colors.fgPrimary,
fontWeight: isSelected ? '700' : '600',
},
]}
>
{opt.label}
</Text>
</View>
{isSelected && (
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} style={{ marginLeft: 'auto' }} />
)}
</Touchable>
);
})}
</View>
</View>
);
}
// ---------- DropdownField ----------
interface DropdownFieldProps {
field: FormField;
value: string;
isFocused: boolean;
onOpen: () => void;
theme: ThemeTokens;
commonStyles: CommonStyles;
}
export function DropdownField({ field: f, value, isFocused, onOpen, theme, commonStyles }: DropdownFieldProps) {
const selectedOpt = f.options?.find(o => o.value === value);
const rawValue = selectedOpt ? selectedOpt.value : value;
const rawLabel = selectedOpt ? selectedOpt.label : '';
const hasColon = rawValue.includes(':');
const triggerPrefix = hasColon ? rawValue.slice(0, rawValue.lastIndexOf(':')) : '';
const triggerTitle = hasColon ? rawValue.slice(rawValue.lastIndexOf(':') + 1) : (rawLabel || f.placeholder || '');
const isPlaceholder = !rawValue;
return (
<View style={[fieldStyles.field, f.flex ? { flex: f.flex } : undefined]}>
<Text style={[theme.typography.caption, fieldStyles.fieldLabel, { color: theme.colors.fgSecondary }]}>
{f.label}
</Text>
<Touchable
style={[
commonStyles.input,
fieldStyles.dropdownTrigger,
{
borderColor: isFocused ? theme.colors.accent : theme.colors.border,
backgroundColor: isFocused ? theme.colors.bgPrimary : theme.colors.bgTertiary,
borderWidth: isFocused ? 1.5 : StyleSheet.hairlineWidth,
},
]}
onPress={onOpen}
>
<View style={fieldStyles.dropdownTriggerContent}>
{triggerPrefix ? (
<Text style={[fieldStyles.optionValueText, { color: theme.colors.fgSecondary }]} numberOfLines={1}>
{triggerPrefix}
</Text>
) : null}
<Text
style={[
fieldStyles.optionLabelText,
{
color: isPlaceholder ? theme.colors.fgSecondary : theme.colors.fgPrimary,
fontWeight: isPlaceholder ? '400' : '600',
},
]}
numberOfLines={1}
>
{triggerTitle}
</Text>
</View>
<Ionicons name="chevron-down" size={16} color={isFocused ? theme.colors.accent : theme.colors.fgSecondary} />
</Touchable>
</View>
);
}
// ---------- DropdownMenu (overlay 内容) ----------
interface DropdownMenuProps {
field: FormField;
value: string;
onSelect: (value: string) => void;
onClose: () => void;
theme: ThemeTokens;
}
export function DropdownMenu({ field, value, onSelect, onClose, theme }: DropdownMenuProps) {
return (
<View style={{ padding: 16 }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, letterSpacing: 0.5, fontWeight: '600' }]}>
{field.label}
</Text>
{field.options?.map(opt => {
const isSelected = value === opt.value;
const hasColon = opt.value.includes(':');
const pathPrefix = hasColon ? opt.value.slice(0, opt.value.lastIndexOf(':')) : '';
const shortName = hasColon ? opt.value.slice(opt.value.lastIndexOf(':') + 1) : opt.label;
return (
<Touchable
key={opt.value}
style={[
fieldStyles.dropdownMenuItem,
{
backgroundColor: isSelected ? theme.colors.accent + '15' : theme.colors.bgPrimary,
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
},
]}
onPress={() => { onSelect(opt.value); onClose(); }}
>
<View style={{ flex: 1 }}>
{pathPrefix ? (
<Text style={[fieldStyles.optionValueText, { color: theme.colors.fgSecondary }]}>
{pathPrefix}
</Text>
) : null}
<Text
style={[
fieldStyles.optionLabelText,
{
color: isSelected ? theme.colors.accent : theme.colors.fgPrimary,
fontWeight: isSelected ? '700' : '600',
fontSize: 14,
lineHeight: 18,
},
]}
>
{shortName}
</Text>
</View>
{isSelected && (
<Ionicons name="checkmark-circle" size={18} color={theme.colors.accent} />
)}
</Touchable>
);
})}
</View>
);
}
// ---------- 共享样式 ----------
const fieldStyles = StyleSheet.create({
field: { marginBottom: 14 },
fieldLabel: { fontWeight: '600', letterSpacing: 0.3, marginBottom: 6 },
optionContainer: { gap: 8, marginTop: 4 },
optionChip: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, borderWidth: 1, flexDirection: 'row', alignItems: 'center' },
optionTextWrap: { flexDirection: 'column' as const },
optionValueText: { fontSize: 10, lineHeight: 12 },
optionLabelText: { fontSize: 13, lineHeight: 16, fontWeight: '600' },
dropdownTrigger: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 2, paddingHorizontal: 12, minHeight: 44 },
dropdownTriggerContent: { justifyContent: 'center' },
dropdownMenuItem: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, borderWidth: 1, flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
});

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