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及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
This commit is contained in:
parent
bf04400852
commit
6767dd538a
@ -12,7 +12,7 @@ DriftLedger (浮记) is an offline-first mobile client for [Beancount](https://b
|
||||
| `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). |
|
||||
| `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. |
|
||||
@ -60,3 +60,8 @@ npx vitest run tests/ledger.test.ts
|
||||
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 弹窗**脱离主窗口 context:Modal 内必须重新包 `<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`。
|
||||
|
||||
@ -38,6 +38,8 @@ npm run typecheck # TypeScript 类型检查
|
||||
| [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 编程助手贡献指南 |
|
||||
|
||||
211
docs/accessibility-wechat-guide.md
Normal file
211
docs/accessibility-wechat-guide.md
Normal 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 Bridge:14 个 `@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` 标志和页面签名白名单的配合。
|
||||
@ -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/TSX(JS Bundle 由 Metro/assemble 自动打入)
|
||||
> - 只改了 `android/app/build.gradle`、`gradle.properties` 等已生成的 Gradle 配置
|
||||
>
|
||||
> [!WARNING]
|
||||
> **改了 TS 后,release 包偶尔不会更新**(见 §7):gradle 的 `createBundleReleaseJsAndAssets` 可能因为缓存跳过重新打包,或 Metro 用了 transformer cache。若发现"改了代码但设备上行为没变",**先按 §7.2 验证 bundle 是否真的含新代码**,而不是反复改代码。
|
||||
|
||||
---
|
||||
|
||||
## 1. 体积优化核心原理
|
||||
|
||||
### 1.1 ABI 分包 (ABI Splits)
|
||||
@ -31,11 +76,14 @@ reactNativeArchitectures=arm64-v8a
|
||||
```
|
||||
* **为什么只包含 arm64-v8a**:目前 99% 的主流现代 Android 实体机都是 64 位 ARM 架构(`arm64-v8a`)。在进行日常分发与本地 Release 测试时,默认只编译 arm64-v8a,能够省去编译另外 3 个架构的机器指令时间,**编译速度提升 3~4 倍**,且输出的包体最小。
|
||||
|
||||
> [!NOTE]
|
||||
> **`-PreactNativeArchitectures` 与 ABI Splits 的关系**:当启用了 §1.1 的 `splits.abi` 后,`assembleRelease` 会**无条件生成所有 `include` 列出的架构分包 + universal 包**,`-PreactNativeArchitectures` 参数只能限制"编译哪几个架构的原生库",并不能减少最终输出的 APK 数量。若想只产出一个 arm64 包、跳过其它架构的编译耗时,最干净的做法是**注释掉 `app/build.gradle` 里的 `splits { abi { ... } }` 块**,让 `reactNativeArchitectures=arm64-v8a` 单独生效。
|
||||
|
||||
### 1.3 Expo 持续原生生成 (Config Plugin) 的自动应用
|
||||
> [!IMPORTANT]
|
||||
> 由于 `android/` 目录被 Git 忽略,**不要直接手动在其他设备上提交原生配置变更**。
|
||||
> 本项目已编写了专门的本地 Config Plugin:[size-optimization](file:///c:/Users/fmq/Documents/work/beancount-mobile/plugins/size-optimization/app.plugin.js)。
|
||||
> 当在新设备上重新 `git clone` 项目后,运行以下指令即可自动拉起 Config Plugin 并在重新生成的 `android/` 目录中完美注入上述所有的体积优化配置(ABI 分包、默认单架构编译):
|
||||
> 由于 `android/` 目录被 Git 忽略(见 `.gitignore`),**不要直接手动修改 `android/` 下的文件并提交**——它们是 prebuild 生成的产物,会在下次重建时丢失。
|
||||
> 本项目已编写了专门的本地 Config Plugin:[size-optimization](file:///c:/Users/fmq/Documents/work/DriftLedger/plugins/size-optimization/app.plugin.js)。
|
||||
> 当在新设备上重新 `git clone` 项目后,运行以下指令即可自动拉起所有 Config Plugin 并在重新生成的 `android/` 目录中完美注入上述所有的体积优化配置(ABI 分包、默认单架构编译、NDK 版本统一):
|
||||
> ```bash
|
||||
> npx expo prebuild --platform android
|
||||
> ```
|
||||
@ -47,15 +95,24 @@ reactNativeArchitectures=arm64-v8a
|
||||
请在项目的根目录(若已在 `android/` 目录中则不需要前缀 `cd android`)执行以下指令:
|
||||
|
||||
### 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
|
||||
# 在 Windows Powershell 下执行:
|
||||
$env:JAVA_HOME="C:\Program Files\Java\jdk-21"
|
||||
$env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk"
|
||||
cd android
|
||||
.\gradlew.bat :app:assembleRelease --offline --no-daemon
|
||||
```
|
||||
编译完成后,可在以下路径找到适合真机安装的轻量版 APK(约 37MB):
|
||||
编译完成后,可在以下路径找到适合真机安装的轻量版 APK(开启混淆后约 **24MB**):
|
||||
* `android\app\build\outputs\apk\release\app-arm64-v8a-release.apk`
|
||||
|
||||
---
|
||||
@ -86,15 +143,228 @@ cd android
|
||||
|
||||
---
|
||||
|
||||
## 3. 高级优化项:Proguard/R8 与混淆 (选填)
|
||||
如需进一步将独立包体积压缩到 25MB 左右,可以考虑在 `gradle.properties` 中开启混淆并做裁剪防御。
|
||||
1. 在 `gradle.properties` 中添加:
|
||||
```properties
|
||||
android.enableMinifyInReleaseBuilds=true
|
||||
android.enableShrinkResourcesInReleaseBuilds=true
|
||||
```
|
||||
2. 注意:由于引入了 `ONNX Runtime` 动态调用,如果运行崩溃,需要在 `android/app/proguard-rules.pro` 中加入如下混淆保留白名单:
|
||||
```proguard
|
||||
-keep class com.microsoft.onnxruntime.** { *; }
|
||||
-dontwarn com.microsoft.onnxruntime.**
|
||||
```
|
||||
## 3. Proguard/R8 混淆(已默认开启)
|
||||
|
||||
本项目的 `gradle.properties` 已**默认启用**代码混淆与资源压缩:
|
||||
```properties
|
||||
android.enableMinifyInReleaseBuilds=true
|
||||
android.enableShrinkResourcesInReleaseBuilds=true
|
||||
```
|
||||
这使得 arm64-v8a 独立包从 ~37MB 压缩到约 **24MB**。无需手动开启。
|
||||
|
||||
> [!WARNING]
|
||||
> 若新增了依赖反射/动态加载的库(如 `ONNX Runtime` 的 JNI 调用),混淆后可能出现运行时 `ClassNotFoundException`。此时需在 `android/app/proguard-rules.pro`(由 ppocr 等 Config Plugin 注入)中补充保留规则,例如:
|
||||
> ```proguard
|
||||
> -keep class com.microsoft.onnxruntime.** { *; }
|
||||
> -dontwarn com.microsoft.onnxruntime.**
|
||||
> ```
|
||||
> 调试混淆问题时,可临时把上述两个属性改为 `false` 排查是否为混淆所致。
|
||||
|
||||
---
|
||||
|
||||
## 4. 通过 adb 安装到真机
|
||||
|
||||
编译产物就绪后,用 adb 覆盖安装到已连接的设备(保留应用数据):
|
||||
|
||||
```bash
|
||||
# 确认设备已连接(USB 调试已开启)
|
||||
adb devices
|
||||
|
||||
# 覆盖安装:-r 保留数据,-d 允许版本号不升(覆盖安装相同/更低 versionCode 时需要)
|
||||
adb install -r -d android/app/build/outputs/apk/release/app-arm64-v8a-release.apk
|
||||
```
|
||||
|
||||
常见问题:
|
||||
| 现象 | 原因与解决 |
|
||||
|---|---|
|
||||
| `adb: command not found` | adb 不在 PATH。Git Bash 下执行 `export PATH="/c/Users/fmq/AppData/Local/Android/Sdk/platform-tools:$PATH"` |
|
||||
| `device offline` / 列表为空 | 手机未授权 USB 调试,或驱动未装;重新插拔并在手机弹窗点「允许」 |
|
||||
| `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | 签名不一致(如之前装的是 debug 版)。先 `adb uninstall com.example.driftledger` 再装 |
|
||||
| 装完打开白屏/闪退 | 多为混淆误删(见 §3)或原生库架构不匹配(模拟器需 x86_64 包) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 原生插件(Config Plugin)开发避坑指南
|
||||
|
||||
本项目通过 7 个 Expo Config Plugin(`plugins/*/app.plugin.js`)在 prebuild 时注入原生代码与配置。以下是踩过的坑:
|
||||
|
||||
### 5.1 跨插件包名一致性陷阱(无障碍伪装包)
|
||||
|
||||
**背景**:为绕过微信 8.0.52+ 的节点混淆,`accessibility` 插件把 7 个 Kotlin 文件整体迁移到伪装包 `com.google.android.accessibility.selecttospeak`(伪装成系统「随说随读」服务),并在注入时用正则把源码里的 `com.beancount.mobile.accessibility` 改写成这个伪装包名。
|
||||
|
||||
**陷阱**:`notification-listener` / `screenshot-monitor` / `sms-receiver` 这三个插件的 Kotlin 源码**也 import 了** `com.beancount.mobile.accessibility.{SelectToSpeakService, ReactContextHolder}`。它们各自的 `app.plugin.js` 有一条「通用包名替换」规则:
|
||||
```js
|
||||
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||
```
|
||||
这条规则会**无差别**地把 `com.beancount.mobile.accessibility` 也替换成 `appId.accessibility`,导致引用指向一个不存在的包,编译时报:
|
||||
```
|
||||
e: ... BillingNotificationListenerService.kt: Unresolved reference 'accessibility'
|
||||
e: ... BillingNotificationListenerService.kt: Unresolved reference 'SelectToSpeakService'
|
||||
```
|
||||
|
||||
**修复**(已在三个插件中落地):在通用替换**之前**,先把 accessibility 子包引用单独改写到伪装包:
|
||||
```js
|
||||
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
|
||||
// 必须先改写 accessibility 子包引用,再做通用 com.beancount.mobile → appId 替换
|
||||
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
|
||||
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
|
||||
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||
```
|
||||
|
||||
> **教训**:任何插件如果要用正则做包名改写,必须**先处理跨插件共享的子包引用**(尤其是被「伪装/重命名」过的包),再做通配替换,否则通用规则会破坏跨插件依赖。
|
||||
|
||||
### 5.2 验证 prebuild 注入是否成功
|
||||
|
||||
prebuild 不会因「源码与注入结果不一致」而报错(它只是文件复制 + 字符串替换),所以注入错误只能在 gradle 编译时暴露。快速自查注入结果:
|
||||
```bash
|
||||
# 检查目标包名/类是否被注入到预期路径
|
||||
find android/app/src/main/java -iname "*SelectToSpeak*" -o -iname "*ReactContext*"
|
||||
# 对比源文件与注入文件的差异(应仅 package 声明行不同)
|
||||
diff plugins/accessibility/android/SelectToSpeakService.kt \
|
||||
android/app/src/main/java/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt
|
||||
```
|
||||
若编译报 `Unresolved reference`,先用上述命令确认类是否被注入、package 是否正确改写。
|
||||
|
||||
### 5.3 编译失败排查清单
|
||||
|
||||
| 错误特征 | 可能原因 | 排查 |
|
||||
|---|---|---|
|
||||
| `Unresolved reference 'XXX'` | 跨插件 import 包名改写不一致(见 §5.1) | 检查注入后文件的 `import` 行 |
|
||||
| `Cannot resolve symbol` / 找不到 R 资源 | res/xml 未随 kt 一起注入 | 检查 `app/src/main/res/xml/` 是否有配置文件 |
|
||||
| 改了 kt 但安装后行为没变 | 忘记重新 prebuild(android/ 是旧的) | `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;改的是 .kt,Metro 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 编译错误(编译错误走 stderr,stdout 往往只有 `> Task :app:compileReleaseKotlin FAILED`);③ APK 文件时间戳晚于本次编译开始时间。三者缺一即视为失败——尤其别被 `| tail` 的假成功骗到。
|
||||
|
||||
152
docs/modal-keyboard-guide.md
Normal file
152
docs/modal-keyboard-guide.md
Normal 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 的 API(SafeArea、某些 Keyboard 行为)在 Modal 内都要重新建立。
|
||||
2. **键盘避让用 padding,不用位移**——这是 Flutter/RN 通用共识(BeeCount 的 `AnimatedPadding + viewInsets`)。位移方案虽然直觉上像"弹窗上移",但会露出原位置,制造视觉缝隙。
|
||||
3. **Hermes bundle 是二进制**——验证 release 包代码时必须 `grep -a`,否则永远误判"代码没进去"。详见 `android-build-guide.md §7.2`。
|
||||
124
docs/ocr-pipeline-guide.md
Normal file
124
docs/ocr-pipeline-guide.md
Normal 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` 下限 1,bbox 四向各扩 `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
docs/ocr及文本模型测试.md
Normal file
147
docs/ocr及文本模型测试.md
Normal 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
docs/账单识别及账户分类设计.md
Normal file
155
docs/账单识别及账户分类设计.md
Normal 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
docs/账户分类模型测试.md
Normal file
195
docs/账户分类模型测试.md
Normal 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
eslint.config.mjs
Normal file
117
eslint.config.mjs
Normal 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',
|
||||
},
|
||||
},
|
||||
);
|
||||
1325
package-lock.json
generated
1325
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -8,7 +8,9 @@
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/ tests/",
|
||||
"lint:fix": "eslint src/ tests/ --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "^6.1.2",
|
||||
@ -43,8 +45,13 @@
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-native": "^5.0.0",
|
||||
"typescript": "~5.9.0",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vitest": "^3.2.0"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@ -13,7 +13,7 @@ import com.facebook.react.bridge.ReadableMap
|
||||
/**
|
||||
* 无障碍服务 RN 桥接模块(plan.md「3.6 无障碍服务」JS 接线)。
|
||||
*
|
||||
* BillingAccessibilityService 是 AccessibilityService 子类(非 RN 模块),
|
||||
* SelectToSpeakService 是 AccessibilityService 子类(非 RN 模块),
|
||||
* 其方法无法直接从 JS 调用。本模块作为中间层,通过 instance 静态引用
|
||||
* 把 JS 调用委托给服务实例。
|
||||
*
|
||||
@ -42,7 +42,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 无障碍服务是否已连接(用户已在系统设置中启用)。 */
|
||||
@ReactMethod
|
||||
fun isServiceRunning(promise: Promise) {
|
||||
promise.resolve(BillingAccessibilityService.instance != null)
|
||||
promise.resolve(SelectToSpeakService.instance != null)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -51,7 +51,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
*/
|
||||
@ReactMethod
|
||||
fun rememberCurrentPage(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
@ -72,7 +72,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 手动触发一次 OCR(截取当前屏幕并发送给 JS 层处理)。 */
|
||||
@ReactMethod
|
||||
fun triggerManualOcr(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
@ -88,7 +88,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 获取所有已记住的页面签名列表。 */
|
||||
@ReactMethod
|
||||
fun getPageSignatures(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
val sigsSet = if (service != null) {
|
||||
service.getPageSignatures()
|
||||
} else {
|
||||
@ -114,7 +114,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 清空所有已记住的页面签名。 */
|
||||
@ReactMethod
|
||||
fun clearPageSignatures(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service != null) {
|
||||
service.clearPageSignatures()
|
||||
} else {
|
||||
@ -132,7 +132,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 删除指定页面签名。 */
|
||||
@ReactMethod
|
||||
fun removePageSignature(signature: String, promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service != null) {
|
||||
service.removePageSignature(signature)
|
||||
} else {
|
||||
@ -155,7 +155,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
@ReactMethod
|
||||
fun getPaymentPackages(promise: Promise) {
|
||||
val arr = WritableNativeArray()
|
||||
for (pkg in BillingAccessibilityService.PAYMENT_PACKAGES) {
|
||||
for (pkg in SelectToSpeakService.PAYMENT_PACKAGES) {
|
||||
arr.pushString(pkg)
|
||||
}
|
||||
promise.resolve(arr)
|
||||
@ -164,7 +164,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 获取当前顶部 App 信息(供 JS 判断用户是否在支付页面)。 */
|
||||
@ReactMethod
|
||||
fun getTopApp(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
@ -178,7 +178,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 将当前应用拉起至前台,用以在后台识别出账单后,弹窗让用户进行交易确认 */
|
||||
@ReactMethod
|
||||
fun bringAppToForeground(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
@ -205,7 +205,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
draftId: String,
|
||||
promise: Promise
|
||||
) {
|
||||
val context = BillingAccessibilityService.instance ?: reactContext.currentActivity
|
||||
val context = SelectToSpeakService.instance ?: reactContext.currentActivity
|
||||
if (context == null) {
|
||||
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
|
||||
return
|
||||
@ -241,8 +241,8 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 设置悬浮球开关状态。 */
|
||||
@ReactMethod
|
||||
fun setFloatingBallEnabled(enabled: Boolean, promise: Promise) {
|
||||
BillingAccessibilityService.floatingBallEnabled = enabled
|
||||
val service = BillingAccessibilityService.instance
|
||||
SelectToSpeakService.floatingBallEnabled = enabled
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service == null) {
|
||||
promise.resolve(true)
|
||||
return
|
||||
|
||||
@ -409,7 +409,7 @@ class FloatingBillView(
|
||||
|
||||
sendOpenAppEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
|
||||
dismiss()
|
||||
BillingAccessibilityService.instance?.bringAppToForeground()
|
||||
SelectToSpeakService.instance?.bringAppToForeground()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ import android.widget.TextView
|
||||
* 贴合在屏幕边缘,采用高透、超轻量竖线指示器,点击后展开垂直对齐的功能菜单。
|
||||
*/
|
||||
class FloatingHelper(
|
||||
private val service: BillingAccessibilityService
|
||||
private val service: SelectToSpeakService
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "FloatingHelper"
|
||||
|
||||
@ -52,7 +52,7 @@ data class FloatingUiConfigLabels(
|
||||
/** 悬浮球「记住此页」。 */ val ballRemember: String = "记住此页",
|
||||
/** 记住页面成功提示(不含 emoji)。 */ val rememberSuccess: String = "已将当前页面加入识别白名单",
|
||||
/** 失败提示前缀(原生拼接 ': ' + e.message)。 */ val rememberFail: String = "记录失败",
|
||||
/** BillingAccessibilityService Toast「已记住页面签名」前缀。 */ val pageRemembered: String = "已记住页面签名",
|
||||
/** SelectToSpeakService Toast「已记住页面签名」前缀。 */ val pageRemembered: String = "已记住页面签名",
|
||||
/** 「该页面签名已存在」前缀。 */ val pageSignatureExists: String = "该页面签名已存在",
|
||||
)
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import android.app.PendingIntent
|
||||
* - 用户下拉快速设置,点击「OCR 记账」磁贴触发一次手动 OCR
|
||||
* - Android 14+ 用 PendingIntent + startActivityAndCollapse
|
||||
*
|
||||
* 触发后调用 BillingAccessibilityService.triggerManualOcr()。
|
||||
* 触发后调用 SelectToSpeakService.triggerManualOcr()。
|
||||
*/
|
||||
class OcrTileService : TileService() {
|
||||
|
||||
@ -38,9 +38,9 @@ class OcrTileService : TileService() {
|
||||
triggerManualOcr()
|
||||
}
|
||||
|
||||
/** 触发手动 OCR(通过 BillingAccessibilityService)。 */
|
||||
/** 触发手动 OCR(通过 SelectToSpeakService)。 */
|
||||
private fun triggerManualOcr() {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service != null) {
|
||||
service.triggerManualOcr()
|
||||
return
|
||||
|
||||
@ -29,16 +29,25 @@ import android.view.accessibility.AccessibilityNodeInfo
|
||||
* - 横屏免打扰(游戏/视频时不触发)
|
||||
* - ocrDoing 守卫(防止重复触发)
|
||||
*
|
||||
* 伪装说明:plan.md 决策 3「纯开源侧载」保留无障碍伪装(非应用商店分发)。
|
||||
* 注意:本服务类名在 manifest 中声明为 BillingAccessibilityService,
|
||||
* 伪装为系统服务(包名 com.beancount.mobile.accessibility)仅在侧载版本保留。
|
||||
* 伪装说明(绕过微信 8.0.52+ 节点混淆):
|
||||
* 微信按 ComponentName(包名/类名)识别系统服务白名单——TalkBack、SelectToSpeak
|
||||
* 等系统服务不受节点混淆影响,第三方服务则被混淆。
|
||||
* 本服务的 package + class 全部伪装为
|
||||
* com.google.android.accessibility.selecttospeak.SelectToSpeakService,
|
||||
* 使微信将其识别为系统 SelectToSpeak 服务,从而拿到未混淆的真实节点文本。
|
||||
*
|
||||
* 伪装由 Config Plugin(plugins/accessibility/app.plugin.js)在 prebuild 时完成:
|
||||
* - kt 源码 package 被 rewrite 为 com.google.android.accessibility.selecttospeak
|
||||
* - Manifest android:name 写成完整全限定名
|
||||
*
|
||||
* 注意:plan.md 决策 3「纯开源侧载」保留无障碍伪装(非应用商店分发)。
|
||||
*
|
||||
* 通过 DeviceEventEmitter 把识别事件推送到 JS 层 automationStore。
|
||||
*
|
||||
* ⚠️ 与 src/domain/constants.ts 同步:PAYMENT_PACKAGES
|
||||
* 修改时需两边同时更新。
|
||||
*/
|
||||
class BillingAccessibilityService : AccessibilityService() {
|
||||
class SelectToSpeakService : AccessibilityService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BillingAccessibility"
|
||||
@ -46,7 +55,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
private const val PREF_PAGE_SIGNATURES = "page_signatures"
|
||||
|
||||
@Volatile
|
||||
var instance: BillingAccessibilityService? = null
|
||||
var instance: SelectToSpeakService? = null
|
||||
private set
|
||||
|
||||
/** 悬浮球全局开关(由 JS 层通过 AccessibilityBridge 控制)。 */
|
||||
@ -119,6 +128,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC
|
||||
flags = AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY or
|
||||
AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or
|
||||
AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS or
|
||||
AccessibilityServiceInfo.DEFAULT
|
||||
notificationTimeout = 100L
|
||||
}
|
||||
@ -151,35 +161,8 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
// 页面切换时检查是否有已记住的签名需要触发文本提取
|
||||
scheduleContentChange()
|
||||
|
||||
// 调试:如果是微信或支付宝,延迟 800ms 抓取并打印全屏无障碍文本内容
|
||||
if (eventPackage == "com.tencent.mm" || eventPackage == "com.eg.android.AlipayGphone") {
|
||||
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)
|
||||
}
|
||||
// 注意:自动监听调试日志已移除(微信 8.0.52+ 混淆节点文本,自动监听无意义)。
|
||||
// 账单识别仅在用户点击悬浮球时触发(isManual=true),此时提取完整节点文本。
|
||||
}
|
||||
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
|
||||
// 内容变化时也检查(防抖合并)
|
||||
@ -231,11 +214,8 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
return // 未记住的页面不自动触发
|
||||
}
|
||||
|
||||
// 抓取并提取屏幕所有无障碍文本,并推送至 JS 侧进行解析/控制
|
||||
val rootNode = rootInActiveWindow
|
||||
val texts = mutableListOf<String>()
|
||||
dumpNodeTexts(rootNode, texts)
|
||||
rootNode?.recycle()
|
||||
// 抓取并提取屏幕所有无障碍文本(含 WebView 子窗口),并推送至 JS 侧
|
||||
val texts = dumpAllTexts()
|
||||
|
||||
val reactContext = ReactContextHolder.context
|
||||
if (reactContext != null) {
|
||||
@ -278,7 +258,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
val bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
|
||||
result.hardwareBuffer.close()
|
||||
if (bitmap != null) {
|
||||
val base64 = bitmapToBase64(bitmap)
|
||||
val base64 = bitmapToBase64(bitmap, packageName)
|
||||
bitmap.recycle()
|
||||
// 推送到 JS 层(NativeEventEmitter)
|
||||
sendScreenshotEvent(base64, packageName)
|
||||
@ -338,7 +318,12 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
}, 150)
|
||||
}
|
||||
|
||||
/** 手动触发一次节点文本提取并发送到 JS,从而让 JS 优先尝试直接文本解析。 */
|
||||
/**
|
||||
* 手动触发一次节点文本提取并发送到 JS,从而让 JS 优先尝试直接文本解析。
|
||||
*
|
||||
* 使用 dumpAllTexts() 覆盖所有窗口(含 WebView 子窗口),而非仅 rootInActiveWindow。
|
||||
* 伪装生效后这条路径会成为微信账单页的主识别路径,必须保证完整性。
|
||||
*/
|
||||
fun triggerManualExtraction() {
|
||||
handler.post {
|
||||
floatingHelper?.collapse()
|
||||
@ -347,10 +332,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
val activity = topActivity ?: ""
|
||||
val sigKey = "$pkg|$activity"
|
||||
|
||||
val rootNode = rootInActiveWindow
|
||||
val texts = mutableListOf<String>()
|
||||
dumpNodeTexts(rootNode, texts)
|
||||
rootNode?.recycle()
|
||||
val texts = dumpAllTexts()
|
||||
|
||||
val reactContext = ReactContextHolder.context
|
||||
if (reactContext != null) {
|
||||
@ -519,13 +501,15 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
|
||||
/** Bitmap → base64(JPEG 质量 60,参考 AutoAccounting bitmapToBase64)。 */
|
||||
private fun bitmapToBase64(bitmap: Bitmap): String {
|
||||
private fun bitmapToBase64(bitmap: Bitmap, packageName: String = ""): String {
|
||||
// 安全起见,如果 bitmap 是 HARDWARE 格式,将其复制为 ARGB_8888 软件格式
|
||||
val softwareBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.config == Bitmap.Config.HARDWARE) {
|
||||
bitmap.copy(Bitmap.Config.ARGB_8888, false)
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
// 诊断:分析截图有效性(区分「截到空白帧」与「有内容但 OCR 失败」)
|
||||
analyzeScreenshot(softwareBitmap, packageName)
|
||||
val baos = ByteArrayOutputStream()
|
||||
softwareBitmap.compress(Bitmap.CompressFormat.JPEG, 60, baos)
|
||||
if (softwareBitmap !== bitmap) {
|
||||
@ -534,6 +518,70 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图有效性诊断(纯日志,不影响主流程)。
|
||||
*
|
||||
* 通过网格采样统计像素,输出:
|
||||
* - 尺寸、采样数
|
||||
* - 是否纯色(所有采样像素颜色完全一致 → 疑似空白/纯色帧)
|
||||
* - 亮度均值与方差(方差≈0 → 无内容;方差高 → 有内容)
|
||||
* - 深色/浅色像素占比
|
||||
*
|
||||
* 用于定位微信 WebView 场景:takeScreenshot 是否截到了未渲染的空白帧。
|
||||
*/
|
||||
private fun analyzeScreenshot(bitmap: Bitmap, packageName: String) {
|
||||
try {
|
||||
val w = bitmap.width
|
||||
val h = bitmap.height
|
||||
if (w <= 0 || h <= 0) {
|
||||
Log.w(TAG, "[截图诊断] pkg=$packageName 无效尺寸: ${w}x${h}")
|
||||
return
|
||||
}
|
||||
// 网格采样:目标约 400 个采样点,避免大图全像素扫描阻塞
|
||||
val stepX = Math.max(1, w / 20)
|
||||
val stepY = Math.max(1, h / 20)
|
||||
var sum = 0L
|
||||
var sumSq = 0L
|
||||
var count = 0
|
||||
var firstPixel = 0
|
||||
var isSolidColor = true
|
||||
var darkCount = 0 // 亮度 < 30(近黑)
|
||||
var lightCount = 0 // 亮度 > 225(近白)
|
||||
for (y in 0 until h step stepY) {
|
||||
for (x in 0 until w step stepX) {
|
||||
val pixel = bitmap.getPixel(x, y)
|
||||
if (count == 0) {
|
||||
firstPixel = pixel
|
||||
} else if (pixel != firstPixel) {
|
||||
isSolidColor = false
|
||||
}
|
||||
// 亮度(ITU-R BT.601):0.299R + 0.587G + 0.114B
|
||||
val r = (pixel shr 16) and 0xFF
|
||||
val g = (pixel shr 8) and 0xFF
|
||||
val b = pixel and 0xFF
|
||||
val lum = (r * 299 + g * 587 + b * 114) / 1000
|
||||
sum += lum
|
||||
sumSq += lum.toLong() * lum
|
||||
if (lum < 30) darkCount++
|
||||
if (lum > 225) lightCount++
|
||||
count++
|
||||
}
|
||||
}
|
||||
val mean = if (count > 0) sum.toDouble() / count else 0.0
|
||||
val variance = if (count > 0) sumSq.toDouble() / count - mean * mean else 0.0
|
||||
val darkRatio = if (count > 0) darkCount.toDouble() / count else 0.0
|
||||
val lightRatio = if (count > 0) lightCount.toDouble() / count else 0.0
|
||||
// 判定:纯色 或 方差<10 视为「疑似空白帧」
|
||||
val likelyBlank = isSolidColor || variance < 10.0
|
||||
Log.i(TAG, "[截图诊断] pkg=$packageName 尺寸=${w}x${h} 采样=$count " +
|
||||
"纯色=$isSolidColor 亮度均值=${"%.1f".format(mean)} 方差=${"%.1f".format(variance)} " +
|
||||
"深色占比=${"%.2f".format(darkRatio)} 浅色占比=${"%.2f".format(lightRatio)} " +
|
||||
"疑似空白帧=$likelyBlank base64大小约=${(w * h) / 8}字节")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "[截图诊断] pkg=$packageName 分析异常: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 辅助:构造 WritableNativeMap。 */
|
||||
private fun writableMapOf(vararg pairs: Pair<String, Any?>): WritableNativeMap {
|
||||
val map = WritableNativeMap()
|
||||
@ -566,13 +614,18 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归提取无障碍节点树的全部文本。 */
|
||||
/** 递归提取无障碍节点树的全部文本(text + contentDescription)。 */
|
||||
private fun dumpNodeTexts(node: AccessibilityNodeInfo?, list: MutableList<String>) {
|
||||
if (node == null) return
|
||||
val text = node.text?.toString()
|
||||
if (!text.isNullOrBlank()) {
|
||||
list.add(text)
|
||||
}
|
||||
// 修复:微信 8.0.52+ 混淆了 text,但部分节点仍通过 contentDescription 暴露内容
|
||||
val desc = node.contentDescription?.toString()
|
||||
if (!desc.isNullOrBlank() && desc != text) {
|
||||
list.add(desc)
|
||||
}
|
||||
val childCount = node.childCount
|
||||
for (i in 0 until childCount) {
|
||||
val child = node.getChild(i) ?: continue
|
||||
@ -581,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 {
|
||||
if (node == null) return false
|
||||
@ -2,7 +2,13 @@
|
||||
* 无障碍服务 Config Plugin(plan.md「3.6 无障碍服务」+「决策 4」)。
|
||||
*
|
||||
* 在 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: { ... } },
|
||||
* 操作 application 必须通过 modResults.manifest.application。
|
||||
@ -12,6 +18,16 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require('
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* 伪装目标包名:系统 SelectToSpeak 服务的完整包名。
|
||||
* 7 个 kt 文件会被复制到此包路径下,Manifest 服务名也用此包的全限定名。
|
||||
*/
|
||||
const FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
|
||||
/** SelectToSpeakService 的完整 ComponentName(Manifest 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 端通过此函数自动同步。
|
||||
@ -52,29 +68,28 @@ function copyDir(src, dest) {
|
||||
}
|
||||
}
|
||||
|
||||
function getAppId(config) {
|
||||
return config.android?.package || 'com.example.driftledger';
|
||||
}
|
||||
|
||||
function withAccessibilityService(config) {
|
||||
const appId = getAppId(config);
|
||||
const PACKAGE = `${appId}.accessibility`;
|
||||
// 7 个 kt 文件全部跟随 SelectToSpeakService 伪装到 FAKE_PACKAGE 包下,
|
||||
// 不再使用应用真实包名(appId)。
|
||||
|
||||
// 1. 复制 Kotlin 源码 + res/xml 资源到原生工程
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (modConfig) => {
|
||||
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
||||
// Kotlin 源码(只复制 .kt 文件,不含 res/ 子目录)
|
||||
const pkgPath = appId.replace(/\./g, '/');
|
||||
const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'accessibility');
|
||||
// Kotlin 源码统一复制到 FAKE_PACKAGE 目录下(伪装为系统 SelectToSpeak 服务包名)。
|
||||
// kt 源码里仍写 `com.beancount.mobile`,此处用正则替换为 FAKE_PACKAGE。
|
||||
const fakePkgPath = FAKE_PACKAGE.replace(/\./g, '/');
|
||||
const ktDest = path.join(projectRoot, 'app/src/main/java', fakePkgPath);
|
||||
fs.mkdirSync(ktDest, { recursive: true });
|
||||
const androidDir = path.join(__dirname, 'android');
|
||||
for (const f of fs.readdirSync(androidDir)) {
|
||||
if (f.endsWith('.kt')) {
|
||||
let content = fs.readFileSync(path.join(androidDir, f), 'utf8');
|
||||
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
|
||||
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
@ -87,7 +102,7 @@ function withAccessibilityService(config) {
|
||||
// 从 constants.ts 读取包名列表,自动同步到 Kotlin
|
||||
const packages = readPaymentPackagesFromConstants();
|
||||
if (packages) {
|
||||
const ktFile = path.join(ktDest, 'BillingAccessibilityService.kt');
|
||||
const ktFile = path.join(ktDest, 'SelectToSpeakService.kt');
|
||||
if (fs.existsSync(ktFile)) {
|
||||
let ktContent = fs.readFileSync(ktFile, 'utf8');
|
||||
// 替换 PAYMENT_PACKAGES 定义(匹配 val PAYMENT_PACKAGES = setOf( ... ))
|
||||
@ -117,6 +132,8 @@ function withAccessibilityService(config) {
|
||||
]);
|
||||
|
||||
// 2. 注册 AccessibilityBridgePackage 到 MainApplication
|
||||
// 注意:AccessibilityBridgePackage 跟随其他 kt 文件一起被复制到 FAKE_PACKAGE 下,
|
||||
// 因此 import 路径要用 FAKE_PACKAGE 而非 PACKAGE(应用包名)。
|
||||
config = withMainApplication(config, (modConfig) => {
|
||||
let content = modConfig.modResults.contents;
|
||||
|
||||
@ -124,7 +141,7 @@ function withAccessibilityService(config) {
|
||||
content = content.replace(/^import\s+[\w.]+\.AccessibilityBridgePackage\s*$/gm, '');
|
||||
content = content.replace(
|
||||
/^(package\s+[\w.]+;?\s*)$/m,
|
||||
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
|
||||
`$1\nimport ${FAKE_PACKAGE}.AccessibilityBridgePackage`,
|
||||
);
|
||||
|
||||
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
|
||||
@ -160,10 +177,10 @@ function withAccessibilityService(config) {
|
||||
manifest['uses-permission'].push({ $: { 'android:name': overlayPerm } });
|
||||
}
|
||||
|
||||
// 1. 添加无障碍服务声明
|
||||
// 1. 添加无障碍服务声明(服务名使用伪装包的全限定名,以匹配微信系统服务白名单)
|
||||
const serviceNode = {
|
||||
$: {
|
||||
'android:name': `${PACKAGE}.BillingAccessibilityService`,
|
||||
'android:name': FAKE_SERVICE_NAME,
|
||||
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
|
||||
'android:label': '浮记-账单识别',
|
||||
'android:exported': 'false',
|
||||
@ -188,7 +205,7 @@ function withAccessibilityService(config) {
|
||||
app.service = [];
|
||||
}
|
||||
const exists = app.service.some(
|
||||
s => s.$['android:name'] === `${PACKAGE}.BillingAccessibilityService`
|
||||
s => s.$['android:name'] === FAKE_SERVICE_NAME
|
||||
);
|
||||
if (!exists) {
|
||||
app.service.push(serviceNode);
|
||||
@ -197,7 +214,7 @@ function withAccessibilityService(config) {
|
||||
// 3. 添加 OcrTileService 声明(快速设置磁贴,plan.md「3.11」)
|
||||
const tileServiceNode = {
|
||||
$: {
|
||||
'android:name': `${PACKAGE}.OcrTileService`,
|
||||
'android:name': FAKE_TILE_SERVICE_NAME,
|
||||
'android:label': 'OCR 记账',
|
||||
'android:icon': '@android:drawable/ic_menu_camera',
|
||||
'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE',
|
||||
@ -208,7 +225,7 @@ function withAccessibilityService(config) {
|
||||
}],
|
||||
};
|
||||
const tileExists = app.service.some(
|
||||
s => s.$['android:name'] === `${PACKAGE}.OcrTileService`
|
||||
s => s.$['android:name'] === FAKE_TILE_SERVICE_NAME
|
||||
);
|
||||
if (!tileExists) {
|
||||
app.service.push(tileServiceNode);
|
||||
|
||||
@ -8,7 +8,7 @@ import android.util.Log
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import com.beancount.mobile.accessibility.BillingAccessibilityService
|
||||
import com.beancount.mobile.accessibility.SelectToSpeakService
|
||||
import com.beancount.mobile.accessibility.ReactContextHolder
|
||||
|
||||
/**
|
||||
@ -16,7 +16,7 @@ import com.beancount.mobile.accessibility.ReactContextHolder
|
||||
*
|
||||
* 参考 AutoAccounting 的 NotificationListenerService:
|
||||
* - 提取支付 App 通知的 title/text
|
||||
* - 白名单过滤(复用 BillingAccessibilityService.PAYMENT_PACKAGES,单一数据源)
|
||||
* - 白名单过滤(复用 SelectToSpeakService.PAYMENT_PACKAGES,单一数据源)
|
||||
* - 关键词黑白名单(JS 层 keywordFilter 进一步过滤)
|
||||
* - MD5 去重(JS 层 NotificationChannel 处理,避免原生持有状态)
|
||||
* - onListenerDisconnected 时 requestRebind 自动重连
|
||||
@ -34,7 +34,7 @@ class BillingNotificationListenerService : NotificationListenerService() {
|
||||
runCatching {
|
||||
val packageName = sbn?.packageName?.toString() ?: return
|
||||
// 白名单过滤(复用无障碍服务的 PAYMENT_PACKAGES)
|
||||
if (!BillingAccessibilityService.PAYMENT_PACKAGES.contains(packageName)) return
|
||||
if (!SelectToSpeakService.PAYMENT_PACKAGES.contains(packageName)) return
|
||||
|
||||
val notification = sbn.notification
|
||||
val extras = notification.extras
|
||||
|
||||
@ -15,6 +15,15 @@ function getAppId(config) {
|
||||
return config.android?.package || 'com.example.driftledger';
|
||||
}
|
||||
|
||||
/**
|
||||
* 无障碍服务伪装目标包名(必须与 accessibility plugin 保持一致)。
|
||||
* 本服务的 Kotlin 源码 import 了 SelectToSpeakService/ReactContextHolder,
|
||||
* 而这些类在 prebuild 时被 accessibility plugin 整体迁移到此伪装包下,
|
||||
* 因此本 plugin 复制源码时必须把对 accessibility 子包的引用一并改写到此包,
|
||||
* 否则会产生 "Unresolved reference" 编译错误。
|
||||
*/
|
||||
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
|
||||
|
||||
function withNotificationListener(config) {
|
||||
const appId = getAppId(config);
|
||||
const PACKAGE = `${appId}.notification`;
|
||||
@ -31,6 +40,9 @@ function withNotificationListener(config) {
|
||||
for (const f of fs.readdirSync(srcDir)) {
|
||||
if (f.endsWith('.kt')) {
|
||||
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
|
||||
// 必须先改写 accessibility 子包引用(迁移到伪装包),再做通用包名替换,
|
||||
// 否则通用规则会把 com.beancount.mobile.accessibility 错误地改成 appId.accessibility。
|
||||
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
|
||||
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
|
||||
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||
fs.writeFileSync(path.join(dest, f), content, 'utf8');
|
||||
|
||||
@ -24,6 +24,8 @@ import kotlinx.coroutines.launch
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.nio.FloatBuffer
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@ -64,6 +66,8 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
|
||||
/** 模型文件目录(filesystem 绝对路径)。非空时从该目录加载模型,否则回退到 assets。 */
|
||||
@Volatile private var modelDir: String? = null
|
||||
/** 初始化完成信号,ensureReady 可等待异步 initEngine 完成(最多等 15 秒)。 */
|
||||
@Volatile private var initLatch = CountDownLatch(1)
|
||||
|
||||
override fun getName(): String = OCR_MODULE_NAME
|
||||
|
||||
@ -115,9 +119,11 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
ortEnv = env
|
||||
dictionary = dict
|
||||
initialized = true
|
||||
initLatch.countDown()
|
||||
Log.i(OCR_MODULE_NAME, "PP-OCRv6 ONNX 模型加载成功(det+rec, dict=${dict.size})")
|
||||
} catch (e: Exception) {
|
||||
initFailed = true
|
||||
initLatch.countDown()
|
||||
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
|
||||
val dir = modelDir
|
||||
if (dir != null) {
|
||||
@ -181,7 +187,7 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
promise.reject("DECODE_FAILED", "base64 解码失败")
|
||||
return@launch
|
||||
}
|
||||
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
|
||||
scaled = capLongEdge(bitmap, CAP_LONG_EDGE)
|
||||
val blocks = runInference(scaled)
|
||||
val text = blocks.joinToString("\n") { it.text }
|
||||
promise.resolve(text)
|
||||
@ -213,7 +219,7 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
promise.reject("DECODE_FAILED", "base64 解码失败")
|
||||
return@launch
|
||||
}
|
||||
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
|
||||
scaled = capLongEdge(bitmap, CAP_LONG_EDGE)
|
||||
val blocks = runInference(scaled)
|
||||
// 序列化为 RN WritableArray
|
||||
val arr = WritableNativeArray()
|
||||
@ -249,24 +255,38 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
/** 设置模型文件目录(绝对路径)。若引擎已初始化则释放并重新加载。 */
|
||||
@ReactMethod
|
||||
fun setModelDir(dir: String, promise: Promise) {
|
||||
modelDir = dir
|
||||
if (initialized) {
|
||||
release()
|
||||
initFailed = false
|
||||
scope.launch { initEngine() }
|
||||
}
|
||||
// 修复:expo-file-system 返回 file:// URI,需转为文件系统绝对路径
|
||||
val newDir = dir.removePrefix("file://")
|
||||
// 修复:目录未变且引擎已就绪时跳过重新加载,避免冗余 release 导致竞态
|
||||
if (newDir == modelDir && initialized) {
|
||||
promise.resolve(true)
|
||||
return
|
||||
}
|
||||
modelDir = newDir
|
||||
// 修复:无论之前是成功还是失败,设置新目录后都应重新加载模型
|
||||
if (initialized || initFailed) {
|
||||
release()
|
||||
initFailed = false
|
||||
initLatch = CountDownLatch(1)
|
||||
scope.launch { initEngine() }
|
||||
}
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
// ============== 推理流水线 ==============
|
||||
|
||||
private fun ensureReady() {
|
||||
if (!initialized && !initFailed) initEngine()
|
||||
// 修复:异步 initEngine 进行中时等待完成(最多 15 秒),而非立即抛异常
|
||||
if (!initialized && !initFailed) {
|
||||
initLatch.await(15, TimeUnit.SECONDS)
|
||||
}
|
||||
if (!initialized) throw IllegalStateException("OCR 引擎未就绪(模型未加载,${if (initFailed) "初始化失败" else "加载中"})")
|
||||
}
|
||||
|
||||
/** 完整推理:det 检测文本框 → 对每个框 rec 识别 → 返回带坐标的文本块。 */
|
||||
private fun runInference(bitmap: Bitmap): List<OcrBlock> {
|
||||
val totalStartTime = System.currentTimeMillis()
|
||||
Log.i(OCR_MODULE_NAME, "runInference 开始: bitmap 尺寸 = ${bitmap.width}x${bitmap.height}")
|
||||
val det = detSession ?: run {
|
||||
Log.e(OCR_MODULE_NAME, "detSession 为空,放弃推理")
|
||||
@ -282,8 +302,14 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
var detOutputs: OrtSession.Result? = null
|
||||
val results = mutableListOf<OcrBlock>()
|
||||
|
||||
var detTime = 0L
|
||||
var postTime = 0L
|
||||
var recTime = 0L
|
||||
var boxCount = 0
|
||||
|
||||
try {
|
||||
// ---- 1. 文本检测(DB)----
|
||||
val detStartTime = System.currentTimeMillis()
|
||||
resized = resizeForDet(bitmap, DET_LIMIT_MAX_SIDE)
|
||||
Log.i(OCR_MODULE_NAME, "det 图像缩放后尺寸 = ${resized.width}x${resized.height}")
|
||||
val ratioX = bitmap.width.toFloat() / resized.width
|
||||
@ -292,18 +318,30 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
val detInput = preprocessDet(resized)
|
||||
detInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(detInput.data), longArrayOf(1L, 3L, detInput.h.toLong(), detInput.w.toLong()))
|
||||
val detInputs = mapOf("x" to detInputTensor)
|
||||
|
||||
val detRunStart = System.currentTimeMillis()
|
||||
detOutputs = det.run(detInputs)
|
||||
detTime = System.currentTimeMillis() - detRunStart
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val detProb = (detOutputs[0].value as Array<Array<Array<FloatArray>>>)[0][0] // [H,W]
|
||||
Log.i(OCR_MODULE_NAME, "det 推理完成,概率图尺寸 = ${detProb.size}x${detProb[0].size}")
|
||||
Log.i(OCR_MODULE_NAME, "det 推理完成 (耗时: ${detTime}ms),概率图尺寸 = ${detProb.size}x${detProb[0].size}")
|
||||
|
||||
// DB 后处理:threshold → 轮廓 → 最小外接矩形
|
||||
val postStart = System.currentTimeMillis()
|
||||
val boxes = dbPostprocess(detProb, detInput.h, detInput.w, ratioX, ratioY)
|
||||
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)----
|
||||
val recStart = System.currentTimeMillis()
|
||||
for ((idx, box) in boxes.withIndex()) {
|
||||
var crop: Bitmap? = null
|
||||
var recInputTensor: OnnxTensor? = null
|
||||
@ -341,6 +379,7 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
recOutputs?.close()
|
||||
}
|
||||
}
|
||||
recTime = System.currentTimeMillis() - recStart
|
||||
} finally {
|
||||
if (resized !== bitmap) {
|
||||
resized?.recycle()
|
||||
@ -349,6 +388,12 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
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
|
||||
}
|
||||
|
||||
@ -370,8 +415,8 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
val px = pixels[y * w + x]
|
||||
// 提取 R/G/B(c=0→R, 1→G, 2→B)
|
||||
val channelVal = (px shr (16 - 8 * c)) and 0xFF
|
||||
// 对齐官方训练通道序 BGR(c=0→B, 1→G, 2→R);mean/std 数值按 BGR 顺序配套
|
||||
val channelVal = (px shr (8 * c)) and 0xFF
|
||||
data[c * w * h + y * w + x] = (channelVal / 255.0f - mean) / std
|
||||
}
|
||||
}
|
||||
@ -383,9 +428,10 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
private fun preprocessRec(bmp: Bitmap): TensorData {
|
||||
var w = bmp.width
|
||||
val h = bmp.height
|
||||
// resize 到高度 48,宽度等比缩放
|
||||
var resizedW = (w.toFloat() / h * REC_IMAGE_HEIGHT).toInt()
|
||||
// 宽度上限,避免单行过长爆显存
|
||||
// resize 到高度 48,宽度等比缩放(ceil 取整对齐官方,避免右缘字符因 floor 被裁)
|
||||
// 注:java.lang.Math.ceil 仅有 double 重载,故用 toDouble()(Math.round 有 float 重载所以无需)
|
||||
var resizedW = Math.ceil(w.toDouble() / h * REC_IMAGE_HEIGHT).toInt()
|
||||
// 宽度上限,避免单行过长爆显存(官方 cap=3200,移动端折中 REC_MAX_WIDTH=1280)
|
||||
resizedW = min(resizedW, REC_MAX_WIDTH)
|
||||
resizedW = max(resizedW, 1)
|
||||
val resized = if (resizedW == w && h == REC_IMAGE_HEIGHT) bmp
|
||||
@ -411,9 +457,9 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
return TensorData(data, w, REC_IMAGE_HEIGHT)
|
||||
}
|
||||
|
||||
// ============== DB 后处理(简化版) ==============
|
||||
// 参考 PaddleOCR db_postprocess:sigmoid → threshold → 连通域 → 最小外接矩形
|
||||
// 这里用轻量实现:逐像素阈值化后用投影法估框,对常见单/多行账单足够。
|
||||
// ============== DB 后处理(连通域法,对齐 PaddleOCR 官方) ==============
|
||||
// sigmoid → 阈值二值化 → 4-连通域标记 → 每域 bbox 按官方 unclip 公式外扩 → box_score_fast 过滤。
|
||||
// 取代旧的「水平/垂直投影法」:投影法会把基线孤立小数点切到行外导致金额丢点(¥143.97→¥14397)。
|
||||
|
||||
/**
|
||||
* DB 后处理:sigmoid + 阈值 0.3 → 二值图 → 连通域外接矩形。
|
||||
@ -438,6 +484,7 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
val endY = (h * 0.92).toInt()
|
||||
|
||||
val binMask = Array(h) { IntArray(w) }
|
||||
val sigMap = Array(h) { FloatArray(w) } // 概率图(sigmoid 后),供连通域 box_score_fast 取文本像素均值
|
||||
var activeCountTotal = 0
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
@ -451,6 +498,7 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
sigMap[y][x] = sig
|
||||
val isActive = if (sig > DET_THRESH) 1 else 0
|
||||
binMask[y][x] = isActive
|
||||
if (isActive == 1) activeCountTotal++
|
||||
@ -475,79 +523,76 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
}
|
||||
Log.i(OCR_MODULE_NAME, "垂直线噪清理完成: 清理了 $clearedColsCount / $w 列")
|
||||
|
||||
// 水平投影:按行找文本行
|
||||
val rowHits = IntArray(h)
|
||||
// 连通域标记(4-连通,迭代 BFS 防栈溢出):取代旧的「水平投影切行 + 垂直投影切列」。
|
||||
// 投影法会把基线/顶线上的孤立标点(小数点等,该行水平投影 < w/20)切到行外,
|
||||
// 导致 crop 不含标点、rec 漏识(金额 ¥143.97 → ¥14397 的根因,已用官方同模型对照坐实)。
|
||||
// 连通域天然把「数字 + 基线点」归为同一域,根治丢点;并正确处理断裂字符与多栏版面。
|
||||
val labels = Array(h) { IntArray(w) }
|
||||
var nComp = 0
|
||||
val compX0 = mutableListOf<Int>()
|
||||
val compY0 = mutableListOf<Int>()
|
||||
val compX1 = mutableListOf<Int>()
|
||||
val compY1 = mutableListOf<Int>()
|
||||
val compArea = mutableListOf<Int>()
|
||||
val compSum = mutableListOf<Float>() // 域内文本像素 sig 累加,供 box_score_fast 取均值
|
||||
val stack = ArrayDeque<IntArray>()
|
||||
for (y in 0 until h) {
|
||||
var sum = 0
|
||||
for (x in 0 until w) sum += binMask[y][x]
|
||||
rowHits[y] = sum
|
||||
}
|
||||
val minRowWidth = max(1, w / 20) // 一行至少要有这么多像素才算文本
|
||||
val rowRanges = mutableListOf<IntArray>()
|
||||
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
|
||||
for (x in 0 until w) {
|
||||
if (binMask[y][x] == 1 && labels[y][x] == 0) {
|
||||
nComp++
|
||||
val id = nComp
|
||||
var x0 = x; var y0 = y; var x1 = x; var y1 = y
|
||||
var area = 0; var sum = 0f
|
||||
stack.clear()
|
||||
stack.addLast(intArrayOf(x, y))
|
||||
labels[y][x] = id
|
||||
while (stack.isNotEmpty()) {
|
||||
val p = stack.removeLast()
|
||||
val px = p[0]; val py = p[1]
|
||||
area++
|
||||
sum += sigMap[py][px]
|
||||
if (px < x0) x0 = px; if (px > x1) x1 = px
|
||||
if (py < y0) y0 = py; if (py > y1) y1 = py
|
||||
if (px > 0 && binMask[py][px - 1] == 1 && labels[py][px - 1] == 0) { labels[py][px - 1] = id; stack.addLast(intArrayOf(px - 1, py)) }
|
||||
if (px < w - 1 && binMask[py][px + 1] == 1 && labels[py][px + 1] == 0) { labels[py][px + 1] = id; stack.addLast(intArrayOf(px + 1, py)) }
|
||||
if (py > 0 && binMask[py - 1][px] == 1 && labels[py - 1][px] == 0) { labels[py - 1][px] = id; stack.addLast(intArrayOf(px, py - 1)) }
|
||||
if (py < h - 1 && binMask[py + 1][px] == 1 && labels[py + 1][px] == 0) { labels[py + 1][px] = id; stack.addLast(intArrayOf(px, py + 1)) }
|
||||
}
|
||||
compX0.add(x0); compY0.add(y0); compX1.add(x1); compY1.add(y1)
|
||||
compArea.add(area); compSum.add(sum)
|
||||
}
|
||||
}
|
||||
}
|
||||
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>>()
|
||||
// 对每行做垂直投影切列(账单每行通常是连续一段或多段)
|
||||
for ((y0, y1) in rowRanges.map { it[0] to it[1] }) {
|
||||
val colHits = IntArray(w)
|
||||
for (x in 0 until w) {
|
||||
var sum = 0
|
||||
for (y in y0..y1) sum += binMask[y][x]
|
||||
colHits[x] = sum
|
||||
}
|
||||
val minColHeight = max(1, (y1 - y0 + 1) / 12)
|
||||
var inSeg = false
|
||||
var segStart = 0
|
||||
var segs = mutableListOf<IntArray>()
|
||||
for (x in 0 until w) {
|
||||
val isText = colHits[x] >= minColHeight
|
||||
if (isText && !inSeg) { inSeg = true; segStart = x }
|
||||
else if (!isText && inSeg) {
|
||||
// 合并间隔很近的段
|
||||
if (segs.isNotEmpty() && segStart - segs.last()[1] < DET_MERGE_GAP) {
|
||||
segs.last()[1] = x - 1
|
||||
} 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(
|
||||
floatArrayOf(fx0, fy0),
|
||||
floatArrayOf(fx1, fy0),
|
||||
floatArrayOf(fx1, fy1),
|
||||
floatArrayOf(fx0, fy1),
|
||||
))
|
||||
}
|
||||
var dropSmall = 0; var dropScore = 0
|
||||
for (i in 0 until nComp) {
|
||||
val bx0 = compX0[i]; val by0 = compY0[i]; val bx1 = compX1[i]; val by1 = compY1[i]
|
||||
val bw = bx1 - bx0 + 1; val bh = by1 - by0 + 1
|
||||
val area = compArea[i]
|
||||
val perim = 2 * (bw + bh)
|
||||
// 官方 unclip 公式:distance = 连通域面积 × 比例 / 周长(水平文本下≈各向同性外扩,把基线标点纳入框)
|
||||
var d = if (perim > 0) area * UNCLIP_RATIO / perim else 0f
|
||||
if (d < 1f) d = 1f
|
||||
val nx0 = max(0, Math.round(bx0 - d))
|
||||
val ny0 = max(0, Math.round(by0 - d))
|
||||
val nx1 = min(w - 1, Math.round(bx1 + d))
|
||||
val ny1 = min(h - 1, Math.round(by1 + d))
|
||||
if ((nx1 - nx0 + 1) < MIN_SIZE || (ny1 - ny0 + 1) < MIN_SIZE) { dropSmall++; continue }
|
||||
// box_score_fast:域内文本像素 prob 均值(非整 bbox 均值,否则被背景稀释而误杀)
|
||||
val score = compSum[i] / area
|
||||
if (score < BOX_THRESH) { dropScore++; continue }
|
||||
val fx0 = nx0 * ratioX; val fy0 = ny0 * ratioY
|
||||
val fx1 = nx1 * ratioX; val fy1 = ny1 * ratioY
|
||||
boxes.add(listOf(
|
||||
floatArrayOf(fx0, fy0),
|
||||
floatArrayOf(fx1, fy0),
|
||||
floatArrayOf(fx1, fy1),
|
||||
floatArrayOf(fx0, fy1),
|
||||
))
|
||||
}
|
||||
Log.i(OCR_MODULE_NAME, "dbPostprocess 取框完成: 扩后min_size丢=$dropSmall, box_score丢=$dropScore, 保留=${boxes.size}")
|
||||
return boxes
|
||||
}
|
||||
|
||||
@ -606,18 +651,29 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
val w = maxX - minX
|
||||
val h = maxY - minY
|
||||
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 {
|
||||
val ratio = maxSide.toFloat() / max(bmp.width, bmp.height)
|
||||
if (ratio >= 1f) return bmp
|
||||
val newW = (bmp.width * ratio).toInt()
|
||||
val newH = (bmp.height * ratio).toInt()
|
||||
// 确保尺寸是 32 的倍数(det 模型下采样要求)
|
||||
val alignedW = (newW / 32) * 32
|
||||
val alignedH = (newH / 32) * 32
|
||||
if (alignedW < 32 || alignedH < 32) return bmp
|
||||
// 长边上限 + 无条件对齐 32(det 下采样要求),half-up 取整对齐官方 round。
|
||||
// 关键修复:旧实现 ratio>=1 时直接 return 不对齐,若整图未预压且非 32 倍数会喂入非法尺寸,
|
||||
// 导致 det 内部特征图广播报错;现无条件对齐,且 cap 后图通常 ≤32 倍数时仅轻微取整。
|
||||
val ratioC = minOf(1f, maxSide.toFloat() / max(bmp.width, bmp.height))
|
||||
val newW = Math.round(bmp.width * ratioC)
|
||||
val newH = Math.round(bmp.height * ratioC)
|
||||
val alignedW = max(32, Math.round(newW / 32f) * 32)
|
||||
val alignedH = max(32, Math.round(newH / 32f) * 32)
|
||||
if (alignedW == bmp.width && alignedH == bmp.height) return bmp
|
||||
return Bitmap.createScaledBitmap(bmp, alignedW, alignedH, true)
|
||||
}
|
||||
|
||||
@ -637,17 +693,18 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
}
|
||||
|
||||
/**
|
||||
* 短边压缩到 maxShortEdge(参考 AutoAccounting scaleDownForOcr)。
|
||||
* 像素量比 1440p 减少约 75%,识别速度大幅提升。
|
||||
* 整图长边上限降采样:仅当长边超过 cap 才按比例缩小(half-up 取整),否则原样返回。
|
||||
* 使 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 height = bitmap.height
|
||||
val shortEdge = minOf(width, height)
|
||||
if (shortEdge <= maxShortEdge) return bitmap
|
||||
val scale = maxShortEdge.toFloat() / shortEdge
|
||||
val newWidth = (width * scale).toInt()
|
||||
val newHeight = (height * scale).toInt()
|
||||
val longEdge = maxOf(width, height)
|
||||
if (longEdge <= capLong) return bitmap
|
||||
val scale = capLong.toFloat() / longEdge
|
||||
val newWidth = Math.round(width * scale)
|
||||
val newHeight = Math.round(height * scale)
|
||||
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
|
||||
}
|
||||
|
||||
@ -683,18 +740,23 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
private data class TensorData(val data: FloatArray, val w: Int, val h: Int)
|
||||
|
||||
companion object {
|
||||
/** OCR 最大短边(参考 AutoAccounting OCR_MAX_SHORT_EDGE)。 */
|
||||
private const val OCR_MAX_SHORT_EDGE = 720
|
||||
/** det resize 最大边(PaddleOCR limit_max_side_len 默认值)。 */
|
||||
private const val DET_LIMIT_MAX_SIDE = 960
|
||||
/** DB 二值化阈值。 */
|
||||
private const val DET_THRESH = 0.3f
|
||||
/** 投影法合并相邻文本段的间隔(像素)。 */
|
||||
private const val DET_MERGE_GAP = 10
|
||||
/** 整图长边上限:仅超大图降采样防 OOM;手机截图(≤2400)不预压,使 rec 的 crop 源为高清原图。
|
||||
* 对齐官方「只放大不缩小」语义的移动端折中(官方 max_side_limit=4000)。 */
|
||||
private const val CAP_LONG_EDGE = 3000
|
||||
/** det 输入长边上限(官方 OCR 管线不降采样;移动端为性能折中取 1600,旧值 960 会让小数点仅 1-2px 而糊掉)。 */
|
||||
private const val DET_LIMIT_MAX_SIDE = 1600
|
||||
/** DB 二值化阈值(对齐官方模型 inference.yml=0.2,对细笔画/小数点更敏感;噪声框由 BOX_THRESH 兜底)。 */
|
||||
private const val DET_THRESH = 0.2f
|
||||
/** box_score_fast 过滤阈值:连通域文本像素 prob 均值低于此值视为噪声框(对齐官方 OCR 管线=0.6)。 */
|
||||
private const val BOX_THRESH = 0.6f
|
||||
/** 官方 unclip 外扩比例:distance = 连通域面积 × 比例 / 周长。 */
|
||||
private const val UNCLIP_RATIO = 1.5f
|
||||
/** 外扩后文本框短边下限(像素),小于此值丢弃(对齐官方 min_size=5)。 */
|
||||
private const val MIN_SIZE = 5
|
||||
/** rec 固定图像高度。 */
|
||||
private const val REC_IMAGE_HEIGHT = 48
|
||||
/** rec 单行最大宽度。 */
|
||||
private const val REC_MAX_WIDTH = 320
|
||||
/** rec 单行最大宽度(官方 cap=3200,移动端折中 1280;旧值 320 会把长商户名水平压扁 5×+)。 */
|
||||
private const val REC_MAX_WIDTH = 1280
|
||||
/** assets 中的模型/字典文件名。 */
|
||||
private const val ASSET_DET_MODEL = "ppocrv6_det.onnx"
|
||||
private const val ASSET_REC_MODEL = "ppocrv6_rec.onnx"
|
||||
|
||||
@ -16,6 +16,13 @@ function getAppId(config) {
|
||||
return config.android?.package || 'com.example.driftledger';
|
||||
}
|
||||
|
||||
/**
|
||||
* 无障碍服务伪装目标包名(必须与 accessibility plugin 保持一致)。
|
||||
* ScreenshotModule import 了 ReactContextHolder,该类在 prebuild 时被
|
||||
* accessibility plugin 整体迁移到此伪装包下,故此处需同步改写引用。
|
||||
*/
|
||||
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
|
||||
|
||||
function withScreenshotMonitor(config) {
|
||||
const appId = getAppId(config);
|
||||
const PACKAGE = `${appId}.screenshot`;
|
||||
@ -32,6 +39,9 @@ function withScreenshotMonitor(config) {
|
||||
for (const f of fs.readdirSync(srcDir)) {
|
||||
if (f.endsWith('.kt')) {
|
||||
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
|
||||
// 必须先改写 accessibility 子包引用(迁移到伪装包),再做通用包名替换,
|
||||
// 否则通用规则会把 com.beancount.mobile.accessibility 错误地改成 appId.accessibility。
|
||||
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
|
||||
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
|
||||
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||
fs.writeFileSync(path.join(dest, f), content, 'utf8');
|
||||
|
||||
@ -1,50 +1,44 @@
|
||||
const { withDangerousMod } = require('@expo/config-plugins');
|
||||
const { withDangerousMod, withGradleProperties } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function withSizeOptimization(config) {
|
||||
// 1. 在 prebuild 时修改 gradle.properties (开启 R8/ProGuard 代码混淆 + 资源裁剪 + .so 库压缩)
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (modConfig) => {
|
||||
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
||||
const propertiesPath = path.join(projectRoot, 'gradle.properties');
|
||||
if (fs.existsSync(propertiesPath)) {
|
||||
let content = fs.readFileSync(propertiesPath, 'utf8');
|
||||
|
||||
// 限制打包架构为 arm64-v8a
|
||||
if (content.includes('reactNativeArchitectures=')) {
|
||||
content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a');
|
||||
} else {
|
||||
content += '\nreactNativeArchitectures=arm64-v8a\n';
|
||||
}
|
||||
|
||||
// 1. 开启 R8 代码混淆与摇树裁剪
|
||||
if (!content.includes('android.enableMinifyInReleaseBuilds=')) {
|
||||
content += 'android.enableMinifyInReleaseBuilds=true\n';
|
||||
} else {
|
||||
content = content.replace(/android\.enableMinifyInReleaseBuilds=.*/, 'android.enableMinifyInReleaseBuilds=true');
|
||||
}
|
||||
|
||||
// 1. 开启无用资源裁剪
|
||||
if (!content.includes('android.enableShrinkResourcesInReleaseBuilds=')) {
|
||||
content += 'android.enableShrinkResourcesInReleaseBuilds=true\n';
|
||||
} else {
|
||||
content = content.replace(/android\.enableShrinkResourcesInReleaseBuilds=.*/, 'android.enableShrinkResourcesInReleaseBuilds=true');
|
||||
}
|
||||
|
||||
// 2. 开启 .so 动态库在 APK 内的高倍率压缩 (useLegacyPackaging=true)
|
||||
if (!content.includes('expo.useLegacyPackaging=')) {
|
||||
content += 'expo.useLegacyPackaging=true\n';
|
||||
} else {
|
||||
content = content.replace(/expo\.useLegacyPackaging=.*/, 'expo.useLegacyPackaging=true');
|
||||
}
|
||||
|
||||
fs.writeFileSync(propertiesPath, content, 'utf8');
|
||||
}
|
||||
return modConfig;
|
||||
/**
|
||||
* 在 gradle.properties 的 modResults 数组中设置某个 key(存在则更新,不存在则追加)。
|
||||
*
|
||||
* 必须用 withGradleProperties(走 expo 内存 mod 流程)而非 withDangerousMod(直接改文件),
|
||||
* 否则会被 expo 的 gradleProperties base mod 在 write 阶段用内存快照全量覆盖。
|
||||
* 典型症状:reactNativeArchitectures 改了不生效(被覆盖回全架构),
|
||||
* 而 minify/shrink 等新 key 反而生效(不在 expo 内存快照里,未被覆盖)。
|
||||
*/
|
||||
function setGradleProperty(modResults, key, value) {
|
||||
let found = false;
|
||||
const updated = modResults.map((prop) => {
|
||||
if (prop.type === 'property' && prop.key === key) {
|
||||
found = true;
|
||||
return { ...prop, value };
|
||||
}
|
||||
]);
|
||||
return prop;
|
||||
});
|
||||
if (!found) {
|
||||
updated.push({ type: 'property', key, value });
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
function withSizeOptimization(config) {
|
||||
// 1. 修改 gradle.properties(用 withGradleProperties 避免 base mod write 覆盖)
|
||||
// - 限制打包架构为 arm64-v8a(加速构建、减小包体)
|
||||
// - 开启 R8 混淆与资源裁剪
|
||||
// - 开启 .so 高倍率压缩
|
||||
config = withGradleProperties(config, (config) => {
|
||||
let props = config.modResults;
|
||||
props = setGradleProperty(props, 'reactNativeArchitectures', 'arm64-v8a');
|
||||
props = setGradleProperty(props, 'android.enableMinifyInReleaseBuilds', 'true');
|
||||
props = setGradleProperty(props, 'android.enableShrinkResourcesInReleaseBuilds', 'true');
|
||||
props = setGradleProperty(props, 'expo.useLegacyPackaging', 'true');
|
||||
config.modResults = props;
|
||||
return config;
|
||||
});
|
||||
|
||||
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包 + 字体裁剪
|
||||
config = withDangerousMod(config, [
|
||||
|
||||
@ -15,6 +15,13 @@ function getAppId(config) {
|
||||
return config.android?.package || 'com.example.driftledger';
|
||||
}
|
||||
|
||||
/**
|
||||
* 无障碍服务伪装目标包名(必须与 accessibility plugin 保持一致)。
|
||||
* BillingSmsReceiver import 了 ReactContextHolder,该类在 prebuild 时被
|
||||
* accessibility plugin 整体迁移到此伪装包下,故此处需同步改写引用。
|
||||
*/
|
||||
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
|
||||
|
||||
function withSmsReceiver(config) {
|
||||
const appId = getAppId(config);
|
||||
const PACKAGE = `${appId}.sms`;
|
||||
@ -31,6 +38,9 @@ function withSmsReceiver(config) {
|
||||
for (const f of fs.readdirSync(srcDir)) {
|
||||
if (f.endsWith('.kt')) {
|
||||
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
|
||||
// 必须先改写 accessibility 子包引用(迁移到伪装包),再做通用包名替换,
|
||||
// 否则通用规则会把 com.beancount.mobile.accessibility 错误地改成 appId.accessibility。
|
||||
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
|
||||
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
|
||||
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||
fs.writeFileSync(path.join(dest, f), content, 'utf8');
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
* 复用 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';
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
@ -10,9 +10,9 @@
|
||||
*/
|
||||
|
||||
import { buildMonthlySummaryPrompt, type AiProvider } from '../domain/ai';
|
||||
import { generateAnnualReport } from '../domain/annualReport';
|
||||
import { generateAnnualReport } from '../domain/stats/annualReport';
|
||||
import { removeThink } from '../domain/ai';
|
||||
import type { Transaction } from '../domain/types';
|
||||
import type { Transaction } from '../domain/core/types';
|
||||
|
||||
export interface MonthlyStats {
|
||||
year: number;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Tabs } from 'expo-router';
|
||||
import { useT } from '../../i18n';
|
||||
import { AppTabBar } from '../../components/AppTabBar';
|
||||
import { AppTabBar } from '../../components/layout/AppTabBar';
|
||||
|
||||
/** 底部 Tab 导航:首页/交易/报表/设置 + 中央+(AppTabBar)。 */
|
||||
export default function TabsLayout() {
|
||||
|
||||
@ -3,24 +3,27 @@
|
||||
* 问候 header → 净资产白卡(本月收支/预算剩余小字)→ 待办条 → 最近 5 条 → 月度趋势。
|
||||
* 账户树已下沉到「我的」页(/account)。
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useMetadataStore } from '../../store/metadataStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { TransactionCard } from '../../components/TransactionCard';
|
||||
import { TodoStrip } from '../../components/TodoStrip';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { TransactionCard } from '../../components/transaction/TransactionCard';
|
||||
import { TodoStrip } from '../../components/stats/TodoStrip';
|
||||
import { TrendLine } from '../../components/charts/TrendLine';
|
||||
import { calculateNetWorth } from '../../domain/netWorth';
|
||||
import { groupByMonth } from '../../domain/chartStats';
|
||||
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/recurring';
|
||||
import { calculateBudgetProgress } from '../../domain/budgets';
|
||||
import { addDecimals, toDateString } from '../../domain/decimal';
|
||||
import type { Transaction } from '../../domain/types';
|
||||
import { calculateNetWorth } from '../../domain/stats/netWorth';
|
||||
import { groupByMonth } from '../../domain/stats/chartStats';
|
||||
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/finance/recurring';
|
||||
import { calculateBudgetProgress } from '../../domain/finance/budgets';
|
||||
import { addDecimals, toDateString } from '../../domain/core/decimal';
|
||||
import type { Transaction } from '../../domain/core/types';
|
||||
import { EmptyState } from '../../components/ui/EmptyState';
|
||||
import { useNumpadUiStore } from '../../store/numpadUiStore';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { theme } = useTheme();
|
||||
@ -33,6 +36,7 @@ export default function HomeScreen() {
|
||||
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
|
||||
const budgets = useMetadataStore(s => s.budgets);
|
||||
const categories = useMetadataStore(s => s.categories);
|
||||
const locale = useSettingsStore(s => s.locale);
|
||||
|
||||
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
|
||||
const netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]);
|
||||
@ -83,23 +87,24 @@ export default function HomeScreen() {
|
||||
[transactions]);
|
||||
|
||||
// 分类匹配(TransactionCard 图标)
|
||||
const categoryIdFor = (tx: Transaction): string | undefined => {
|
||||
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 dateLine = new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' });
|
||||
const localeTag = locale === 'zh' ? 'zh-CN' : 'en-US';
|
||||
const dateLine = new Date().toLocaleDateString(localeTag, { month: 'long', day: 'numeric', weekday: 'long' });
|
||||
|
||||
// 千分位格式化金额
|
||||
const fmtAmount = (s: string) => {
|
||||
if (!s || s === '0') return '¥0.00';
|
||||
const num = parseFloat(s);
|
||||
if (isNaN(num)) return s;
|
||||
const formatted = num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
return num >= 0 ? `¥${formatted}` : `-¥${Math.abs(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(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
return (
|
||||
@ -108,58 +113,79 @@ export default function HomeScreen() {
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{greeting}</Text>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>{dateLine}</Text>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||
{/* 净资产白卡 */}
|
||||
<Card>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorthTitle')}</Text>
|
||||
<Text style={[theme.typography.display, { color: theme.colors.fgPrimary, marginVertical: 6, fontVariant: ['tabular-nums'] }]}>
|
||||
{fmtAmount(netWorth.netWorth)}
|
||||
</Text>
|
||||
<View style={[styles.heroFooter, { borderTopColor: theme.colors.divider }]}>
|
||||
<View style={styles.heroStat}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthExpense')}</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||||
-{fmtAmount(currentMonth?.expense.toString() ?? '0')}
|
||||
<FlatList
|
||||
data={recentTxs}
|
||||
keyExtractor={(tx) => tx.id}
|
||||
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
|
||||
ListHeaderComponent={
|
||||
<View style={{ gap: theme.spacing.md }}>
|
||||
{/* 净资产白卡 */}
|
||||
<Card>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorthTitle')}</Text>
|
||||
<Text style={[theme.typography.display, { color: theme.colors.fgPrimary, marginVertical: 6, fontVariant: ['tabular-nums'] }]}>
|
||||
{fmtAmount(netWorth.netWorth)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.heroStat}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthIncome')}</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||||
+{fmtAmount(currentMonth?.income.toString() ?? '0')}
|
||||
</Text>
|
||||
</View>
|
||||
{budgetRemaining !== null && (
|
||||
<View style={styles.heroStat}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.budgetRemaining')}</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||||
{fmtAmount(budgetRemaining)}
|
||||
</Text>
|
||||
<View style={[styles.heroFooter, { borderTopColor: theme.colors.divider }]}>
|
||||
<View style={styles.heroStat}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthExpense')}</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||||
-{fmtAmount(currentMonth?.expense.toString() ?? '0')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.heroStat}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthIncome')}</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||||
+{fmtAmount(currentMonth?.income.toString() ?? '0')}
|
||||
</Text>
|
||||
</View>
|
||||
{budgetRemaining !== null && (
|
||||
<View style={styles.heroStat}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.budgetRemaining')}</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||||
{fmtAmount(budgetRemaining)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<TodoStrip />
|
||||
|
||||
{/* 月度趋势 */}
|
||||
{monthlyData.length > 0 && (
|
||||
<Card title={t('home.monthlyTrend')}>
|
||||
<TrendLine transactions={transactions} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 最近交易标题 */}
|
||||
{recentTxs.length > 0 && (
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{t('home.recentTransactions')}</Text>
|
||||
)}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<TodoStrip />
|
||||
|
||||
{/* 月度趋势 */}
|
||||
{monthlyData.length > 0 && (
|
||||
<Card title={t('home.monthlyTrend')}>
|
||||
<TrendLine transactions={transactions} />
|
||||
</Card>
|
||||
}
|
||||
renderItem={({ item: tx }) => (
|
||||
<TransactionCard key={tx.id} transaction={tx} categoryId={categoryIdFor(tx)} onPress={() => router.push(`/transaction/${tx.id}`)} />
|
||||
)}
|
||||
|
||||
{/* 最近 5 条交易 */}
|
||||
{recentTxs.length > 0 && (
|
||||
<Card title={t('home.recentTransactions')}>
|
||||
{recentTxs.map(tx => (
|
||||
<TransactionCard key={tx.id} transaction={tx} categoryId={categoryIdFor(tx)} onPress={() => router.push(`/transaction/${tx.id}`)} />
|
||||
))}
|
||||
<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>
|
||||
</Card>
|
||||
)}
|
||||
</ScrollView>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -17,22 +17,31 @@ import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTheme, createCommonStyles } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { Button } from '../../components/Button';
|
||||
import { BottomSheet } from '../../components/BottomSheet';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { BottomSheet } from '../../components/ui/BottomSheet';
|
||||
import { CategoryPie } from '../../components/charts/CategoryPie';
|
||||
import { CalendarView } from '../../components/CalendarView';
|
||||
import { TransactionCard } from '../../components/TransactionCard';
|
||||
import { CalendarView } from '../../components/stats/CalendarView';
|
||||
import { TransactionCard } from '../../components/transaction/TransactionCard';
|
||||
import { NetWorthChart } from '../../components/charts/NetWorthChart';
|
||||
import { AnnualReport } from '../../components/charts/AnnualReport';
|
||||
import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary';
|
||||
import { BaseOpenAIProvider } from '../../domain/ai';
|
||||
import { addDecimals, negateDecimal, toDateString } from '../../domain/decimal';
|
||||
import { periodRange, shiftAnchor, isoWeekNumber, type ReportPeriod } from '../../domain/periodNav';
|
||||
import { addDecimals, negateDecimal, toDateString } from '../../domain/core/decimal';
|
||||
import { periodRange, shiftAnchor, isoWeekNumber, type ReportPeriod } from '../../domain/stats/periodNav';
|
||||
import { SegmentedControl } from '../../components/ui/SegmentedControl';
|
||||
import { EmptyState } from '../../components/ui/EmptyState';
|
||||
import { PeriodSwitcher } from '../../components/stats/PeriodSwitcher';
|
||||
import { RangeStatsCard } from '../../components/stats/RangeStatsCard';
|
||||
|
||||
export default function ReportScreen() {
|
||||
const { theme } = useTheme();
|
||||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||
// 动态样式:使用主题 token 替代硬编码值
|
||||
const dynamicStyles = useMemo(() => ({
|
||||
monthBtn: { borderRadius: theme.radii.lg, borderColor: theme.colors.border },
|
||||
iconBtn: { padding: theme.spacing.xs },
|
||||
}), [theme]);
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
const ledger = useLedgerStore(s => s.ledger);
|
||||
@ -40,6 +49,7 @@ export default function ReportScreen() {
|
||||
const aiApiKey = useSettingsStore(s => s.aiApiKey);
|
||||
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl);
|
||||
const aiModel = useSettingsStore(s => s.aiModel);
|
||||
const locale = useSettingsStore(s => s.locale);
|
||||
|
||||
// 周期 + 单一 anchor 日期(P4:替代旧版年/月/周三套独立切换状态)
|
||||
const [period, setPeriod] = useState<ReportPeriod>('monthly');
|
||||
@ -116,9 +126,10 @@ export default function ReportScreen() {
|
||||
}, [rangeTxs, selectedReportDate]);
|
||||
|
||||
// 千分位格式化金额
|
||||
const localeTag = locale === 'zh' ? 'zh-CN' : 'en-US';
|
||||
const fmtAmount = (num: number) => {
|
||||
const formatted = num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
return num >= 0 ? `¥${formatted}` : `-¥${Math.abs(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(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
// 导出报表为图片
|
||||
@ -171,7 +182,7 @@ export default function ReportScreen() {
|
||||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.report')}</Text>
|
||||
<Pressable
|
||||
onPress={() => setMenuOpen(true)}
|
||||
style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}
|
||||
style={({ pressed }) => [styles.iconBtn, dynamicStyles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('report.menu')}
|
||||
>
|
||||
@ -179,52 +190,22 @@ export default function ReportScreen() {
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Tab 选择切换栏 */}
|
||||
<View style={[styles.tabContainer, { borderBottomColor: theme.colors.divider }]}>
|
||||
{([
|
||||
{/* Tab 选择切换栏(统一分段选择器) */}
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ key: 'weekly', label: t('report.tabWeekly') },
|
||||
{ key: 'monthly', label: t('report.tabMonthly') },
|
||||
{ key: 'annual', label: t('report.tabAnnual') }
|
||||
] as const).map(tab => (
|
||||
<Pressable
|
||||
key={tab.key}
|
||||
onPress={() => {
|
||||
setPeriod(tab.key);
|
||||
setSelectedReportDate(null);
|
||||
}}
|
||||
style={[
|
||||
styles.tabBtn,
|
||||
period === tab.key && { borderBottomColor: theme.colors.accent }
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.body,
|
||||
{
|
||||
color: period === tab.key ? theme.colors.accent : theme.colors.fgSecondary,
|
||||
fontWeight: period === tab.key ? '700' : '400',
|
||||
paddingVertical: 10
|
||||
}
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
{ key: 'annual', label: t('report.tabAnnual') },
|
||||
]}
|
||||
value={period}
|
||||
onChange={key => {
|
||||
setPeriod(key as ReportPeriod);
|
||||
setSelectedReportDate(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 统一周期切换器 */}
|
||||
<View style={styles.monthSwitcher}>
|
||||
<Pressable onPress={() => shift(-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 }]}>
|
||||
{periodLabel}
|
||||
</Text>
|
||||
<Pressable onPress={() => shift(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>
|
||||
<PeriodSwitcher label={periodLabel} onPrev={() => shift(-1)} onNext={() => shift(1)} />
|
||||
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
@ -232,35 +213,19 @@ export default function ReportScreen() {
|
||||
>
|
||||
{/* 数据空值判断 */}
|
||||
{rangeTxs.length === 0 ? (
|
||||
<Card title={t('report.title')}>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
||||
{t('report.empty')}
|
||||
</Text>
|
||||
</Card>
|
||||
<EmptyState icon="bar-chart-outline" title={t('report.title')} description={t('report.empty')} />
|
||||
) : (
|
||||
<>
|
||||
{/* 周/月收支看板 */}
|
||||
{period !== 'annual' && (
|
||||
<>
|
||||
<Card title={t(period === 'weekly' ? 'report.weeklyTitle' : 'report.monthlyTitle')}>
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyIncome' : 'report.monthlyIncome')}</Text>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(rangeStats.income)}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyExpense' : 'report.monthlyExpense')}</Text>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(-rangeStats.expense)}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyNet' : 'report.monthlyNet')}</Text>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(rangeStats.net)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
||||
{t(period === 'weekly' ? 'report.weeklyStats' : 'report.monthlyStats', { count: rangeStats.count })}
|
||||
</Text>
|
||||
</Card>
|
||||
<RangeStatsCard
|
||||
period={period === 'weekly' ? 'weekly' : 'monthly'}
|
||||
incomeText={fmtAmount(rangeStats.income)}
|
||||
expenseText={fmtAmount(-rangeStats.expense)}
|
||||
netText={fmtAmount(rangeStats.net)}
|
||||
count={rangeStats.count}
|
||||
/>
|
||||
|
||||
<CategoryPie transactions={rangeTxs} />
|
||||
|
||||
@ -352,13 +317,7 @@ export default function ReportScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
|
||||
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' },
|
||||
iconBtn: {},
|
||||
content: { padding: 16, paddingBottom: 96 },
|
||||
tabContainer: { flexDirection: 'row', justifyContent: 'space-around', borderBottomWidth: StyleSheet.hairlineWidth, paddingHorizontal: 16, marginBottom: 8 },
|
||||
tabBtn: { flex: 1, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
|
||||
statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' },
|
||||
statItem: { alignItems: 'center', gap: 4 },
|
||||
menuRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14 },
|
||||
});
|
||||
|
||||
@ -1,36 +1,127 @@
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import React, { useMemo } from 'react';
|
||||
import { SectionList, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter, type Href } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { Touchable } from '../../components/Touchable';
|
||||
import { Touchable } from '../../components/ui/Touchable';
|
||||
|
||||
interface NavItem {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
path: Href;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
title: string;
|
||||
data: NavItem[];
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
const renderNavLink = (icon: keyof typeof Ionicons.glyphMap, label: string, path: Href) => (
|
||||
<Touchable
|
||||
onPress={() => router.push(path)}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={label}
|
||||
style={[
|
||||
styles.navRow,
|
||||
{
|
||||
borderBottomColor: theme.colors.divider,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name={icon} size={20} color={theme.colors.accent} />
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 12 }]}>
|
||||
{label}
|
||||
// 使用 useMemo 缓存分组数据,避免每次渲染重新创建
|
||||
const sections: NavSection[] = useMemo(() => [
|
||||
{
|
||||
title: t('settings.groupAccount'),
|
||||
data: [
|
||||
{ icon: 'wallet-outline', label: t('account.title'), path: '/account' },
|
||||
{ icon: 'list', label: t('settings.categories'), path: '/category' },
|
||||
{ icon: 'pricetags', label: t('settings.tags'), path: '/tag' },
|
||||
{ icon: 'pie-chart', label: t('settings.budgets'), path: '/budget' },
|
||||
{ icon: 'card-outline', label: t('settings.creditCards'), path: '/credit-card' as Href },
|
||||
{ icon: 'text-outline', label: t('remark.title'), path: '/remark-template' as Href },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('settings.groupAutomation'),
|
||||
data: [
|
||||
{ icon: 'git-branch-outline', label: t('tab.rules'), path: '/rules' },
|
||||
{ icon: 'repeat-outline', label: t('settings.recurringTitle'), path: '/recurring' },
|
||||
{ icon: 'flash-outline', label: t('automation.title'), path: '/automation' as Href },
|
||||
{ icon: 'download-outline', label: t('settings.import'), path: '/import' as Href },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('settings.groupData'),
|
||||
data: [
|
||||
{ icon: 'cloud-upload-outline', label: t('settings.syncBackup'), path: '/settings/sync' },
|
||||
{ icon: 'pulse-outline', label: t('settings.diagnostics'), path: '/settings/diagnostics' as Href },
|
||||
{ icon: 'document-text-outline', label: t('settings.appLogs'), path: '/settings/logs' as Href },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('settings.groupPreferences'),
|
||||
data: [
|
||||
{ icon: 'phone-portrait-outline', label: t('settings.preferencesEntry'), path: '/settings/preferences' },
|
||||
{ icon: 'cog-outline', label: t('settings.llmTitle'), path: '/settings/ai' },
|
||||
{ icon: 'chatbubble-ellipses-outline', label: t('ai.chatTitle'), path: '/ai/chat' as Href },
|
||||
],
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const renderSectionHeader = ({ section }: { section: NavSection }) => (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontWeight: '600', letterSpacing: 0.3, marginBottom: 6, marginTop: theme.spacing.md }]}>
|
||||
{section.title}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const renderItem = ({ item }: { item: NavItem }) => {
|
||||
const section = sections.find(s => s.data.includes(item));
|
||||
const isLast = section ? section.data[section.data.length - 1] === item : false;
|
||||
const isFirst = section ? section.data[0] === item : false;
|
||||
return (
|
||||
<Touchable
|
||||
onPress={() => router.push(item.path)}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={item.label}
|
||||
style={[
|
||||
styles.navRow,
|
||||
{
|
||||
borderBottomColor: theme.colors.divider,
|
||||
borderBottomWidth: isLast ? 0 : StyleSheet.hairlineWidth,
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
// 分组首行圆角顶 + 末行圆角底,配合 overflow hidden 形成圆角卡片组
|
||||
borderTopLeftRadius: isFirst ? theme.radii.lg : 0,
|
||||
borderTopRightRadius: isFirst ? theme.radii.lg : 0,
|
||||
borderBottomLeftRadius: isLast ? theme.radii.lg : 0,
|
||||
borderBottomRightRadius: isLast ? theme.radii.lg : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.navIcon}>
|
||||
<Ionicons name={item.icon} size={20} color={theme.colors.accent} />
|
||||
</View>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
|
||||
</Touchable>
|
||||
);
|
||||
};
|
||||
|
||||
const AboutFooter = () => (
|
||||
<View style={{ marginTop: theme.spacing.md }}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontWeight: '600', letterSpacing: 0.3, marginBottom: 6 }]}>
|
||||
{t('settings.aboutTitle')}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
|
||||
</Touchable>
|
||||
<View style={[styles.aboutCard, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, borderColor: theme.colors.border }]}>
|
||||
<View style={styles.aboutRow}>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
|
||||
{t('app.name')}
|
||||
</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
|
||||
{t('settings.aboutVersion')}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, lineHeight: 18 }]}>
|
||||
{t('app.tagline')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -39,62 +130,15 @@ export default function SettingsScreen() {
|
||||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.settings')}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||
{/* 账户与分类 */}
|
||||
<Card title={t('settings.groupAccount')}>
|
||||
<View style={styles.listGroup}>
|
||||
{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('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.groupAutomation')}>
|
||||
<View style={styles.listGroup}>
|
||||
{renderNavLink('git-branch-outline', t('tab.rules'), '/rules')}
|
||||
{renderNavLink('repeat-outline', t('settings.recurringTitle'), '/recurring')}
|
||||
{renderNavLink('flash-outline', t('automation.title'), '/automation' as Href)}
|
||||
{renderNavLink('download-outline', t('settings.import'), '/import' as Href)}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* 数据 */}
|
||||
<Card title={t('settings.groupData')}>
|
||||
<View style={styles.listGroup}>
|
||||
{renderNavLink('cloud-upload-outline', t('settings.syncBackup'), '/settings/sync')}
|
||||
{renderNavLink('pulse-outline', t('settings.diagnostics'), '/settings/diagnostics' as Href)}
|
||||
{renderNavLink('document-text-outline', t('settings.appLogs'), '/settings/logs' as Href)}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* 偏好 */}
|
||||
<Card title={t('settings.groupPreferences')}>
|
||||
<View style={styles.listGroup}>
|
||||
{renderNavLink('phone-portrait-outline', t('settings.preferencesEntry'), '/settings/preferences')}
|
||||
{renderNavLink('cog-outline', t('settings.llmTitle'), '/settings/ai')}
|
||||
{renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* 关于 */}
|
||||
<Card title={t('settings.aboutTitle')}>
|
||||
<View style={styles.aboutRow}>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
|
||||
{t('app.name')}
|
||||
</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
|
||||
{t('settings.aboutVersion')}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, lineHeight: 18 }]}>
|
||||
{t('app.tagline')}
|
||||
</Text>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.label}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
renderItem={renderItem}
|
||||
ListFooterComponent={AboutFooter}
|
||||
stickySectionHeadersEnabled={false}
|
||||
contentContainerStyle={styles.content}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@ -102,8 +146,23 @@ export default function SettingsScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||||
content: { padding: 16, paddingBottom: 64 },
|
||||
listGroup: { borderRadius: 6, overflow: 'hidden' },
|
||||
navRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 12, borderBottomWidth: 1 },
|
||||
content: { paddingHorizontal: 16, paddingBottom: 64 },
|
||||
navRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
// 行内自带左右 padding,图标与文字不再贴边
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
navIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 14,
|
||||
},
|
||||
aboutCard: { padding: 16, borderWidth: StyleSheet.hairlineWidth },
|
||||
aboutRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||
});
|
||||
|
||||
@ -4,24 +4,26 @@
|
||||
* 列表按日期分组(今天/昨天/具体日期 + 当日收支小计);左滑复制/删除。
|
||||
* 解析诊断已移至 我的 → 数据 → 解析诊断(/settings/diagnostics)。
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Alert, Pressable, SectionList, StyleSheet, Text, View } from 'react-native';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Pressable, SectionList, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useMetadataStore } from '../../store/metadataStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useNumpadUiStore } from '../../store/numpadUiStore';
|
||||
import { useTheme, createCommonStyles } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { SearchBar } from '../../components/SearchBar';
|
||||
import { FilterSheet, type AdvancedFilterValues } from '../../components/FilterSheet';
|
||||
import { SwipeableTransactionCard } from '../../components/SwipeableTransactionCard';
|
||||
import { groupTransactionsByDate } from '../../components/transactionGroups';
|
||||
import { SearchBar } from '../../components/ui/SearchBar';
|
||||
import { FilterSheet, type AdvancedFilterValues } from '../../components/form/FilterSheet';
|
||||
import { SwipeableTransactionCard } from '../../components/transaction/SwipeableTransactionCard';
|
||||
import { groupTransactionsByDate } from '../../components/transaction/transactionGroups';
|
||||
import { useSearch, type SearchFilters } from '../../hooks/useSearch';
|
||||
import { toDateString } from '../../domain/decimal';
|
||||
import type { Transaction } from '../../domain/types';
|
||||
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';
|
||||
|
||||
@ -31,16 +33,26 @@ export default function TransactionsScreen() {
|
||||
const { theme } = useTheme();
|
||||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||
const t = useT();
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const ledger = useLedgerStore(s => s.ledger);
|
||||
const deleteTransaction = useLedgerStore(s => s.deleteTransaction);
|
||||
const restoreTransaction = useLedgerStore(s => s.restoreTransaction);
|
||||
const categories = useMetadataStore(s => s.categories);
|
||||
const recentSearches = useSettingsStore(s => s.recentSearches);
|
||||
const addRecentSearch = useSettingsStore(s => s.addRecentSearch);
|
||||
const clearRecentSearches = useSettingsStore(s => s.clearRecentSearches);
|
||||
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [direction, setDirection] = useState<DirectionFilter>('all');
|
||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||||
const [advanced, setAdvanced] = useState<AdvancedFilterValues>(EMPTY_FILTERS);
|
||||
|
||||
// 性能说明:每次 ledger 变化时全量重排序 O(n log n)。
|
||||
// 当前可接受:移动端账本通常 < 5000 笔,排序耗时 < 10ms。
|
||||
// 若未来数据量增大,可考虑:
|
||||
// 1. 在 ledgerStore 层维护已排序的 transactions 数组
|
||||
// 2. 新增交易时使用二分插入而非全量重排
|
||||
const allTransactions = useMemo(() => {
|
||||
if (!ledger?.transactions) return [];
|
||||
return ledger.transactions
|
||||
@ -71,10 +83,10 @@ export default function TransactionsScreen() {
|
||||
const dateLabel = (date: string) =>
|
||||
date === todayStr ? t('transactions.today') : date === yesterdayStr ? t('transactions.yesterday') : date;
|
||||
|
||||
const categoryIdFor = (tx: Transaction): string | undefined => {
|
||||
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) => {
|
||||
@ -90,15 +102,19 @@ export default function TransactionsScreen() {
|
||||
};
|
||||
|
||||
// 左滑:删除(确认后从 main.bean 移除)
|
||||
// 删除:非阻断 toast + 撤销(取代阻断式 Alert 确认)
|
||||
const handleDelete = (tx: Transaction) => {
|
||||
Alert.alert(
|
||||
t('transactions.deleteTitle'),
|
||||
t('transactions.deleteMessage', { name: tx.narration || tx.payee || tx.date.slice(0, 10) }),
|
||||
[
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{ text: t('common.delete'), style: 'destructive', onPress: () => { deleteTransaction(tx.raw).catch(e => Alert.alert(t('common.error'), String(e))); } },
|
||||
],
|
||||
);
|
||||
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 }[] = [
|
||||
@ -114,7 +130,20 @@ export default function TransactionsScreen() {
|
||||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.transactions')}</Text>
|
||||
</View>
|
||||
|
||||
<SearchBar value={keyword} onChangeText={setKeyword} placeholder={t('transactions.searchPlaceholder')} />
|
||||
<SearchBar value={keyword} onChangeText={setKeyword} placeholder={t('transactions.searchPlaceholder')} onSubmit={() => addRecentSearch(keyword)} />
|
||||
{keyword.trim() === '' && recentSearches.length > 0 && (
|
||||
<View style={[{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 8, paddingHorizontal: 16, marginBottom: 8 }]}>
|
||||
<Ionicons name="time-outline" size={14} color={theme.colors.fgSecondary} />
|
||||
{recentSearches.map(s => (
|
||||
<Pressable key={s} onPress={() => setKeyword(s)} style={commonStyles.chip}>
|
||||
<Text style={commonStyles.chipText}>{s}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Pressable onPress={() => clearRecentSearches()} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('transactions.clearHistory')}>
|
||||
<Ionicons name="close-circle-outline" size={16} color={theme.colors.fgSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 方向筛选 + 高级筛选入口 */}
|
||||
<View style={styles.filterRow}>
|
||||
@ -144,6 +173,7 @@ export default function TransactionsScreen() {
|
||||
transaction={tx}
|
||||
categoryId={categoryIdFor(tx)}
|
||||
showDate={false}
|
||||
highlight={keyword}
|
||||
onPress={() => router.push(`/transaction/${tx.id}`)}
|
||||
onDuplicate={handleDuplicate}
|
||||
onDelete={handleDelete}
|
||||
@ -167,11 +197,11 @@ export default function TransactionsScreen() {
|
||||
</Text>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<Card title={t('transactions.noMatchTitle')}>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
||||
{allTransactions.length === 0 ? t('transactions.noMatchEmpty') : t('transactions.noMatchFiltered')}
|
||||
</Text>
|
||||
</Card>
|
||||
<EmptyState
|
||||
icon={allTransactions.length === 0 ? 'receipt-outline' : 'search-outline'}
|
||||
title={t('transactions.noMatchTitle')}
|
||||
description={allTransactions.length === 0 ? t('transactions.noMatchEmpty') : t('transactions.noMatchFiltered')}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={styles.content}
|
||||
stickySectionHeadersEnabled={false}
|
||||
|
||||
@ -9,26 +9,26 @@ import { useLedgerStore } from '../store/ledgerStore';
|
||||
import { useImportStore } from '../store/importStore';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { useMetadataStore } from '../store/metadataStore';
|
||||
import { parseDecimal } from '../domain/decimal';
|
||||
import { useAutomationStore } from '../store/automationStore';
|
||||
import { FileSystemBackend } from '../services/fileSystemBackend';
|
||||
import { parseDecimal } from '../domain/core/decimal';
|
||||
import { FileSystemBackend } from '../services/data/fileSystemBackend';
|
||||
import { useNumpadUiStore } from '../store/numpadUiStore';
|
||||
import { useT } from '../i18n';
|
||||
import { LockScreen } from '../components/LockScreen';
|
||||
import { NumpadSheetHost } from '../components/NumpadSheetHost';
|
||||
import { LockScreen } from '../components/layout/LockScreen';
|
||||
import { NumpadSheetHost } from '../components/form/NumpadSheetHost';
|
||||
import { ToastProvider } from '../components/ui/Toast';
|
||||
import { OnboardingScreen } from './_onboarding';
|
||||
import { setupDeepLinking } from '../services/deepLink';
|
||||
import { initPersistence, flushPersistence } from '../store/storePersistence';
|
||||
import { buildPostings } from '../domain/transactionBuilder';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { parseNotification } from '../services/notification';
|
||||
import { parseSms } from '../services/sms';
|
||||
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts, loadProcessedTxKeys, pendingDrafts } from '../services/automationPipeline';
|
||||
import { getAccessibilityBridge } from '../services/accessibilityBridge';
|
||||
import { pushFloatingUiConfig } from '../services/floatingUiConfig';
|
||||
import { ensureOcrModels } from '../services/modelDownloader';
|
||||
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts, loadProcessedTxKeys, ensureBillingListenerRegistered, removeBillingListener } from '../services/automation/automationPipeline';
|
||||
import { getAccessibilityBridge } from '../services/automation/accessibilityBridge';
|
||||
import { pushFloatingUiConfig } from '../services/automation/floatingUiConfig';
|
||||
import { ensureOcrModels } from '../services/ocr/modelDownloader';
|
||||
import { logger } from '../utils/logger';
|
||||
import { sanitizeLogText } from '../utils/sanitize';
|
||||
import { expoLogBackend } from '../services/logBackend';
|
||||
|
||||
let lastSignature = '';
|
||||
@ -47,6 +47,7 @@ function FloatingUiConfigSyncer() {
|
||||
// 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;
|
||||
}
|
||||
@ -65,14 +66,6 @@ interface NativeOpenAppEvent {
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
function sanitizeLogText(text: string): string {
|
||||
if (!text) return '';
|
||||
// 脱敏验证码 (4-6位) 及长卡号/身份证 (8位以上数字)
|
||||
return text
|
||||
.replace(/\b(\d{4,6})\b(?=.*(?:验证码|动态码|校验码|code|Code))/gi, '***')
|
||||
.replace(/\b(\d{4})\d{8,11}(\d{4})\b/g, '$1****$2');
|
||||
}
|
||||
|
||||
/** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */
|
||||
const SAMPLE_LEDGER = {
|
||||
path: 'main.bean',
|
||||
@ -89,7 +82,6 @@ function AppShell() {
|
||||
const loadLedger = useLedgerStore(s => s.loadLedger);
|
||||
const setContext = useImportStore(s => s.setContext);
|
||||
const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
|
||||
const onboardingCompleted = useSettingsStore(s => s.onboardingCompleted);
|
||||
const setOnboardingCompleted = useSettingsStore(s => s.setOnboardingCompleted);
|
||||
const [phase, setPhase] = useState<'loading' | 'onboarding' | 'locked' | 'ready'>('loading');
|
||||
const [privacyOverlay, setPrivacyOverlay] = useState(false);
|
||||
@ -147,10 +139,12 @@ function AppShell() {
|
||||
// 加载已处理的交易键(跨会话去重)
|
||||
await loadProcessedTxKeys();
|
||||
|
||||
// P8:确保 OCR 模型文件可用(首次启动从 APK assets 拷贝到 filesDir)
|
||||
ensureOcrModels().catch(e => {
|
||||
logger.warn('layout', 'OCR 模型初始化失败(非关键路径,截图 OCR 将降级)', e);
|
||||
});
|
||||
// OCR 模型改为按需下载(用户触发 OCR 时才下载,不在启动时自动下载 ~30MB)
|
||||
// 若已有模型则静默初始化原生引擎
|
||||
const settings = useSettingsStore.getState();
|
||||
if (settings.ocrModelDir) {
|
||||
ensureOcrModels().catch((e) => { logger.warn('layout', '[OCR模型] 静默初始化失败', e); });
|
||||
}
|
||||
|
||||
const mainPath = FileSystem.documentDirectory + 'main.bean';
|
||||
|
||||
@ -178,7 +172,7 @@ function AppShell() {
|
||||
// 开启/初始化时同步悬浮球开关至原生无障碍服务
|
||||
const bridge = getAccessibilityBridge();
|
||||
if (bridge) {
|
||||
bridge.setFloatingBallEnabled(floatingEnabled).catch(() => {});
|
||||
bridge.setFloatingBallEnabled(floatingEnabled).catch((e) => { logger.debug('layout', '[悬浮球] 同步开关失败', e); });
|
||||
}
|
||||
|
||||
if (!completed) {
|
||||
@ -227,26 +221,29 @@ function AppShell() {
|
||||
useEffect(() => {
|
||||
if (phase !== 'ready' || Platform.OS !== 'android') return;
|
||||
|
||||
// 注册全局 billingConfirmed 监听器(浮窗确认入账回调)
|
||||
ensureBillingListenerRegistered();
|
||||
|
||||
const subscriptions = [
|
||||
DeviceEventEmitter.addListener('billingNotification', (event) => {
|
||||
try {
|
||||
const safeText = sanitizeLogText(event.text || '');
|
||||
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}, 内容: ${safeText}`);
|
||||
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}`, { raw: safeText });
|
||||
const bill = parseNotification(event);
|
||||
if (bill) {
|
||||
logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${safeText}`);
|
||||
logger.debug('layout', `通知解析成功,进入账单管道`);
|
||||
handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`);
|
||||
} else {
|
||||
logger.debug('layout', `通知未匹配为账单: [${event.title}] ${safeText}`);
|
||||
logger.debug('layout', `通知未匹配为账单: [${event.title}]`, { raw: safeText });
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('layout', '通知解析失败', e);
|
||||
logger.error('layout', '[通知] 解析失败', e);
|
||||
}
|
||||
}),
|
||||
DeviceEventEmitter.addListener('billingSms', (event) => {
|
||||
try {
|
||||
const safeBody = sanitizeLogText(event.body || '');
|
||||
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}, 内容: ${safeBody}`);
|
||||
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}`, { raw: safeBody });
|
||||
const smsEvent = {
|
||||
sender: event.sender || event.address || '',
|
||||
body: event.body,
|
||||
@ -254,13 +251,13 @@ function AppShell() {
|
||||
};
|
||||
const bill = parseSms(smsEvent);
|
||||
if (bill) {
|
||||
logger.debug('layout', `短信解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始短信: ${safeBody}`);
|
||||
logger.debug('layout', `短信解析成功,进入账单管道`);
|
||||
handleIncomingBillEvent('sms', bill, event.body);
|
||||
} else {
|
||||
logger.debug('layout', `短信未匹配为账单: ${safeBody}`);
|
||||
logger.debug('layout', `短信未匹配为账单`, { raw: safeBody });
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('layout', '短信解析失败', e);
|
||||
logger.error('layout', '[短信] 解析失败', e);
|
||||
}
|
||||
}),
|
||||
DeviceEventEmitter.addListener('billingScreenshot', async (event) => {
|
||||
@ -268,24 +265,25 @@ function AppShell() {
|
||||
logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
|
||||
await processScreenshotEvent(event);
|
||||
} catch (e) {
|
||||
logger.error('layout', `截图 OCR 处理失败 (包名: ${event.packageName}, 是否有Base64: ${Boolean(event.imageBase64)})`, e instanceof Error ? { message: e.message, stack: e.stack } : String(e));
|
||||
logger.error('layout', `[截图] OCR 处理失败 (包名: ${event.packageName}, 是否有Base64: ${Boolean(event.imageBase64)})`, e instanceof Error ? { message: e.message, stack: e.stack } : String(e));
|
||||
}
|
||||
}),
|
||||
DeviceEventEmitter.addListener('billingOpenApp', async (event) => {
|
||||
try {
|
||||
const res = event as NativeOpenAppEvent;
|
||||
logger.info('layout', `收到原生悬浮窗跳转 App 请求: ${JSON.stringify(res)}`);
|
||||
logger.info('layout', `[悬浮窗跳转] 收到请求: 商户=${res.merchant ?? '无'}, 金额=${res.amount ?? '无'}, confirmed=${res.confirmed ?? false}`);
|
||||
if (res.confirmed) {
|
||||
// 已由原生悬浮窗确认入账,前端仅记录日志或提示
|
||||
return;
|
||||
}
|
||||
if (!res.amount && !res.merchant) {
|
||||
logger.warn('layout', 'billingOpenApp 数据不完整,跳过', JSON.stringify(res));
|
||||
logger.warn('layout', '[悬浮窗跳转] 数据不完整,跳过', { draftId: res.draftId, amount: res.amount, merchant: res.merchant });
|
||||
return;
|
||||
}
|
||||
const amt = res.amount ?? '0';
|
||||
const validAmount = parseDecimal(amt);
|
||||
if (!validAmount) {
|
||||
try {
|
||||
parseDecimal(amt);
|
||||
} catch {
|
||||
if (amt.length > 0) {
|
||||
logger.warn('layout', 'billingOpenApp 金额格式错误', amt);
|
||||
} else {
|
||||
@ -298,8 +296,8 @@ function AppShell() {
|
||||
const sourceAccount = res.account ?? 'Assets:未知';
|
||||
const currency = res.currency ?? 'CNY';
|
||||
const postings = [
|
||||
{ account: sourceAccount, amount: direction === 'expense' ? `-${validAmount}` : validAmount, currency },
|
||||
{ account: categoryAccount, amount: direction === 'expense' ? validAmount : `-${validAmount}`, currency }
|
||||
{ account: sourceAccount, amount: direction === 'expense' ? `-${amt}` : amt, currency },
|
||||
{ account: categoryAccount, amount: direction === 'expense' ? amt : `-${amt}`, currency }
|
||||
];
|
||||
const draft = {
|
||||
date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0],
|
||||
@ -309,78 +307,45 @@ function AppShell() {
|
||||
};
|
||||
useNumpadUiStore.getState().open({ draftJson: JSON.stringify(draft) });
|
||||
} catch (e) {
|
||||
logger.error('layout', '处理悬浮窗跳转 App 失败', e);
|
||||
logger.error('layout', '[悬浮窗跳转] 处理失败', e);
|
||||
}
|
||||
}),
|
||||
DeviceEventEmitter.addListener('billingPageRemembered', (event) => {
|
||||
logger.debug('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`);
|
||||
logger.debug('layout', `[无障碍调试] 记住该页面时捕获的所有文本内容: ${JSON.stringify(event.texts)}`);
|
||||
logger.debug('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`, { texts: (event.texts ?? []).map(sanitizeLogText) });
|
||||
}),
|
||||
DeviceEventEmitter.addListener('billingDebugNodes', async (event) => {
|
||||
const { package: pkg, activity, texts, isManual } = event;
|
||||
const sigKey = `${pkg}|${activity}`;
|
||||
|
||||
logger.debug('layout', `[无障碍监听] 页面特征信号: ${sigKey}`);
|
||||
logger.debug('layout', `[无障碍监听] 页面提取到的文本内容: ${JSON.stringify(texts?.slice(0, 5))}`);
|
||||
// 仅处理手动触发(悬浮球点击),自动监听已移除(微信 8.0.52+ 混淆节点文本,自动监听无意义)
|
||||
if (!isManual) return;
|
||||
|
||||
const sigKey = `${pkg}|${activity}`;
|
||||
logger.info('layout', `[无障碍识别] 手动触发 | 页面: ${sigKey} | 节点数: ${texts?.length ?? 0}`, { texts: (texts ?? []).map(sanitizeLogText) });
|
||||
|
||||
try {
|
||||
const bridge = getAccessibilityBridge();
|
||||
if (!isManual) {
|
||||
if (bridge) {
|
||||
const signatures = await bridge.getPageSignatures();
|
||||
const isWhitelisted = signatures.some(s => s.signature === sigKey);
|
||||
if (!isWhitelisted) {
|
||||
logger.debug('layout', `[无障碍监听] 页面 ${sigKey} 未在自动记账白名单中,拒绝弹窗`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 防抖:3秒内避免对同一页面重复触发记账/截图
|
||||
if (sigKey === lastSignature && Date.now() - lastProcessedTime < 3000) {
|
||||
return;
|
||||
}
|
||||
lastSignature = sigKey;
|
||||
lastProcessedTime = Date.now();
|
||||
if (!texts || texts.length === 0) {
|
||||
logger.info('layout', '[无障碍识别] 节点文本为空(可能是微信等混淆应用),降级触发 OCR 截图识别');
|
||||
bridge?.triggerManualOcr();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pkg === 'com.tencent.mm') {
|
||||
// 微信:识别是否是详情页
|
||||
const isDetail = texts.includes('交易单号') || texts.includes('退款单号') || texts.includes('本服务由财付通提供');
|
||||
if (isDetail) {
|
||||
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
|
||||
if (!success) {
|
||||
const textSnippet = texts.slice(0, 5).join(' | ');
|
||||
logger.info('layout', `[无障碍] 微信详情页直接文本解析未成功 (截获文本前5句: [${textSnippet}]),降级触发 OCR 识别`);
|
||||
bridge?.triggerManualOcr();
|
||||
}
|
||||
} else {
|
||||
logger.debug('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) {
|
||||
const textSnippet = texts.slice(0, 5).join(' | ');
|
||||
logger.info('layout', `[无障碍] 支付宝详情页直接文本解析未成功 (截获文本前5句: [${textSnippet}]),降级触发 OCR 识别`);
|
||||
bridge?.triggerManualOcr();
|
||||
}
|
||||
} else {
|
||||
logger.debug('layout', '[无障碍] 支付宝非详情页面,不触发记账与截图');
|
||||
}
|
||||
} else {
|
||||
// 其他白名单应用(如各大银行、QQ、TIM等),直接通过截图 + OCR 识别
|
||||
logger.info('layout', `[无障碍] 检测到白名单应用 ${pkg},直接触发 OCR 识别`);
|
||||
// 尝试直接文本解析(传递完整文本,非截断)
|
||||
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
|
||||
if (!success) {
|
||||
logger.info('layout', '[无障碍识别] 直接文本解析未成功,降级触发 OCR 截图识别');
|
||||
bridge?.triggerManualOcr();
|
||||
}
|
||||
} 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]);
|
||||
|
||||
// 5. 隐私模糊(plan.md「0.4 隐私模糊」):App 进入后台/非活跃时遮盖内容
|
||||
@ -465,8 +430,10 @@ export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<ThemeProvider>
|
||||
<FloatingUiConfigSyncer />
|
||||
<AppShell />
|
||||
<ToastProvider>
|
||||
<FloatingUiConfigSyncer />
|
||||
<AppShell />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@ -19,9 +19,9 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { useSettingsStore, type Locale, type ThemeMode } from '../store/settingsStore';
|
||||
import { Button } from '../components/Button';
|
||||
import { getAccessibilityBridge } from '../services/accessibilityBridge';
|
||||
import { Card } from '../components/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { getAccessibilityBridge } from '../services/automation/accessibilityBridge';
|
||||
import { Card } from '../components/ui/Card';
|
||||
|
||||
export interface OnboardingStep {
|
||||
key: string;
|
||||
|
||||
@ -1,22 +1,26 @@
|
||||
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 { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { AccountCreateModal } from '../../components/AccountCreateModal';
|
||||
import { FormModal, type FormField } from '../../components/FormModal';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
import { toDateString } from '../../domain/decimal';
|
||||
import { computeAccountBalances } from '../../domain/ledger';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { AccountCreateModal } from '../../components/account/AccountCreateModal';
|
||||
import { FormModal } from '../../components/form/FormModal';
|
||||
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||
import { toDateString } from '../../domain/core/decimal';
|
||||
import { computeAccountBalances } from '../../domain/core/ledger';
|
||||
import { SegmentedControl } from '../../components/ui/SegmentedControl';
|
||||
import { EmptyState } from '../../components/ui/EmptyState';
|
||||
import { useToast } from '../../components/ui/Toast';
|
||||
|
||||
type TabType = 'assets' | 'liabilities' | 'expenses' | 'income';
|
||||
|
||||
export default function AccountScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const ledger = useLedgerStore(s => s.ledger);
|
||||
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
|
||||
@ -56,7 +60,7 @@ export default function AccountScreen() {
|
||||
onPress: async () => {
|
||||
try {
|
||||
await autoCloseAccount(accountName);
|
||||
Alert.alert(t('account.closeSuccess'), t('account.closeSuccessDesc'));
|
||||
showToast(t('account.closeSuccessDesc'), 'success');
|
||||
} catch (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 typeVal = values.type?.trim();
|
||||
const nameVal = values.name?.trim();
|
||||
const currencyVal = values.currency?.trim() || 'CNY';
|
||||
|
||||
if (!typeVal || !nameVal) {
|
||||
Alert.alert(t('account.addFail'), t('account.addFailEmpty'));
|
||||
@ -84,7 +87,7 @@ export default function AccountScreen() {
|
||||
.map((seg, idx) => {
|
||||
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(/[^\w\u4e00-\u9fa5-]/g, '');
|
||||
@ -102,7 +105,7 @@ export default function AccountScreen() {
|
||||
try {
|
||||
await autoOpenAccounts([sanitized]);
|
||||
setIsAdding(false);
|
||||
Alert.alert(t('account.openSuccess'), t('account.openSuccessDesc', { name: sanitized }));
|
||||
showToast(t('account.openSuccessDesc', { name: sanitized }), 'success');
|
||||
} catch (e) {
|
||||
Alert.alert(t('account.openFail'), e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
@ -121,7 +124,7 @@ export default function AccountScreen() {
|
||||
try {
|
||||
await adjustAccountBalance(adjustingAccount, balanceVal, dateVal);
|
||||
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) {
|
||||
Alert.alert(t('account.adjustFail'), e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
@ -134,81 +137,22 @@ export default function AccountScreen() {
|
||||
{ key: 'income', label: t('account.tabIncome') },
|
||||
];
|
||||
|
||||
const rootTypeOptions = [
|
||||
{ label: t('account.rootTypes.Assets'), value: 'Assets', subLabel: 'Assets' },
|
||||
{ label: t('account.rootTypes.Liabilities'), value: 'Liabilities', subLabel: 'Liabilities' },
|
||||
{ label: t('account.rootTypes.Expenses'), value: 'Expenses', subLabel: 'Expenses' },
|
||||
{ label: t('account.rootTypes.Income'), value: 'Income', subLabel: 'Income' },
|
||||
{ label: t('account.rootTypes.Equity'), value: 'Equity', subLabel: 'Equity' },
|
||||
];
|
||||
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
key: 'type',
|
||||
label: t('account.fieldType'),
|
||||
type: 'dropdown',
|
||||
options: rootTypeOptions,
|
||||
defaultValue: tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income',
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: t('account.fieldName'),
|
||||
placeholder: t('account.fieldNamePlaceholder'),
|
||||
defaultValue: '',
|
||||
flex: 1.5,
|
||||
},
|
||||
{
|
||||
key: 'currency',
|
||||
label: t('account.fieldCurrency'),
|
||||
placeholder: 'CNY',
|
||||
defaultValue: 'CNY',
|
||||
},
|
||||
];
|
||||
|
||||
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')} />
|
||||
|
||||
{/* Tab 选项卡 */}
|
||||
<View style={[styles.tabBar, { borderBottomColor: theme.colors.border }]}>
|
||||
{tabs.map(item => {
|
||||
const isActive = tab === item.key;
|
||||
return (
|
||||
<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>
|
||||
{/* Tab 选项卡(统一分段选择器) */}
|
||||
<SegmentedControl
|
||||
options={tabs}
|
||||
value={tab}
|
||||
onChange={key => setTab(key as TabType)}
|
||||
/>
|
||||
|
||||
{/* 账户列表 */}
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Pressable onPress={() => setIsAdding(true)} style={styles.addBtn}>
|
||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>
|
||||
{t('account.addNew')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{filteredAccounts.map(account => {
|
||||
<FlatList
|
||||
data={filteredAccounts}
|
||||
keyExtractor={account => account}
|
||||
renderItem={({ item: account }) => {
|
||||
const shortName = account.slice(prefix.length);
|
||||
const rootType = account.split(':')[0] as 'Assets' | 'Liabilities' | 'Expenses' | 'Income' | 'Equity';
|
||||
const localizedRoot = t(`account.rootLabels.${rootType}`);
|
||||
@ -216,7 +160,7 @@ export default function AccountScreen() {
|
||||
const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY';
|
||||
|
||||
return (
|
||||
<Card key={account} title={shortName}>
|
||||
<Card title={shortName}>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||
{t('account.fullName')}
|
||||
@ -242,7 +186,7 @@ export default function AccountScreen() {
|
||||
<View style={styles.cardActions}>
|
||||
<Pressable
|
||||
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} />
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
|
||||
@ -251,7 +195,7 @@ export default function AccountScreen() {
|
||||
</Pressable>
|
||||
<Pressable
|
||||
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} />
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
|
||||
@ -261,14 +205,20 @@ export default function AccountScreen() {
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredAccounts.length === 0 && (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}>
|
||||
{t('account.empty')}
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
}}
|
||||
ListHeaderComponent={
|
||||
<Pressable onPress={() => setIsAdding(true)} style={styles.addBtn}>
|
||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>
|
||||
{t('account.addNew')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<EmptyState icon="wallet-outline" title={t('account.empty')} />
|
||||
}
|
||||
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
|
||||
/>
|
||||
|
||||
{/* 新开户对话框 */}
|
||||
<AccountCreateModal
|
||||
@ -305,12 +255,10 @@ export default function AccountScreen() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
tabBar: { flexDirection: 'row', borderBottomWidth: 1, paddingHorizontal: 8 },
|
||||
tabItem: { flex: 1, alignItems: 'center', paddingVertical: 12 },
|
||||
content: { padding: 16, gap: 12 },
|
||||
content: { padding: 16 },
|
||||
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
|
||||
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' },
|
||||
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
|
||||
actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
|
||||
closeBtn: { 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, paddingHorizontal: 8, paddingVertical: 4 },
|
||||
});
|
||||
|
||||
@ -13,14 +13,14 @@ import React, { useState, useCallback, useRef } from 'react';
|
||||
import { FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useNumpadUiStore } from '../../store/numpadUiStore';
|
||||
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai';
|
||||
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider } from '../../domain/ai';
|
||||
import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||
import { SkeletonText } from '../../components/ui/Skeleton';
|
||||
|
||||
interface UiMessage {
|
||||
role: 'user' | 'assistant';
|
||||
@ -45,9 +45,6 @@ function buildProvider(get: ReturnType<typeof useSettingsStore.getState>): AiPro
|
||||
export default function AIChatScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
const aiEnabled = useSettingsStore(s => s.aiEnabled);
|
||||
const aiApiKey = useSettingsStore(s => s.aiApiKey);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<UiMessage[]>([
|
||||
@ -109,9 +106,10 @@ export default function AIChatScreen() {
|
||||
{
|
||||
backgroundColor: isUser ? theme.colors.accent : theme.colors.bgTertiary,
|
||||
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}
|
||||
</Text>
|
||||
{/* 账单卡片 */}
|
||||
@ -125,7 +123,7 @@ export default function AIChatScreen() {
|
||||
<Pressable
|
||||
key={i}
|
||||
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: dirColor, fontWeight: '700' }]}>
|
||||
{dirSign} {card.amount} {card.currency}
|
||||
@ -148,7 +146,7 @@ export default function AIChatScreen() {
|
||||
};
|
||||
|
||||
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('ai.chatTitle')} />
|
||||
|
||||
<FlatList
|
||||
@ -156,24 +154,27 @@ export default function AIChatScreen() {
|
||||
data={messages}
|
||||
keyExtractor={(item, index) => `${index}`}
|
||||
renderItem={renderMessage}
|
||||
contentContainerStyle={{ padding: 16, gap: 8 }}
|
||||
contentContainerStyle={{ padding: 16, gap: theme.spacing.md }}
|
||||
onContentSizeChange={() => listRef.current?.scrollToEnd()}
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', paddingBottom: 4 }]}>
|
||||
{t('ai.thinking')}
|
||||
</Text>
|
||||
<View style={{ paddingHorizontal: 16, paddingBottom: 4 }}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: theme.spacing.xs }]}>
|
||||
{t('ai.thinking')}
|
||||
</Text>
|
||||
<SkeletonText lines={2} lineHeight={12} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||
<View style={[styles.inputRow, { backgroundColor: theme.colors.bgSecondary, borderTopColor: theme.colors.border }]}>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
placeholder={t('ai.inputPlaceholder')}
|
||||
placeholderTextColor={theme.colors.fgSecondary}
|
||||
style={[styles.input, { color: theme.colors.fgPrimary }]}
|
||||
style={[styles.input, { color: theme.colors.fgPrimary, fontSize: theme.typography.body.fontSize }]}
|
||||
multiline
|
||||
/>
|
||||
<Pressable
|
||||
@ -195,9 +196,9 @@ export default function AIChatScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
msgRow: { flexDirection: 'row', maxWidth: '100%' },
|
||||
bubble: { maxWidth: '85%', borderRadius: 12, padding: 12, borderWidth: 1 },
|
||||
billCard: { marginTop: 8, padding: 10, borderRadius: 8, borderWidth: 1 },
|
||||
bubble: { maxWidth: '85%', padding: 12, borderWidth: 1 },
|
||||
billCard: { marginTop: 8, padding: 10, borderWidth: 1 },
|
||||
inputRow: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingVertical: 8, borderTopWidth: 1, gap: 8 },
|
||||
input: { flex: 1, maxHeight: 100, fontSize: 15, paddingVertical: 8 },
|
||||
input: { flex: 1, maxHeight: 100, paddingVertical: 8 },
|
||||
sendBtn: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
|
||||
@ -16,27 +16,26 @@
|
||||
* - 支付 App 白名单展示
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, Alert, AppState, Linking, NativeModules, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, Switch } 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 { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../theme';
|
||||
import { createCommonStyles } from '../../theme/commonStyles';
|
||||
import { useT } from '../../i18n';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { Card } from '../../components/Card';
|
||||
import { Button } from '../../components/Button';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||
import {
|
||||
getAccessibilityBridge,
|
||||
getPackageLabel,
|
||||
type PageSignature,
|
||||
} from '../../services/accessibilityBridge';
|
||||
import { ensureOcrModels, downloadOcrModels } from '../../services/modelDownloader';
|
||||
} from '../../services/automation/accessibilityBridge';
|
||||
import { ensureOcrModels, downloadOcrModels } from '../../services/ocr/modelDownloader';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export default function AutomationScreen() {
|
||||
const { theme } = useTheme();
|
||||
const commonStyles = createCommonStyles(theme);
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
@ -51,14 +50,8 @@ export default function AutomationScreen() {
|
||||
const layer3AiEnabled = useSettingsStore(s => s.layer3AiEnabled);
|
||||
const setLayer3AiEnabled = useSettingsStore(s => s.setLayer3AiEnabled);
|
||||
const ocrModelVersion = useSettingsStore(s => s.ocrModelVersion);
|
||||
const setOcrModelVersion = useSettingsStore(s => s.setOcrModelVersion);
|
||||
const setOcrModelDir = useSettingsStore(s => s.setOcrModelDir);
|
||||
const aiEnabled = useSettingsStore(s => s.aiEnabled);
|
||||
const aiProviderId = useSettingsStore(s => s.aiProviderId);
|
||||
const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
|
||||
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || '';
|
||||
const aiModel = useSettingsStore(s => s.aiModel) || '';
|
||||
const updateAiConfig = useSettingsStore(s => s.updateAiConfig);
|
||||
const dedupEnabled = useSettingsStore(s => s.dedupEnabled);
|
||||
const setDedupEnabled = useSettingsStore(s => s.setDedupEnabled);
|
||||
const transferRecognitionEnabled = useSettingsStore(s => s.transferRecognitionEnabled);
|
||||
@ -128,18 +121,6 @@ export default function AutomationScreen() {
|
||||
setLayer3AiEnabled(val);
|
||||
};
|
||||
|
||||
const providerDefaultUrls: Record<string, string> = {
|
||||
openai: 'https://api.openai.com/v1',
|
||||
gemini: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
deepseek: 'https://api.deepseek.com/v1',
|
||||
};
|
||||
|
||||
const providerDefaultModels: Record<string, string> = {
|
||||
openai: 'gpt-4o-mini',
|
||||
gemini: 'gemini-2.0-flash',
|
||||
deepseek: 'deepseek-chat',
|
||||
};
|
||||
|
||||
// 刷新无障碍状态
|
||||
const refreshAccessibilityState = useCallback(async () => {
|
||||
const bridge = getAccessibilityBridge();
|
||||
@ -155,8 +136,8 @@ export default function AutomationScreen() {
|
||||
setPageSignatures(sigs);
|
||||
setPaymentPackages(pkgs.map(pkg => ({ package: pkg, label: getPackageLabel(pkg) })));
|
||||
setTopApp(top && top.package ? top : null);
|
||||
} catch {
|
||||
// 静默
|
||||
} catch (e) {
|
||||
logger.debug('automation', '[刷新无障碍状态] 失败', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -208,7 +189,7 @@ export default function AutomationScreen() {
|
||||
|
||||
const handleOpenNotificationSettings = () => {
|
||||
if (Platform.OS !== 'android') return;
|
||||
Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS').catch(() => {});
|
||||
Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS').catch((e) => { logger.debug('automation', '[打开通知设置] 失败', e); });
|
||||
};
|
||||
|
||||
const handleRequestSms = async () => {
|
||||
@ -217,7 +198,7 @@ export default function AutomationScreen() {
|
||||
};
|
||||
|
||||
const handleOpenOverlaySettings = () => {
|
||||
Linking.sendIntent('android.settings.action.MANAGE_OVERLAY_PERMISSION').catch(() => {});
|
||||
Linking.sendIntent('android.settings.action.MANAGE_OVERLAY_PERMISSION').catch((e) => { logger.debug('automation', '[打开悬浮窗设置] 失败', e); });
|
||||
};
|
||||
|
||||
const handleRequestStorage = async () => {
|
||||
@ -318,8 +299,8 @@ export default function AutomationScreen() {
|
||||
try {
|
||||
await bridge.removePageSignature(sig);
|
||||
refreshAccessibilityState();
|
||||
} catch {
|
||||
// 静默
|
||||
} catch (e) {
|
||||
logger.warn('automation', `[删除页面签名] 失败: ${sig}`, e);
|
||||
}
|
||||
};
|
||||
|
||||
@ -338,8 +319,8 @@ export default function AutomationScreen() {
|
||||
try {
|
||||
await bridge.clearPageSignatures();
|
||||
refreshAccessibilityState();
|
||||
} catch {
|
||||
// 静默
|
||||
} catch (e) {
|
||||
logger.warn('automation', '[清空页面签名] 失败', e);
|
||||
}
|
||||
},
|
||||
},
|
||||
@ -348,7 +329,7 @@ export default function AutomationScreen() {
|
||||
};
|
||||
|
||||
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('automation.title')} />
|
||||
|
||||
<ScrollView ref={scrollRef} contentContainerStyle={styles.content}>
|
||||
@ -381,7 +362,7 @@ export default function AutomationScreen() {
|
||||
setFloatingBallEnabled(val);
|
||||
const bridge = getAccessibilityBridge();
|
||||
if (bridge) {
|
||||
try { await bridge.setFloatingBallEnabled(val); } catch {}
|
||||
try { await bridge.setFloatingBallEnabled(val); } catch (e) { logger.debug('automation', '[悬浮球] 同步开关失败', e); }
|
||||
}
|
||||
}}
|
||||
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
||||
@ -655,8 +636,6 @@ export default function AutomationScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
content: { padding: 16, paddingBottom: 64 },
|
||||
statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
|
||||
statItem: { alignItems: 'center', gap: 4 },
|
||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
statusDot: { width: 8, height: 8, borderRadius: 4 },
|
||||
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
|
||||
@ -11,11 +11,11 @@ import { useTheme } from '../../theme';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||
import { calculateBudgetProgress } from '../../domain/budgets';
|
||||
import { toDateString } from '../../domain/decimal';
|
||||
import type { Budget } from '../../domain/budgets';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||
import { calculateBudgetProgress } from '../../domain/finance/budgets';
|
||||
import { toDateString } from '../../domain/core/decimal';
|
||||
import type { Budget } from '../../domain/finance/budgets';
|
||||
|
||||
export default function BudgetScreen() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
@ -10,10 +10,10 @@ import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme, createCommonStyles } from '../../theme';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||
import { Touchable } from '../../components/Touchable';
|
||||
import type { Category } from '../../domain/categories';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||
import { Touchable } from '../../components/ui/Touchable';
|
||||
import type { Category } from '../../domain/taxonomy/categories';
|
||||
|
||||
export default function CategoryScreen() {
|
||||
const { theme } = useTheme();
|
||||
@ -33,7 +33,7 @@ export default function CategoryScreen() {
|
||||
title={t('category.title')}
|
||||
items={categories.filter(c => c.type === catType)}
|
||||
keyExtractor={cat => cat.id}
|
||||
addLabel={t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') })}
|
||||
addLabel={catType === 'income' ? t('category.addIncome') : t('category.addExpense')}
|
||||
headerContent={
|
||||
<View style={styles.chipRow}>
|
||||
{(['expense', 'income'] as const).map(tp => (
|
||||
@ -61,7 +61,7 @@ export default function CategoryScreen() {
|
||||
)}
|
||||
formTitle={editing => (editing
|
||||
? t('category.editTitle')
|
||||
: t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') }))}
|
||||
: catType === 'income' ? t('category.addIncome') : t('category.addExpense'))}
|
||||
formFields={editing => [
|
||||
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder'), defaultValue: editing?.name },
|
||||
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder'), defaultValue: editing?.linkedAccount },
|
||||
@ -79,7 +79,7 @@ export default function CategoryScreen() {
|
||||
id: generateId('cat'),
|
||||
name: values.name || t('common.untitled'),
|
||||
type: catType,
|
||||
linkedAccount: values.linkedAccount || (catType === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized'),
|
||||
linkedAccount: values.linkedAccount || (catType === 'income' ? 'Income:未分类' : 'Expenses:未分类'),
|
||||
keywords: values.keywords ? values.keywords.split(/[,,\s]+/).filter(Boolean) : [],
|
||||
});
|
||||
}
|
||||
|
||||
@ -11,12 +11,12 @@ import { useTheme } from '../../theme';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||
import { AccountCreateModal } from '../../components/AccountCreateModal';
|
||||
import { Touchable } from '../../components/Touchable';
|
||||
import type { CreditCard } from '../../domain/creditCards';
|
||||
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||
import { AccountCreateModal } from '../../components/account/AccountCreateModal';
|
||||
import { Touchable } from '../../components/ui/Touchable';
|
||||
import type { CreditCard } from '../../domain/finance/creditCards';
|
||||
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/finance/creditCards';
|
||||
|
||||
export default function CreditCardScreen() {
|
||||
const { theme } = useTheme();
|
||||
@ -29,7 +29,7 @@ export default function CreditCardScreen() {
|
||||
|
||||
const ledger = useLedgerStore(s => s.ledger);
|
||||
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
|
||||
const transactions = ledger?.transactions ?? [];
|
||||
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger?.transactions]);
|
||||
|
||||
const [isQuickAddingAccount, setIsQuickAddingAccount] = useState(false);
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { DuplicateDetail } from '../../domain/dedup';
|
||||
import type { DuplicateDetail, DedupConfidence } from '../../domain/transaction/dedup';
|
||||
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@ -9,18 +9,19 @@ import * as XLSX from 'xlsx';
|
||||
|
||||
// 实例化纯 JS GBK 解码器(Hermes 原生 TextDecoder 仅支持 UTF-8,需强迫 text-encoding-gbk 返回其纯 JS 实现)
|
||||
const GbkTextDecoder = (() => {
|
||||
const origDecoder = (globalThis as any).TextDecoder;
|
||||
const origEncoder = (globalThis as any).TextEncoder;
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
const origDecoder = g.TextDecoder;
|
||||
const origEncoder = g.TextEncoder;
|
||||
try {
|
||||
(globalThis as any).TextDecoder = undefined;
|
||||
(globalThis as any).TextEncoder = undefined;
|
||||
g.TextDecoder = undefined;
|
||||
g.TextEncoder = undefined;
|
||||
const dec = require('text-encoding-gbk').TextDecoder;
|
||||
return dec;
|
||||
} catch {
|
||||
return origDecoder;
|
||||
} finally {
|
||||
(globalThis as any).TextDecoder = origDecoder;
|
||||
(globalThis as any).TextEncoder = origEncoder;
|
||||
g.TextDecoder = origDecoder;
|
||||
g.TextEncoder = origEncoder;
|
||||
}
|
||||
})();
|
||||
|
||||
@ -29,13 +30,13 @@ import { useImportStore } from '../../store/importStore';
|
||||
import { useMetadataStore } from '../../store/metadataStore';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { Button } from '../../components/Button';
|
||||
import { DedupBanner } from '../../components/DedupBanner';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
import { classifyWithCategories } from '../../domain/rules';
|
||||
import { validateTransaction } from '../../domain/ledger';
|
||||
import type { ImportedEvent } from '../../domain/types';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { DedupBanner } from '../../components/transaction/DedupBanner';
|
||||
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||
import { classifyWithCategories } from '../../domain/rules/rules';
|
||||
import { validateTransaction } from '../../domain/core/ledger';
|
||||
import type { ImportedEvent } from '../../domain/core/types';
|
||||
|
||||
|
||||
// 纯 JS 实现 Base64 解码为字节数组
|
||||
@ -118,7 +119,7 @@ export default function ImportScreen() {
|
||||
try {
|
||||
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
content = utf8Decoder.decode(bytes);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// UTF-8 校验失败,回退到国标 GBK 解码(利用 text-encoding-gbk 保证 Hermes 兼容)
|
||||
try {
|
||||
const gbkDecoder = new GbkTextDecoder('gbk');
|
||||
@ -291,7 +292,7 @@ export default function ImportScreen() {
|
||||
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === event.id);
|
||||
const matchedTx = detail?.matchedWith?.rawTransaction;
|
||||
|
||||
let linkTag = matchedTx?.links?.[0] || 'lnk-' + Math.random().toString(36).slice(2, 8);
|
||||
const linkTag = matchedTx?.links?.[0] || 'lnk-' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
// 为新草稿添加链接
|
||||
const newDraft = {
|
||||
@ -332,7 +333,7 @@ export default function ImportScreen() {
|
||||
};
|
||||
|
||||
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('tab.import')} />
|
||||
<FlatList
|
||||
data={pendingDrafts}
|
||||
@ -399,14 +400,14 @@ export default function ImportScreen() {
|
||||
|
||||
const result = {
|
||||
isDuplicate: true,
|
||||
confidence: (detail?.confidence ?? 'medium') as any,
|
||||
confidence: (detail?.confidence ?? 'medium') as DedupConfidence,
|
||||
reason,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
title={`${item.occurredAt} · ${item.counterparty || '未知来源'}`}
|
||||
title={`${item.occurredAt} · ${item.counterparty || t('importFlow.unknownSource')}`}
|
||||
onPress={() => handleLinkDuplicate(item)}
|
||||
>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
||||
|
||||
@ -11,10 +11,10 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
import { Card } from '../../components/Card';
|
||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||
import { toDateString } from '../../domain/decimal';
|
||||
import type { RecurringTransaction } from '../../domain/recurring';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||
import { toDateString } from '../../domain/core/decimal';
|
||||
import type { RecurringTransaction } from '../../domain/finance/recurring';
|
||||
|
||||
export default function RecurringScreen() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
@ -8,8 +8,8 @@ import { Pressable, Text } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
import { Card } from '../../components/Card';
|
||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||
|
||||
interface RemarkTemplate {
|
||||
id: string;
|
||||
|
||||
@ -11,9 +11,9 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
import { Card } from '../../components/Card';
|
||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||
import type { Rule } from '../../domain/types';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||
import type { Rule } from '../../domain/core/types';
|
||||
|
||||
export default function RulesScreen() {
|
||||
const { theme } = useTheme();
|
||||
@ -77,7 +77,7 @@ export default function RulesScreen() {
|
||||
counterpartyContains: values.counterpartyContains?.trim() || undefined,
|
||||
memoContains: values.memoContains?.trim() || undefined,
|
||||
sourceAccount: values.sourceAccount?.trim() || 'Assets:Unknown',
|
||||
categoryAccount: values.categoryAccount?.trim() || 'Expenses:Uncategorized',
|
||||
categoryAccount: values.categoryAccount?.trim() || 'Expenses:未分类',
|
||||
narration: values.narration?.trim() || undefined,
|
||||
tags: values.tags ? values.tags.split(/[,,\s]+/).filter(Boolean) : [],
|
||||
};
|
||||
|
||||
@ -9,14 +9,14 @@
|
||||
* - AI 总开关
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View, Switch } 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 { useTheme } from '../../theme';
|
||||
import { createCommonStyles } from '../../theme/commonStyles';
|
||||
import { useT } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { Card } from '../../components/Card';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||
|
||||
const PROVIDERS: { key: string; label: string }[] = [
|
||||
{ key: 'openai', label: 'OpenAI' },
|
||||
@ -62,9 +62,10 @@ export default function AiSettingsScreen() {
|
||||
};
|
||||
|
||||
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('settings.llmTitle')} />
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
|
||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]} keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive">
|
||||
{/* AI 总开关 */}
|
||||
<Card>
|
||||
<View style={styles.row}>
|
||||
@ -144,13 +145,14 @@ export default function AiSettingsScreen() {
|
||||
/>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
content: { padding: 16, paddingBottom: 64, gap: 12 },
|
||||
content: { padding: 16, paddingBottom: 64 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
chipRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' },
|
||||
});
|
||||
|
||||
@ -5,8 +5,8 @@ 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/Card';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||
|
||||
export default function DiagnosticsScreen() {
|
||||
const { theme } = useTheme();
|
||||
@ -14,7 +14,7 @@ export default function DiagnosticsScreen() {
|
||||
const ledger = useLedgerStore(s => s.ledger);
|
||||
|
||||
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('settings.diagnostics')} />
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Card title={t('diagnostics.title')}>
|
||||
|
||||
@ -8,9 +8,10 @@
|
||||
* 4. 一键导出日志(调用原生分享/文件导出)与清空日志
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AppState,
|
||||
FlatList,
|
||||
Modal,
|
||||
Pressable,
|
||||
@ -26,7 +27,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||
import { useT } from '../../i18n';
|
||||
import { useTheme } from '../../theme';
|
||||
import { LogEntry, LogFileInfo, LogLevel, LEVEL_LABELS, logger } from '../../utils/logger';
|
||||
@ -73,6 +74,18 @@ export default function LogsScreen() {
|
||||
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;
|
||||
|
||||
@ -199,7 +212,7 @@ export default function LogsScreen() {
|
||||
);
|
||||
|
||||
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('logs.title')} right={renderHeaderRight} />
|
||||
|
||||
{/* 日志日期 Dropdown 触发按钮 */}
|
||||
@ -356,7 +369,7 @@ export default function LogsScreen() {
|
||||
<FlatList
|
||||
data={filteredLogs}
|
||||
keyExtractor={(_, index) => String(index)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
contentContainerStyle={[styles.listContent, { gap: theme.spacing.md }]}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons name="document-text-outline" size={48} color={theme.colors.fgSecondary} />
|
||||
@ -427,16 +440,16 @@ 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, height: 38 },
|
||||
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, height: 36, gap: 8 },
|
||||
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, gap: 10, paddingBottom: 40 },
|
||||
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 },
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View, Switch } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { TimePicker } from '../../components/ui/TimePicker';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { useSettingsStore, type Locale, type ThemeMode } from '../../store/settingsStore';
|
||||
import { Card } from '../../components/Card';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||
|
||||
export default function PreferencesScreen() {
|
||||
const { theme } = useTheme();
|
||||
@ -23,6 +24,7 @@ export default function PreferencesScreen() {
|
||||
const reminderHour = useSettingsStore(s => s.reminderHour);
|
||||
const reminderMinute = useSettingsStore(s => s.reminderMinute);
|
||||
const updateReminderConfig = useSettingsStore(s => s.updateReminderConfig);
|
||||
const [timePickerOpen, setTimePickerOpen] = useState(false);
|
||||
const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
|
||||
const setNumpadGlobalEntry = useSettingsStore(s => s.setNumpadGlobalEntry);
|
||||
|
||||
@ -38,10 +40,10 @@ export default function PreferencesScreen() {
|
||||
];
|
||||
|
||||
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('settings.preferencesTitle')} />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||
{/* 外观设置 */}
|
||||
<Card title={t('settings.appearance')}>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
|
||||
@ -59,6 +61,7 @@ export default function PreferencesScreen() {
|
||||
{
|
||||
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
|
||||
borderColor: active ? theme.colors.accent : theme.colors.border,
|
||||
borderRadius: theme.radii.sm,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@ -88,6 +91,7 @@ export default function PreferencesScreen() {
|
||||
{
|
||||
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
|
||||
borderColor: active ? theme.colors.accent : theme.colors.border,
|
||||
borderRadius: theme.radii.sm,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@ -129,7 +133,7 @@ export default function PreferencesScreen() {
|
||||
onValueChange={async (val) => {
|
||||
if (val) {
|
||||
// 开启:需要设置 PIN
|
||||
const { hasPinSet, setPin } = await import('../../components/LockScreen');
|
||||
const { hasPinSet } = await import('../../components/layout/LockScreen');
|
||||
const hasPin = await hasPinSet();
|
||||
if (!hasPin) {
|
||||
// Alert.prompt 仅 iOS 可用;Android 直接开启(用户首次锁屏时设置)
|
||||
@ -155,7 +159,7 @@ export default function PreferencesScreen() {
|
||||
}
|
||||
} else {
|
||||
// 关闭:清除 PIN
|
||||
const { clearPin } = await import('../../components/LockScreen');
|
||||
const { clearPin } = await import('../../components/layout/LockScreen');
|
||||
await clearPin();
|
||||
setAppLockEnabled(false);
|
||||
}
|
||||
@ -197,56 +201,42 @@ export default function PreferencesScreen() {
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
||||
{t('settings.reminderTime')}
|
||||
</Text>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700' }]}>
|
||||
{String(reminderHour).padStart(2, '0')}:{String(reminderMinute).padStart(2, '0')}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 4, marginLeft: 12 }}>
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
const newHour = (reminderHour - 1 + 24) % 24;
|
||||
updateReminderConfig({ reminderHour: newHour });
|
||||
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
|
||||
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
|
||||
}}
|
||||
style={{ padding: 4 }}
|
||||
>
|
||||
<Ionicons name="remove-circle-outline" size={20} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
const newHour = (reminderHour + 1) % 24;
|
||||
updateReminderConfig({ reminderHour: newHour });
|
||||
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
|
||||
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
|
||||
}}
|
||||
style={{ padding: 4 }}
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={20} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
const newMinute = (reminderMinute + 5) % 60;
|
||||
updateReminderConfig({ reminderMinute: newMinute });
|
||||
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
|
||||
await setupDailyReminder(new RealNotificationScheduler(), reminderHour, newMinute);
|
||||
}}
|
||||
style={{ padding: 4 }}
|
||||
>
|
||||
<Ionicons name="time-outline" size={20} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => setTimePickerOpen(true)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('settings.reminderTime')}
|
||||
style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||||
{String(reminderHour).padStart(2, '0')}:{String(reminderMinute).padStart(2, '0')}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
</ScrollView>
|
||||
<TimePicker
|
||||
visible={timePickerOpen}
|
||||
hour={reminderHour}
|
||||
minute={reminderMinute}
|
||||
title={t('settings.reminderTime')}
|
||||
onCancel={() => setTimePickerOpen(false)}
|
||||
onConfirm={async (h, m) => {
|
||||
setTimePickerOpen(false);
|
||||
updateReminderConfig({ reminderHour: h, reminderMinute: m });
|
||||
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
|
||||
await setupDailyReminder(new RealNotificationScheduler(), h, m);
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
content: { padding: 16, gap: 16 },
|
||||
content: { padding: 16 },
|
||||
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' },
|
||||
});
|
||||
|
||||
@ -1,24 +1,25 @@
|
||||
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 * as DocumentPicker from 'expo-document-picker';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useSettingsStore, type PersistableSettings } from '../../store/settingsStore';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useImportStore } from '../../store/importStore';
|
||||
import { useMetadataStore } from '../../store/metadataStore';
|
||||
import { Card } from '../../components/Card';
|
||||
import { Button } from '../../components/Button';
|
||||
import { FormModal, type FormField } from '../../components/FormModal';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
import { useMetadataStore, type PersistableMetadata } from '../../store/metadataStore';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { FormModal, type FormField } from '../../components/form/FormModal';
|
||||
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||
import { syncWithWebDAV, WebDAVClient, type FetchImpl } from '../../services/sync/webdavSync';
|
||||
import { syncWithGit, ExpoGitBackend } from '../../services/sync/gitSync';
|
||||
import { syncWithICloud, MockICloudFileSystem } from '../../services/sync/icloudSync';
|
||||
import { createBackupBundle, serializeBundle, deserializeBundle, restoreFiles } from '../../services/backup';
|
||||
import { runMaintenance, ExpoMaintenanceFs, ExpoMaintenanceDb } from '../../services/maintenance';
|
||||
import { createBackupBundle, serializeBundle, deserializeBundle, restoreFiles } from '../../services/data/backup';
|
||||
import type { ExcelWorkbook } from '../../services/data/exportToExcel';
|
||||
import { runMaintenance, ExpoMaintenanceFs, ExpoMaintenanceDb } from '../../services/data/maintenance';
|
||||
|
||||
export default function SyncSettingsScreen() {
|
||||
const { theme } = useTheme();
|
||||
@ -239,7 +240,7 @@ export default function SyncSettingsScreen() {
|
||||
|
||||
// 恢复设置(v2)
|
||||
if (bundle.settings) {
|
||||
useSettingsStore.getState().hydrate(bundle.settings as any);
|
||||
useSettingsStore.getState().hydrate(bundle.settings as Partial<PersistableSettings>);
|
||||
// 同步持久化到文件
|
||||
const settingsPath = FileSystem.documentDirectory + 'settings.json';
|
||||
await FileSystem.writeAsStringAsync(settingsPath, JSON.stringify(bundle.settings, null, 2));
|
||||
@ -247,7 +248,7 @@ export default function SyncSettingsScreen() {
|
||||
|
||||
// 恢复元数据(v2)
|
||||
if (bundle.metadata) {
|
||||
useMetadataStore.getState().hydrate(bundle.metadata as any);
|
||||
useMetadataStore.getState().hydrate(bundle.metadata as Partial<PersistableMetadata>);
|
||||
// 同步持久化到文件
|
||||
const metadataPath = FileSystem.documentDirectory + 'metadata.json';
|
||||
await FileSystem.writeAsStringAsync(metadataPath, JSON.stringify(bundle.metadata, null, 2));
|
||||
@ -284,7 +285,7 @@ export default function SyncSettingsScreen() {
|
||||
const categories = useMetadataStore.getState().categories;
|
||||
const loadLedger = useLedgerStore.getState().loadLedger;
|
||||
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());
|
||||
setContext({ rules, categories });
|
||||
@ -338,7 +339,7 @@ export default function SyncSettingsScreen() {
|
||||
const handleExportExcel = async () => {
|
||||
try {
|
||||
const XLSX = (await import('xlsx')).default;
|
||||
const { exportToExcel } = await import('../../services/exportToExcel');
|
||||
const { exportToExcel } = await import('../../services/data/exportToExcel');
|
||||
const { transactions } = ledger!;
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
@ -346,7 +347,7 @@ export default function SyncSettingsScreen() {
|
||||
let base64Content = '';
|
||||
// 用 xlsx 库创建 workbook wrapper
|
||||
const wbWrapper = {
|
||||
addSheet: (name: string, rows: any[]) => {
|
||||
addSheet: (name: string, rows: Record<string, unknown>[]) => {
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
XLSX.utils.book_append_sheet(workbook, ws, name);
|
||||
},
|
||||
@ -355,7 +356,7 @@ export default function SyncSettingsScreen() {
|
||||
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 直接保存到本地公开目录
|
||||
if (Platform.OS === 'android') {
|
||||
@ -453,10 +454,10 @@ export default function SyncSettingsScreen() {
|
||||
const isAndroid = Platform.OS === 'android';
|
||||
|
||||
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('sync.title')} />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||
{/* 云同步配置与云端操作 */}
|
||||
<Card title={t('sync.cloudTitle')}>
|
||||
<View style={styles.syncBtnRow}>
|
||||
@ -530,7 +531,7 @@ export default function SyncSettingsScreen() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
content: { padding: 16, gap: 16 },
|
||||
content: { padding: 16 },
|
||||
syncBtnRow: { flexDirection: 'row', gap: 8 },
|
||||
buttonRow: { flexDirection: 'row', gap: 8 },
|
||||
});
|
||||
|
||||
@ -10,9 +10,9 @@ 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';
|
||||
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||
import { isValidTagName } from '../../domain/taxonomy/tags';
|
||||
import type { Tag } from '../../domain/taxonomy/tags';
|
||||
|
||||
export default function TagScreen() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
@ -8,18 +8,18 @@
|
||||
* - 原始 .bean 文本
|
||||
*/
|
||||
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 { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme, createCommonStyles } from '../../theme';
|
||||
import { Card } from '../../components/Card';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useNumpadUiStore } from '../../store/numpadUiStore';
|
||||
import { hash } from '../../domain/ledger';
|
||||
import { hash } from '../../domain/core/ledger';
|
||||
import { useT } from '../../i18n';
|
||||
import { TransactionCard } from '../../components/TransactionCard';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
import { TransactionCard } from '../../components/transaction/TransactionCard';
|
||||
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
export default function TransactionDetailScreen() {
|
||||
@ -46,7 +46,7 @@ export default function TransactionDetailScreen() {
|
||||
return ledger.transactions.filter(
|
||||
t => t.id !== id && t.links.some(l => tx.links.includes(l))
|
||||
);
|
||||
}, [ledger, id, tx?.links]);
|
||||
}, [ledger, id, tx]);
|
||||
|
||||
// 筛选可以用来关联的其他账单
|
||||
const linkableTransactions = useMemo(() => {
|
||||
@ -224,7 +224,7 @@ export default function TransactionDetailScreen() {
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
|
||||
<ScreenHeader title={t('transaction.detailTitle')} />
|
||||
<ScrollView contentContainerStyle={[styles.content, { gap: 12 }]}>
|
||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||
{/* 摘要卡片 */}
|
||||
<Card title={t('transaction.summary')}>
|
||||
<View style={styles.summaryRow}>
|
||||
@ -489,7 +489,7 @@ export default function TransactionDetailScreen() {
|
||||
transparent={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}>
|
||||
<Pressable onPress={() => setLinkModalVisible(false)}>
|
||||
<Ionicons name="close" size={24} color={theme.colors.fgPrimary} />
|
||||
@ -499,9 +499,10 @@ export default function TransactionDetailScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
|
||||
<View style={styles.searchBarContainer}>
|
||||
<TextInput
|
||||
style={[commonStyles.input, { height: 40 }]}
|
||||
style={[commonStyles.input, { minHeight: 40 }]}
|
||||
placeholder={t('transaction.searchTxPlaceholder')}
|
||||
placeholderTextColor={theme.colors.fgSecondary}
|
||||
value={searchQuery}
|
||||
@ -512,6 +513,7 @@ export default function TransactionDetailScreen() {
|
||||
<FlatList
|
||||
data={linkableTransactions}
|
||||
keyExtractor={(item) => item.id}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
renderItem={({ item }) => (
|
||||
<View style={{ paddingHorizontal: 16, paddingVertical: 4 }}>
|
||||
<TransactionCard
|
||||
@ -526,6 +528,7 @@ export default function TransactionDetailScreen() {
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
@ -538,13 +541,11 @@ const styles = StyleSheet.create({
|
||||
content: { padding: 16 },
|
||||
summaryRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
directionBadge: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: 12 },
|
||||
postingRow: { flexDirection: 'row', paddingTop: 10, gap: 8 },
|
||||
tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||
tag: { paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
|
||||
tagContainer: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
|
||||
linkBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 8 },
|
||||
searchBarContainer: { paddingHorizontal: 16, paddingBottom: 8 },
|
||||
searchInput: { height: 40, borderWidth: StyleSheet.hairlineWidth, borderRadius: 8, paddingHorizontal: 12, fontSize: 14 },
|
||||
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
|
||||
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
|
||||
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 },
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
|
||||
interface BottomSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
/** 内容可滚动(maxHeight 480),适合长表单。 */
|
||||
scrollable?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** 通用底部弹层容器:顶部 xl 圆角 + 把手、遮罩点击关闭、Android 返回键关闭。 */
|
||||
export function BottomSheet({ visible, onClose, title, scrollable, 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}
|
||||
{scrollable ? (
|
||||
<ScrollView style={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||
{children}
|
||||
</ScrollView>
|
||||
) : (
|
||||
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 },
|
||||
scroll: { maxHeight: 480 },
|
||||
});
|
||||
@ -1,490 +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, useMemo, useState } from 'react';
|
||||
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme, createCommonStyles } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { Button } from './Button';
|
||||
import { Touchable } from './Touchable';
|
||||
|
||||
export interface FormOption {
|
||||
label: string; // 中文名称
|
||||
value: string; // 英文前缀 (如 Assets)
|
||||
subLabel?: string;
|
||||
}
|
||||
|
||||
export interface FormField {
|
||||
/** 字段 key,对应 values 对象的属性名。 */
|
||||
key: string;
|
||||
/** 显示标签。 */
|
||||
label: string;
|
||||
/** 占位提示。 */
|
||||
placeholder?: string;
|
||||
/** 默认值(新增时为空,编辑时预填)。 */
|
||||
defaultValue?: string;
|
||||
/** 键盘类型。 */
|
||||
keyboardType?: 'default' | 'numeric' | 'decimal-pad' | 'phone-pad';
|
||||
/** 多行输入。 */
|
||||
multiline?: boolean;
|
||||
/** 密码输入(遮蔽文本)。 */
|
||||
secureTextEntry?: boolean;
|
||||
/** 控件类型:'text' (默认) | 'select' 单选卡片组 | 'dropdown' 下拉选择框 */
|
||||
type?: 'text' | 'select' | 'dropdown';
|
||||
/** select / dropdown 类型的可选项。 */
|
||||
options?: FormOption[];
|
||||
/** 在同一行内布局时的占比弹性系数。 */
|
||||
flex?: number;
|
||||
/** 行号标识,相同 row 的字段组合在同一行中。 */
|
||||
row?: number;
|
||||
}
|
||||
|
||||
interface FormModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
fields: FormField[];
|
||||
onConfirm: (values: Record<string, string>) => void;
|
||||
onCancel: () => void;
|
||||
/** 确认按钮文本(默认「确定」)。 */
|
||||
confirmLabel?: string;
|
||||
/** 字段值变更时的联动回调,返回新值覆盖。 */
|
||||
onValuesChange?: (changedKey: string, newValue: string, prevValues: Record<string, string>) => Record<string, string> | void;
|
||||
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel, onValuesChange, children }: FormModalProps) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [activeDropdownKey, setActiveDropdownKey] = useState<string | null>(null);
|
||||
const [focusedKey, setFocusedKey] = useState<string | null>(null);
|
||||
|
||||
const updateFieldValue = (key: string, val: string) => {
|
||||
setValues(prev => {
|
||||
const next = { ...prev, [key]: val };
|
||||
if (onValuesChange) {
|
||||
const overridden = onValuesChange(key, val, next);
|
||||
return overridden ? { ...next, ...overridden } : next;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 每次 visible 变为 true 时,用 fields 的 defaultValue 初始化
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const init: Record<string, string> = {};
|
||||
for (const f of fields) {
|
||||
init[f.key] = f.defaultValue ?? '';
|
||||
}
|
||||
setValues(init);
|
||||
setActiveDropdownKey(null);
|
||||
setFocusedKey(null);
|
||||
}
|
||||
}, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 将包含 flex 的连续字段组合成同行渲染组
|
||||
const renderField = (f: FormField) => {
|
||||
const isFocused = focusedKey === f.key || activeDropdownKey === f.key;
|
||||
|
||||
if (f.type === 'dropdown' && f.options) {
|
||||
const selectedOpt = f.options.find(o => o.value === (values[f.key] ?? ''));
|
||||
const rawValue = selectedOpt ? selectedOpt.value : (values[f.key] ?? '');
|
||||
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 key={f.key} style={[styles.field, f.flex ? { flex: f.flex } : undefined]}>
|
||||
<Text style={[theme.typography.caption, styles.fieldLabel, { color: theme.colors.fgSecondary }]}>
|
||||
{f.label}
|
||||
</Text>
|
||||
<Touchable
|
||||
style={[
|
||||
commonStyles.input,
|
||||
styles.dropdownTrigger,
|
||||
{
|
||||
borderColor: isFocused ? theme.colors.accent : theme.colors.border,
|
||||
backgroundColor: isFocused ? theme.colors.bgPrimary : theme.colors.bgTertiary,
|
||||
borderWidth: isFocused ? 1.5 : StyleSheet.hairlineWidth,
|
||||
},
|
||||
]}
|
||||
onPress={() => setActiveDropdownKey(f.key)}
|
||||
>
|
||||
<View style={styles.dropdownTriggerContent}>
|
||||
{triggerPrefix ? (
|
||||
<Text style={[styles.dropdownValueText, { color: theme.colors.fgSecondary, fontSize: 10, lineHeight: 12 }]} numberOfLines={1}>
|
||||
{triggerPrefix}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
styles.dropdownLabelText,
|
||||
{
|
||||
color: isPlaceholder ? theme.colors.fgSecondary : theme.colors.fgPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: isPlaceholder ? '400' : '600',
|
||||
lineHeight: 16,
|
||||
},
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{triggerTitle}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-down" size={16} color={isFocused ? theme.colors.accent : theme.colors.fgSecondary} />
|
||||
</Touchable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (f.type === 'select' && f.options) {
|
||||
return (
|
||||
<View key={f.key} style={[styles.field, f.flex ? { flex: f.flex } : undefined]}>
|
||||
<Text style={[theme.typography.caption, styles.fieldLabel, { color: theme.colors.fgSecondary }]}>
|
||||
{f.label}
|
||||
</Text>
|
||||
<View style={styles.optionContainer}>
|
||||
{f.options.map(opt => {
|
||||
const isSelected = (values[f.key] ?? '') === opt.value;
|
||||
return (
|
||||
<Touchable
|
||||
key={opt.value}
|
||||
style={[
|
||||
styles.optionChip,
|
||||
{
|
||||
backgroundColor: isSelected ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
|
||||
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
|
||||
},
|
||||
]}
|
||||
onPress={() => updateFieldValue(f.key, opt.value)}
|
||||
>
|
||||
<View style={styles.optionTextWrap}>
|
||||
<Text style={[styles.dropdownValueText, { color: theme.colors.fgSecondary }]}>
|
||||
{opt.value}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.dropdownLabelText,
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={f.key} style={[styles.field, f.flex ? { flex: f.flex } : undefined]}>
|
||||
<Text style={[theme.typography.caption, styles.fieldLabel, { color: theme.colors.fgSecondary }]}>
|
||||
{f.label}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={values[f.key] ?? ''}
|
||||
onChangeText={(text) => updateFieldValue(f.key, text)}
|
||||
onFocus={() => setFocusedKey(f.key)}
|
||||
onBlur={() => setFocusedKey(null)}
|
||||
placeholder={f.placeholder}
|
||||
placeholderTextColor={theme.colors.fgSecondary}
|
||||
keyboardType={f.keyboardType ?? 'default'}
|
||||
multiline={f.multiline}
|
||||
secureTextEntry={f.secureTextEntry}
|
||||
style={[
|
||||
commonStyles.input,
|
||||
f.flex ? { height: 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>
|
||||
);
|
||||
};
|
||||
|
||||
// 分组具有 flex 且相同 row 的连续字段
|
||||
const fieldElements: React.ReactNode[] = [];
|
||||
let flexGroup: FormField[] = [];
|
||||
let currentGroupRow: number | undefined = undefined;
|
||||
|
||||
const flushFlexGroup = () => {
|
||||
if (flexGroup.length > 0) {
|
||||
fieldElements.push(
|
||||
<View key={`row-${flexGroup[0].key}`} style={styles.rowFieldGroup}>
|
||||
{flexGroup.map(renderField)}
|
||||
</View>
|
||||
);
|
||||
flexGroup = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const f of fields) {
|
||||
if (f.flex) {
|
||||
const fieldRow = f.row ?? 1;
|
||||
if (flexGroup.length > 0 && currentGroupRow !== fieldRow) {
|
||||
flushFlexGroup();
|
||||
}
|
||||
currentGroupRow = fieldRow;
|
||||
flexGroup.push(f);
|
||||
} else {
|
||||
flushFlexGroup();
|
||||
currentGroupRow = undefined;
|
||||
fieldElements.push(renderField(f));
|
||||
}
|
||||
}
|
||||
flushFlexGroup();
|
||||
|
||||
const activeField = fields.find(f => f.key === activeDropdownKey);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
|
||||
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onCancel}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.sheet,
|
||||
theme.shadows.lg,
|
||||
{
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 顶部 Drag Handle */}
|
||||
<View style={[styles.dragHandle, { backgroundColor: theme.colors.divider }]} />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{title}</Text>
|
||||
<Touchable onPress={onCancel} hitSlop={8} style={[styles.closeCircleBtn, { backgroundColor: theme.colors.bgTertiary }]}>
|
||||
<Ionicons name="close" size={16} color={theme.colors.fgSecondary} />
|
||||
</Touchable>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.body} showsVerticalScrollIndicator={false}>
|
||||
{fieldElements}
|
||||
</ScrollView>
|
||||
|
||||
{children}
|
||||
<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 */}
|
||||
<Modal visible={activeDropdownKey !== null} transparent animationType="fade" onRequestClose={() => setActiveDropdownKey(null)}>
|
||||
<Pressable style={[styles.menuOverlay, { backgroundColor: theme.colors.overlay }]} onPress={() => setActiveDropdownKey(null)}>
|
||||
<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, letterSpacing: 0.5, fontWeight: '600' }]}>
|
||||
{activeField?.label}
|
||||
</Text>
|
||||
{activeField?.options?.map(opt => {
|
||||
const isSelected = (values[activeField.key] ?? '') === 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={[
|
||||
styles.dropdownMenuItem,
|
||||
{
|
||||
backgroundColor: isSelected ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
|
||||
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
updateFieldValue(activeField.key, opt.value);
|
||||
setActiveDropdownKey(null);
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
{pathPrefix ? (
|
||||
<Text style={[styles.dropdownValueText, { color: theme.colors.fgSecondary, fontSize: 10, lineHeight: 12 }]}>
|
||||
{pathPrefix}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={[
|
||||
styles.dropdownLabelText,
|
||||
{
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
sheet: {
|
||||
maxHeight: '80%',
|
||||
padding: 20,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 36,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
},
|
||||
dragHandle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
alignSelf: 'center',
|
||||
marginBottom: 16,
|
||||
opacity: 0.8,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
closeCircleBtn: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
body: {
|
||||
maxHeight: 400,
|
||||
},
|
||||
field: {
|
||||
marginBottom: 14,
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.3,
|
||||
marginBottom: 6,
|
||||
},
|
||||
rowFieldGroup: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
dropdownTrigger: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 2,
|
||||
paddingHorizontal: 12,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
},
|
||||
dropdownTriggerContent: {
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dropdownValueText: {
|
||||
fontSize: 10,
|
||||
lineHeight: 12,
|
||||
},
|
||||
dropdownLabelText: {
|
||||
fontSize: 13,
|
||||
lineHeight: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
optionContainer: {
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
optionChip: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
optionTextWrap: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
optionSubLabel: {
|
||||
fontSize: 11,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
marginTop: 12,
|
||||
},
|
||||
menuOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
dropdownMenu: {
|
||||
width: '100%',
|
||||
maxWidth: 320,
|
||||
borderRadius: 20,
|
||||
padding: 18,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
gap: 10,
|
||||
},
|
||||
dropdownMenuItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
},
|
||||
});
|
||||
@ -1,47 +0,0 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Alert, Modal, Pressable, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { NumpadSheet } from './NumpadSheet';
|
||||
import { useNumpadUiStore } from '../store/numpadUiStore';
|
||||
|
||||
/** 全局记账面板宿主:挂载于根布局,任意页面可唤起(不丢上下文)。 */
|
||||
export function NumpadSheetHost() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const visible = useNumpadUiStore(s => s.visible);
|
||||
const prefill = useNumpadUiStore(s => s.prefill);
|
||||
const close = useNumpadUiStore(s => s.close);
|
||||
// 表单脏状态(NumpadSheet 每次渲染后上报),遮罩/返回键关闭前据此弹未保存确认
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
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 },
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={requestClose}>
|
||||
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={requestClose}>
|
||||
<Pressable style={styles.sheetWrap} onPress={e => e.stopPropagation()}>
|
||||
<NumpadSheet
|
||||
presentation="modal"
|
||||
editId={prefill?.editId}
|
||||
draftJson={prefill?.draftJson}
|
||||
autoOcr={prefill?.autoOcr}
|
||||
onClose={close}
|
||||
onDirtyChange={d => { dirtyRef.current = d; }}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: { flex: 1, justifyContent: 'flex-end' },
|
||||
sheetWrap: { maxHeight: '94%' },
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import { FormModal, type FormField } from './FormModal';
|
||||
import { useT } from '../../i18n';
|
||||
import { FormModal, type FormField } from '../form/FormModal';
|
||||
|
||||
export interface AccountCreateModalProps {
|
||||
visible: boolean;
|
||||
@ -1,8 +1,8 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { calculateAccountTotalBalance, type AccountNode } from '../domain/accountTree';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { calculateAccountTotalBalance, type AccountNode } from '../../domain/taxonomy/accountTree';
|
||||
|
||||
interface AccountTreeProps {
|
||||
nodes: AccountNode[];
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { getCategoryColor } from '../theme/palette';
|
||||
import { getCategoryColor } from '../../theme/palette';
|
||||
import { getCategoryIcon } from './categoryIcons';
|
||||
|
||||
interface CategoryIconProps {
|
||||
@ -1,10 +1,10 @@
|
||||
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/categories';
|
||||
import { useTheme } from '../../theme';
|
||||
import { getCategoryColor } from '../../theme/palette';
|
||||
import type { Category } from '../../domain/taxonomy/categories';
|
||||
import { CategoryIcon } from './CategoryIcon';
|
||||
import { Touchable } from './Touchable';
|
||||
import { Touchable } from '../ui/Touchable';
|
||||
|
||||
interface CategoryPickerProps {
|
||||
categories: Category[];
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme, createCommonStyles } from '../theme';
|
||||
import type { Tag } from '../domain/tags';
|
||||
import { useTheme, createCommonStyles } from '../../theme';
|
||||
import type { Tag } from '../../domain/taxonomy/tags';
|
||||
|
||||
interface TagPickerProps {
|
||||
tags: Tag[];
|
||||
@ -6,8 +6,8 @@ import React, { useMemo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { generateAnnualReport } from '../../domain/annualReport';
|
||||
import type { Transaction } from '../../domain/types';
|
||||
import { generateAnnualReport } from '../../domain/stats/annualReport';
|
||||
import type { Transaction } from '../../domain/core/types';
|
||||
|
||||
export function AnnualReport({ transactions, year }: { transactions: Transaction[]; year: number }) {
|
||||
const { theme } = useTheme();
|
||||
@ -21,7 +21,7 @@ export function AnnualReport({ transactions, year }: { transactions: Transaction
|
||||
|
||||
return (
|
||||
<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>
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
@ -43,7 +43,7 @@ export function AnnualReport({ transactions, year }: { transactions: Transaction
|
||||
</View>
|
||||
|
||||
{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>
|
||||
{report.topCategories.slice(0, 5).map(c => (
|
||||
<View key={c.category} style={[styles.catRow, { borderTopColor: theme.colors.progressBg }]}>
|
||||
@ -54,17 +54,18 @@ export function AnnualReport({ transactions, year }: { transactions: Transaction
|
||||
</View>
|
||||
)}
|
||||
|
||||
<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.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}>
|
||||
<View style={[styles.rhythmTrack, { backgroundColor: theme.colors.bgTertiary }]}>
|
||||
<View style={[styles.rhythmFill, { height: `${Math.max(ratio * 100, 2)}%`, backgroundColor: theme.colors.financial.expense }]} />
|
||||
{/* 节奏条圆角使用 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={[styles.rhythmLabel, { color: theme.colors.fgSecondary }]}>{Number(m.month.slice(5))}</Text>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: theme.typography.caption.fontSize - 3 }]}>{Number(m.month.slice(5))}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
@ -82,7 +83,6 @@ const styles = StyleSheet.create({
|
||||
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%', borderRadius: 4, justifyContent: 'flex-end', overflow: 'hidden' },
|
||||
rhythmFill: { width: '100%', borderRadius: 4 },
|
||||
rhythmLabel: { fontSize: 9 },
|
||||
});
|
||||
rhythmTrack: { flex: 1, width: '100%', justifyContent: 'flex-end', overflow: 'hidden' },
|
||||
rhythmFill: { width: '100%' },
|
||||
});
|
||||
@ -1,73 +0,0 @@
|
||||
/**
|
||||
* 日历热力图(plan.md「5.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) };
|
||||
}
|
||||
|
||||
export function CalendarHeatmap({ transactions, year, month }: { transactions: Transaction[]; year: number; month: number }) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
// 星期头周日起(与 getMonthGrid 网格一致),文案走 i18n
|
||||
const weekdays = useMemo(() => t('calendar.weekdays').split(','), [t]);
|
||||
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 },
|
||||
});
|
||||
@ -6,8 +6,8 @@ import React, { useMemo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { groupByCategory } from '../../domain/chartStats';
|
||||
import type { Transaction } from '../../domain/types';
|
||||
import { groupByCategory } from '../../domain/stats/chartStats';
|
||||
import type { Transaction } from '../../domain/core/types';
|
||||
|
||||
export function CategoryPie({ transactions }: { transactions: Transaction[] }) {
|
||||
const { theme } = useTheme();
|
||||
@ -24,15 +24,16 @@ export function CategoryPie({ transactions }: { transactions: Transaction[] }) {
|
||||
{data.map(d => (
|
||||
<View key={d.category} style={styles.row}>
|
||||
<Text
|
||||
style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1, fontWeight: '600' }]}
|
||||
style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1, maxWidth: '38%', fontWeight: '600' }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{d.category.replace('Expenses:', '').replace('Income:', '')}
|
||||
</Text>
|
||||
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, borderWidth: StyleSheet.hairlineWidth }]}>
|
||||
<View style={[styles.bar, { width: `${(d.amount / maxAmount) * 100}%`, backgroundColor: theme.colors.accent }]} />
|
||||
{/* 进度条圆角使用 theme.radii.sm / 2,避免硬编码 */}
|
||||
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, borderWidth: StyleSheet.hairlineWidth, borderRadius: theme.radii.sm / 2 }]}>
|
||||
<View style={[styles.bar, { width: `${(d.amount / maxAmount) * 100}%`, backgroundColor: theme.colors.accent, borderRadius: theme.radii.sm / 2 }]} />
|
||||
</View>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 55, textAlign: 'right', fontVariant: ['tabular-nums'], fontSize: 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}%
|
||||
</Text>
|
||||
</View>
|
||||
@ -51,6 +52,6 @@ const styles = StyleSheet.create({
|
||||
container: { gap: 10 },
|
||||
list: { gap: 8 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||
barWrap: { width: 120, height: 8, borderRadius: 4, overflow: 'hidden' },
|
||||
bar: { height: '100%', borderRadius: 4 },
|
||||
});
|
||||
barWrap: { flex: 1, height: 8, overflow: 'hidden' },
|
||||
bar: { height: '100%' },
|
||||
});
|
||||
@ -1,32 +1,38 @@
|
||||
/**
|
||||
* 净资产趋势图(plan.md「5.2 净资产趋势」)。
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { Animated, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { calculateNetWorthTrend } from '../../domain/netWorth';
|
||||
import type { Transaction } from '../../domain/types';
|
||||
import { calculateNetWorthTrend } from '../../domain/stats/netWorth';
|
||||
import type { Transaction } from '../../domain/core/types';
|
||||
|
||||
export function NetWorthChart({ transactions, dates }: { transactions: Transaction[]; dates: string[] }) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const points = useMemo(() => calculateNetWorthTrend(transactions, dates), [transactions, dates]);
|
||||
const maxAbs = Math.max(...points.map(p => Math.abs(parseFloat(p.netWorth))), 1);
|
||||
const progress = useRef(new Animated.Value(0)).current;
|
||||
useEffect(() => {
|
||||
Animated.timing(progress, { toValue: 1, duration: 600, useNativeDriver: false }).start();
|
||||
}, [progress]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.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>
|
||||
<View style={styles.chart}>
|
||||
{points.map(p => (
|
||||
<View key={p.date} style={styles.col}>
|
||||
<View style={styles.barWrap}>
|
||||
<View style={[styles.bar, {
|
||||
height: `${(Math.abs(parseFloat(p.netWorth)) / maxAbs) * 100}%`,
|
||||
{/* 柱状条圆角使用 theme.radii.sm / 2,避免硬编码 */}
|
||||
<Animated.View style={[styles.bar, {
|
||||
height: progress.interpolate({ inputRange: [0, 1], outputRange: [0, (Math.abs(parseFloat(p.netWorth)) / maxAbs) * 100] }),
|
||||
backgroundColor: parseFloat(p.netWorth) >= 0 ? theme.colors.accent : theme.colors.error,
|
||||
borderRadius: theme.radii.sm / 2,
|
||||
}]} />
|
||||
</View>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: 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>
|
||||
@ -45,5 +51,5 @@ const styles = StyleSheet.create({
|
||||
chart: { flexDirection: 'row', alignItems: 'flex-end', height: 120, gap: 4 },
|
||||
col: { flex: 1, alignItems: 'center', gap: 4 },
|
||||
barWrap: { height: 100, justifyContent: 'flex-end', width: '100%', alignItems: 'center' },
|
||||
bar: { width: 12, borderRadius: 3 },
|
||||
});
|
||||
bar: { width: 12 },
|
||||
});
|
||||
@ -2,21 +2,25 @@
|
||||
* 趋势折线图(plan.md「5.2 图表与可视化」)。
|
||||
* 采用 react-native-svg 绘制三次贝塞尔曲线及渐变填充区域。
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Animated, Dimensions, StyleSheet, Text, View } from 'react-native';
|
||||
import Svg, { Path, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import type { Transaction } from '../../domain/types';
|
||||
import { groupByMonth } from '../../domain/chartStats';
|
||||
import type { Transaction } from '../../domain/core/types';
|
||||
import { groupByMonth } from '../../domain/stats/chartStats';
|
||||
|
||||
export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const fade = useRef(new Animated.Value(0)).current;
|
||||
useEffect(() => {
|
||||
Animated.timing(fade, { toValue: 1, duration: 500, useNativeDriver: true }).start();
|
||||
}, [fade]);
|
||||
const data = useMemo(() => groupByMonth(transactions).slice(-6), [transactions]);
|
||||
|
||||
// 1. 数据配置
|
||||
const width = 300;
|
||||
// 1. 数据配置 —— 自适应宽度:初始取屏幕宽减去页面 padding,onLayout 后更新为容器实际宽度
|
||||
const [chartWidth, setChartWidth] = useState(() => Dimensions.get('window').width - 64);
|
||||
const height = 120;
|
||||
const paddingX = 20;
|
||||
const paddingY = 15;
|
||||
@ -26,16 +30,16 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
||||
// 2. 坐标转换计算
|
||||
const pointsIncome = data.map((d, i) => {
|
||||
const x = data.length > 1
|
||||
? paddingX + (i * (width - 2 * paddingX)) / (data.length - 1)
|
||||
: width / 2;
|
||||
? paddingX + (i * (chartWidth - 2 * paddingX)) / (data.length - 1)
|
||||
: chartWidth / 2;
|
||||
const y = height - paddingY - (d.income / maxVal) * (height - 2 * paddingY);
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const pointsExpense = data.map((d, i) => {
|
||||
const x = data.length > 1
|
||||
? paddingX + (i * (width - 2 * paddingX)) / (data.length - 1)
|
||||
: width / 2;
|
||||
? paddingX + (i * (chartWidth - 2 * paddingX)) / (data.length - 1)
|
||||
: chartWidth / 2;
|
||||
const y = height - paddingY - (d.expense / maxVal) * (height - 2 * paddingY);
|
||||
return { x, y };
|
||||
});
|
||||
@ -72,14 +76,14 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
||||
const expenseClosedPath = getClosedBezierPath(pointsExpense);
|
||||
|
||||
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 }]}>
|
||||
{t('report.trendTitle')}
|
||||
</Text>
|
||||
|
||||
{data.length > 0 ? (
|
||||
<View style={styles.chartWrapper}>
|
||||
<Svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||
<View style={styles.chartWrapper} onLayout={(e) => setChartWidth(e.nativeEvent.layout.width)}>
|
||||
<Svg width="100%" height={height} viewBox={`0 0 ${chartWidth} ${height}`}>
|
||||
<Defs>
|
||||
<LinearGradient id="incomeGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="0%" stopColor={theme.colors.financial.income} stopOpacity={0.25} />
|
||||
@ -93,7 +97,7 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
||||
|
||||
{/* 网格线(只绘制一条底线和中线) */}
|
||||
<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}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 4"
|
||||
@ -153,7 +157,7 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
||||
{data.map((d, i) => (
|
||||
<Text
|
||||
key={`x-label-${i}`}
|
||||
style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: 10 }]}
|
||||
style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: theme.typography.caption.fontSize - 2 }]}
|
||||
>
|
||||
{d.month.slice(5)}
|
||||
</Text>
|
||||
@ -176,7 +180,7 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
||||
{t('home.expense')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
@ -186,4 +190,4 @@ const styles = StyleSheet.create({
|
||||
xAxis: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 12, marginTop: 4 },
|
||||
legend: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 },
|
||||
legendDot: { width: 8, height: 8, borderRadius: 4, marginLeft: 8 },
|
||||
});
|
||||
});
|
||||
@ -1,10 +1,10 @@
|
||||
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 './BottomSheet';
|
||||
import { buildMonthGrid } from './dateGrid';
|
||||
import { useTheme, createCommonStyles } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { BottomSheet } from '../ui/BottomSheet';
|
||||
import { buildMonthGrid } from '../stats/dateGrid';
|
||||
|
||||
interface DatePickerFieldProps {
|
||||
/** YYYY-MM-DD,空串表示未选。 */
|
||||
@ -4,11 +4,11 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { useTheme, createCommonStyles } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { BottomSheet } from './BottomSheet';
|
||||
import { useTheme, createCommonStyles } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { BottomSheet } from '../ui/BottomSheet';
|
||||
import { DatePickerField } from './DatePickerField';
|
||||
import { Button } from './Button';
|
||||
import { Button } from '../ui/Button';
|
||||
|
||||
export interface AdvancedFilterValues {
|
||||
account: string;
|
||||
249
src/components/form/FormFields.tsx
Normal file
249
src/components/form/FormFields.tsx
Normal 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 },
|
||||
});
|
||||
261
src/components/form/FormModal.tsx
Normal file
261
src/components/form/FormModal.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
/**
|
||||
* 通用模态表单(plan.md 管理页 CRUD)。
|
||||
*
|
||||
* 底部弹出的模态对话框,包含多个文本输入字段 + 确认/取消按钮。
|
||||
* 供分类/标签/预算/信用卡/规则管理页的「添加/编辑」复用。
|
||||
*
|
||||
* 拆分说明:
|
||||
* - 字段渲染拆分为 TextField / SelectField / DropdownField 子组件(见 formFields.tsx)
|
||||
* - 下拉菜单不再使用嵌套 Modal,改为同层 overlay,避免 Android z-index/返回键冲突
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme, createCommonStyles } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { useKeyboardHeight } from '../../hooks/useKeyboardAvoiding';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Touchable } from '../ui/Touchable';
|
||||
import { TextField, SelectField, DropdownField, DropdownMenu } from './FormFields';
|
||||
|
||||
export interface FormOption {
|
||||
label: string;
|
||||
value: string;
|
||||
subLabel?: string;
|
||||
}
|
||||
|
||||
export interface FormField {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
keyboardType?: 'default' | 'numeric' | 'decimal-pad' | 'phone-pad';
|
||||
multiline?: boolean;
|
||||
secureTextEntry?: boolean;
|
||||
type?: 'text' | 'select' | 'dropdown';
|
||||
options?: FormOption[];
|
||||
flex?: number;
|
||||
row?: number;
|
||||
}
|
||||
|
||||
interface FormModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
fields: FormField[];
|
||||
onConfirm: (values: Record<string, string>) => void;
|
||||
onCancel: () => void;
|
||||
confirmLabel?: string;
|
||||
onValuesChange?: (changedKey: string, newValue: string, prevValues: Record<string, string>) => Record<string, string> | void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 外层:Modal + SafeAreaProvider 包裹。
|
||||
* 关键:RN <Modal> 会脱离主窗口的 SafeAreaProvider context,导致内部
|
||||
* useSafeAreaInsets() 返回全 0、KeyboardAvoidingView 失效。
|
||||
* 故在 Modal 内重新包一层 SafeAreaProvider,让内层弹窗恢复 SafeArea 与键盘避让能力。
|
||||
*/
|
||||
export function FormModal(props: FormModalProps) {
|
||||
return (
|
||||
<Modal visible={props.visible} transparent animationType="slide" onRequestClose={props.onCancel}>
|
||||
<SafeAreaProvider>
|
||||
<FormModalContent {...props} />
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function FormModalContent({ visible, title, fields, onConfirm, onCancel, confirmLabel, onValuesChange, children }: FormModalProps) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomInset = insets.bottom;
|
||||
// 响应式键盘避让:键盘高度做 paddingBottom(参考 BeeCount AnimatedPadding 方案)。
|
||||
// 键盘弹出 → paddingBottom 增大顶起内容;关闭 → 回到基础留白,无残留。
|
||||
const kbHeight = useKeyboardHeight();
|
||||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||
/* eslint-disable react-native/no-unused-styles */
|
||||
const styles = useMemo(() => StyleSheet.create({
|
||||
overlay: { flex: 1, justifyContent: 'flex-end' },
|
||||
// paddingBottom 取「安全区」与「最小视觉留白 24」的较大值,二者不叠加
|
||||
// (安全区已包含手势条区域,叠加 36 会导致底部空白过大)
|
||||
sheet: { maxHeight: '80%', padding: 20, paddingTop: 12, paddingBottom: Math.max(bottomInset, 24), borderTopLeftRadius: theme.radii.xl, borderTopRightRadius: theme.radii.xl },
|
||||
dragHandle: { width: 36, height: 4, borderRadius: 2, alignSelf: 'center', marginBottom: theme.spacing.md, opacity: 0.8 },
|
||||
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: theme.spacing.md },
|
||||
closeCircleBtn: { width: 32, height: 32, borderRadius: theme.radii.full, alignItems: 'center', justifyContent: 'center' },
|
||||
body: { maxHeight: 400 },
|
||||
rowFieldGroup: { flexDirection: 'row', gap: 12 },
|
||||
actions: { flexDirection: 'row', marginTop: theme.spacing.md },
|
||||
// 下拉菜单同层 overlay(替代嵌套 Modal)
|
||||
menuOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center', padding: 24 },
|
||||
dropdownMenu: { width: '100%', maxHeight: 320, borderRadius: theme.radii.lg, borderWidth: 1, overflow: 'hidden' },
|
||||
}), [theme, bottomInset]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
/* eslint-enable react-native/no-unused-styles */
|
||||
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [activeDropdownKey, setActiveDropdownKey] = useState<string | null>(null);
|
||||
const [focusedKey, setFocusedKey] = useState<string | null>(null);
|
||||
|
||||
const updateFieldValue = (key: string, val: string) => {
|
||||
setValues(prev => {
|
||||
const next = { ...prev, [key]: val };
|
||||
if (onValuesChange) {
|
||||
const overridden = onValuesChange(key, val, next);
|
||||
return overridden ? { ...next, ...overridden } : next;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const init: Record<string, string> = {};
|
||||
for (const f of fields) {
|
||||
init[f.key] = f.defaultValue ?? '';
|
||||
}
|
||||
setValues(init);
|
||||
setActiveDropdownKey(null);
|
||||
setFocusedKey(null);
|
||||
}
|
||||
}, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 渲染单个字段(委托给子组件)
|
||||
const renderField = (f: FormField) => {
|
||||
const isFocused = focusedKey === f.key || activeDropdownKey === f.key;
|
||||
|
||||
if (f.type === 'dropdown' && f.options) {
|
||||
return (
|
||||
<DropdownField
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={values[f.key] ?? ''}
|
||||
isFocused={isFocused}
|
||||
onOpen={() => setActiveDropdownKey(f.key)}
|
||||
theme={theme}
|
||||
commonStyles={commonStyles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (f.type === 'select' && f.options) {
|
||||
return (
|
||||
<SelectField
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={values[f.key] ?? ''}
|
||||
onSelect={(v) => updateFieldValue(f.key, v)}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={values[f.key] ?? ''}
|
||||
onChangeText={(text) => updateFieldValue(f.key, text)}
|
||||
isFocused={isFocused}
|
||||
onFocus={() => setFocusedKey(f.key)}
|
||||
onBlur={() => setFocusedKey(null)}
|
||||
theme={theme}
|
||||
commonStyles={commonStyles}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 分组具有 flex 且相同 row 的连续字段
|
||||
const fieldElements: React.ReactNode[] = [];
|
||||
let flexGroup: FormField[] = [];
|
||||
let currentGroupRow: number | undefined = undefined;
|
||||
|
||||
const flushFlexGroup = () => {
|
||||
if (flexGroup.length > 0) {
|
||||
fieldElements.push(
|
||||
<View key={'row-' + flexGroup[0].key} style={styles.rowFieldGroup}>
|
||||
{flexGroup.map(renderField)}
|
||||
</View>
|
||||
);
|
||||
flexGroup = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const f of fields) {
|
||||
if (f.flex) {
|
||||
const fieldRow = f.row ?? 1;
|
||||
if (flexGroup.length > 0 && currentGroupRow !== fieldRow) {
|
||||
flushFlexGroup();
|
||||
}
|
||||
currentGroupRow = fieldRow;
|
||||
flexGroup.push(f);
|
||||
} else {
|
||||
flushFlexGroup();
|
||||
currentGroupRow = undefined;
|
||||
fieldElements.push(renderField(f));
|
||||
}
|
||||
}
|
||||
flushFlexGroup();
|
||||
|
||||
const activeField = fields.find(f => f.key === activeDropdownKey);
|
||||
|
||||
return (
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onCancel}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.sheet,
|
||||
theme.shadows.lg,
|
||||
{
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderColor: theme.colors.border,
|
||||
// 响应式 paddingBottom:基础留白(取安全区与24较大值) + 键盘高度
|
||||
paddingBottom: Math.max(bottomInset, 24) + kbHeight,
|
||||
},
|
||||
]}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 顶部 Drag Handle */}
|
||||
<View style={[styles.dragHandle, { backgroundColor: theme.colors.divider }]} />
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{title}</Text>
|
||||
<Touchable onPress={onCancel} hitSlop={8} style={[styles.closeCircleBtn, { backgroundColor: theme.colors.bgTertiary }]}>
|
||||
<Ionicons name="close" size={16} color={theme.colors.fgSecondary} />
|
||||
</Touchable>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.body} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive">
|
||||
{fieldElements}
|
||||
</ScrollView>
|
||||
|
||||
{children}
|
||||
<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>
|
||||
|
||||
{/* 下拉菜单同层 overlay(替代嵌套 Modal,避免 Android z-index/返回键冲突) */}
|
||||
{activeDropdownKey !== null && activeField && (
|
||||
<Pressable style={[styles.menuOverlay, { backgroundColor: theme.colors.overlay }]} onPress={() => setActiveDropdownKey(null)}>
|
||||
<Pressable
|
||||
style={[styles.dropdownMenu, theme.shadows.lg, { backgroundColor: theme.colors.bgSecondary, borderColor: theme.colors.border }]}
|
||||
onPress={e => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu
|
||||
field={activeField}
|
||||
value={values[activeField.key] ?? ''}
|
||||
onSelect={(v) => updateFieldValue(activeField.key, v)}
|
||||
onClose={() => setActiveDropdownKey(null)}
|
||||
theme={theme}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../theme';
|
||||
import { Touchable } from './Touchable';
|
||||
import { useTheme } from '../../theme';
|
||||
import { Touchable } from '../ui/Touchable';
|
||||
|
||||
/** 键值:数字/小数点/加减走 numpadExpression;today/done 由宿主处理。 */
|
||||
export type KeyboardKey = string;
|
||||
@ -25,6 +25,18 @@ const ROWS: KeyboardKey[][] = [
|
||||
/** 内嵌数字键盘(4×4):金额优先,+/- 连续计算,「今天」快速设日期。带有按压微缩放与变色触控反馈。 */
|
||||
export function NumpadKeyboard({ onKey, todayLabel, doneLabel, backspaceLabel }: NumpadKeyboardProps) {
|
||||
const { theme } = useTheme();
|
||||
// 主题化样式:在组件内创建以引用 theme tokens(数字键圆角 radii.sm、数字 h2、运算/完成 body)
|
||||
/* eslint-disable react-native/no-unused-styles */
|
||||
const styles = useMemo(() => StyleSheet.create({
|
||||
grid: { gap: 2 },
|
||||
row: { flexDirection: 'row' },
|
||||
key: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 14, borderRadius: theme.radii.sm },
|
||||
digitText: { fontSize: theme.typography.h2.fontSize, fontWeight: '600', fontVariant: ['tabular-nums'] },
|
||||
opText: { fontSize: theme.typography.body.fontSize, fontWeight: '600' },
|
||||
doneKey: { margin: 4 },
|
||||
doneText: { fontSize: theme.typography.body.fontSize, fontWeight: '700' },
|
||||
}), [theme]);
|
||||
/* eslint-enable react-native/no-unused-styles */
|
||||
|
||||
const renderKey = (key: KeyboardKey) => {
|
||||
if (key === 'done') {
|
||||
@ -127,12 +139,4 @@ export function NumpadKeyboard({ onKey, todayLabel, doneLabel, backspaceLabel }:
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
grid: { gap: 2 },
|
||||
row: { flexDirection: 'row' },
|
||||
key: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 14, borderRadius: 8 },
|
||||
digitText: { fontSize: 22, fontWeight: '600', fontVariant: ['tabular-nums'] },
|
||||
opText: { fontSize: 16, fontWeight: '600' },
|
||||
doneKey: { margin: 4 },
|
||||
doneText: { fontSize: 15, fontWeight: '700' },
|
||||
});
|
||||
|
||||
@ -15,33 +15,33 @@ import {
|
||||
} from 'react-native';
|
||||
import { useNavigation, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme, createCommonStyles } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { useLedgerStore } from '../store/ledgerStore';
|
||||
import { useMetadataStore } from '../store/metadataStore';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { TAG_COLORS } from '../theme/palette';
|
||||
import { AccountCreateModal } from './AccountCreateModal';
|
||||
import { BottomSheet } from './BottomSheet';
|
||||
import { Button } from './Button';
|
||||
import { CategoryIcon } from './CategoryIcon';
|
||||
import { CategoryPicker } from './CategoryPicker';
|
||||
import { useTheme, createCommonStyles } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useMetadataStore } from '../../store/metadataStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { TAG_COLORS } from '../../theme/palette';
|
||||
import { AccountCreateModal } from '../account/AccountCreateModal';
|
||||
import { BottomSheet } from '../ui/BottomSheet';
|
||||
import { Button } from '../ui/Button';
|
||||
import { CategoryIcon } from '../category/CategoryIcon';
|
||||
import { CategoryPicker } from '../category/CategoryPicker';
|
||||
import { DatePickerField } from './DatePickerField';
|
||||
import { FormModal, type FormField } from './FormModal';
|
||||
import { NumpadKeyboard } from './NumpadKeyboard';
|
||||
import { PostingEditor } from './PostingEditor';
|
||||
import { TagPicker } from './TagPicker';
|
||||
import { PostingEditor } from '../transaction/PostingEditor';
|
||||
import { TagPicker } from '../category/TagPicker';
|
||||
import { pressKey, evaluateExpression, type NumpadKey } from './numpadExpression';
|
||||
import { useOcrScan } from './useOcrScan';
|
||||
import { buildAndSaveTransaction, buildPostings } from '../domain/transactionBuilder';
|
||||
import { parsePostingsToLegs } from '../domain/postingLegs';
|
||||
import { serializeTransaction } from '../domain/ledger';
|
||||
import { addDecimals, compareDecimals, toDateString } from '../domain/decimal';
|
||||
import { matchCategory } from '../domain/categories';
|
||||
import type { Category } from '../domain/categories';
|
||||
import type { Tag } from '../domain/tags';
|
||||
import type { Posting } from '../domain/types';
|
||||
import { logger } from '../utils/logger';
|
||||
import { useOcrScan } from '../../hooks/useOcrScan';
|
||||
import { buildAndSaveTransaction, buildPostings } from '../../domain/transaction/transactionBuilder';
|
||||
import { parsePostingsToLegs } from '../../domain/transaction/postingLegs';
|
||||
import { serializeTransaction } from '../../domain/core/ledger';
|
||||
import { addDecimals, compareDecimals, toDateString } from '../../domain/core/decimal';
|
||||
import { useBottomInset } from '../../hooks/useBottomInset';
|
||||
import { matchCategory } from '../../domain/taxonomy/categories';
|
||||
import type { Category } from '../../domain/taxonomy/categories';
|
||||
import type { Tag } from '../../domain/taxonomy/tags';
|
||||
import type { Posting } from '../../domain/core/types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
type Direction = 'expense' | 'income' | 'transfer';
|
||||
|
||||
@ -58,6 +58,7 @@ interface NumpadSheetProps {
|
||||
export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr, onClose, onDirtyChange }: NumpadSheetProps) {
|
||||
const { theme } = useTheme();
|
||||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||
const bottomInset = useBottomInset(0);
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
const navigation = useNavigation();
|
||||
@ -104,7 +105,7 @@ export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr
|
||||
.split(':')
|
||||
.map((seg, idx) => {
|
||||
if (idx === 0) return seg;
|
||||
let cleaned = seg.replace(/[\(\)()\[\]]/g, '-');
|
||||
let cleaned = seg.replace(/[()()[\]]/g, '-');
|
||||
cleaned = cleaned.replace(/-+/g, '-');
|
||||
cleaned = cleaned.replace(/^-|-$/g, '');
|
||||
cleaned = cleaned.replace(/[^\w\u4e00-\u9fa5-]/g, '');
|
||||
@ -200,7 +201,7 @@ export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr
|
||||
direction: legs?.direction || 'expense',
|
||||
};
|
||||
if (Array.isArray(draft.postings) && draft.postings.length > 0) {
|
||||
setPostings(draft.postings.map((p: any) => ({
|
||||
setPostings((draft.postings as Posting[]).map((p) => ({
|
||||
account: p.account, amount: p.amount, currency: p.currency,
|
||||
cost: p.cost, price: p.price, metadata: p.metadata,
|
||||
})));
|
||||
@ -268,7 +269,7 @@ export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr
|
||||
}, [editingTx, categories, tags]);
|
||||
|
||||
// ===== OCR =====
|
||||
const applyOcrEvent = (event: import('./useOcrScan').OcrEvent) => {
|
||||
const applyOcrEvent = (event: import('../../hooks/useOcrScan').OcrEvent) => {
|
||||
// 金额/摘要仅在当前为空时填充,避免覆盖用户已输入内容
|
||||
if (event.amount) setExpr(prev => prev || event.amount!.replace(/^-/, ''));
|
||||
if (event.direction === 'income' || event.direction === 'transfer') setDirection(event.direction);
|
||||
@ -312,7 +313,7 @@ export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr
|
||||
payee: draft.payee || '',
|
||||
tags: Array.isArray(draft.tags) ? draft.tags : [],
|
||||
};
|
||||
} catch {}
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
}, [editingTx, draftJson]);
|
||||
|
||||
@ -356,7 +357,7 @@ export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr
|
||||
]);
|
||||
});
|
||||
return unsubscribe;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
}, [navigation, presentation, t]);
|
||||
|
||||
// ===== 键盘 =====
|
||||
@ -442,7 +443,7 @@ export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr
|
||||
}
|
||||
const categoryAccount = direction === 'transfer'
|
||||
? targetAccount
|
||||
: selectedCategory?.linkedAccount ?? (direction === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized');
|
||||
: selectedCategory?.linkedAccount ?? (direction === 'income' ? 'Income:未分类' : 'Expenses:未分类');
|
||||
const defaultNarration =
|
||||
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
|
||||
direction === 'income' ? t('transaction.narrationDefaultIncome') :
|
||||
@ -521,7 +522,7 @@ export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr
|
||||
const amt = amount;
|
||||
const categoryAccount = direction === 'transfer'
|
||||
? targetAccount
|
||||
: selectedCategory?.linkedAccount ?? (direction === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized');
|
||||
: selectedCategory?.linkedAccount ?? (direction === 'income' ? 'Income:未分类' : 'Expenses:未分类');
|
||||
setPostings(buildPostings(direction, sourceAccount, categoryAccount, amt || '0', currency)
|
||||
.map(p => ({ ...p, amount: p.amount === '-0' || p.amount === '0' ? '' : p.amount })));
|
||||
}
|
||||
@ -543,7 +544,16 @@ export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr
|
||||
<Text style={[theme.typography.caption, styles.accountLabel, { color: theme.colors.fgSecondary }]}>{label}</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.accountChips}>
|
||||
{assetAccounts.length === 0 ? (
|
||||
<Pressable onPress={() => router.push('/account')} style={commonStyles.chip}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
// 复用「⋯更多→新建账户」的快捷创建流程(AccountCreateModal),
|
||||
// 避免整页路由跳转导致表单状态丢失。setAccountPicker 让
|
||||
// handleQuickAddAccount 知道新建后回填到 source/target 哪条腿。
|
||||
setAccountPicker(pickerKey);
|
||||
setIsQuickAddingAccount(true);
|
||||
}}
|
||||
style={commonStyles.chip}
|
||||
>
|
||||
<Text style={[commonStyles.chipText, { color: theme.colors.accent }]}>{t('numpad.noAccount')}</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
@ -570,7 +580,7 @@ export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr
|
||||
);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={presentation === 'page' ? styles.flex1 : undefined}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={presentation === 'page' ? styles.flex1 : undefined}>
|
||||
<View style={[
|
||||
styles.sheet,
|
||||
presentation === 'modal' && {
|
||||
@ -580,7 +590,7 @@ export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr
|
||||
},
|
||||
presentation === 'page' && { backgroundColor: theme.colors.bgPrimary },
|
||||
]}>
|
||||
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={styles.content}>
|
||||
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={[styles.content, { paddingBottom: Math.max(bottomInset, 24) }]}>
|
||||
{/* 工具行 */}
|
||||
<View style={styles.toolRow}>
|
||||
{presentation === 'modal' ? (
|
||||
69
src/components/form/NumpadSheetHost.tsx
Normal file
69
src/components/form/NumpadSheetHost.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Alert, Modal, Pressable, StyleSheet } from 'react-native';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { NumpadSheet } from './NumpadSheet';
|
||||
import { useNumpadUiStore } from '../../store/numpadUiStore';
|
||||
|
||||
/** 全局记账面板宿主:挂载于根布局,任意页面可唤起(不丢上下文)。 */
|
||||
export function NumpadSheetHost() {
|
||||
const visible = useNumpadUiStore(s => s.visible);
|
||||
// 通过 ref 共享 Inner 内的关闭逻辑,让 Modal 的 onRequestClose 也能触发未保存守卫
|
||||
const requestCloseRef = useRef<() => void>(() => {});
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => requestCloseRef.current()}
|
||||
>
|
||||
<SafeAreaProvider>
|
||||
<NumpadSheetHostInner requestCloseRef={requestCloseRef} />
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface NumpadSheetHostInnerProps {
|
||||
requestCloseRef: React.MutableRefObject<() => void>;
|
||||
}
|
||||
|
||||
function NumpadSheetHostInner({ requestCloseRef }: NumpadSheetHostInnerProps) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const prefill = useNumpadUiStore(s => s.prefill);
|
||||
const close = useNumpadUiStore(s => s.close);
|
||||
// 表单脏状态(NumpadSheet 每次渲染后上报),遮罩/返回键关闭前据此弹未保存确认
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
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 },
|
||||
]);
|
||||
};
|
||||
// 把关闭逻辑暴露给外层 Modal 的 onRequestClose(Android 返回键)
|
||||
requestCloseRef.current = requestClose;
|
||||
|
||||
return (
|
||||
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={requestClose}>
|
||||
<Pressable style={styles.sheetWrap} onPress={e => e.stopPropagation()}>
|
||||
<NumpadSheet
|
||||
presentation="modal"
|
||||
editId={prefill?.editId}
|
||||
draftJson={prefill?.draftJson}
|
||||
autoOcr={prefill?.autoOcr}
|
||||
onClose={close}
|
||||
onDirtyChange={d => { dirtyRef.current = d; }}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: { flex: 1, justifyContent: 'flex-end' },
|
||||
sheetWrap: { maxHeight: '94%' },
|
||||
});
|
||||
@ -2,7 +2,7 @@
|
||||
* 数字键盘表达式编辑与求值(+/- 连续记账,如 20+15 直接出 35)。
|
||||
* 金额运算走 domain/decimal 的字符串十进制,无浮点误差。
|
||||
*/
|
||||
import { addDecimals, subtractDecimals } from '../domain/decimal';
|
||||
import { addDecimals, subtractDecimals } from '../../domain/core/decimal';
|
||||
|
||||
export type NumpadKey =
|
||||
| '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
|
||||
@ -1,13 +1,11 @@
|
||||
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 { useSettingsStore } from '../store/settingsStore';
|
||||
import { useNumpadUiStore } from '../store/numpadUiStore';
|
||||
import type { IoniconName } from './categoryIcons';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useNumpadUiStore } from '../../store/numpadUiStore';
|
||||
import type { IoniconName } from '../category/categoryIcons';
|
||||
|
||||
/** 路由名 → 图标(与 (tabs)/_layout.tsx 的 4 个 Screen 对应)。 */
|
||||
const TAB_ICONS: Record<string, IoniconName> = {
|
||||
@ -23,9 +21,7 @@ const TAB_ICONS: Record<string, IoniconName> = {
|
||||
*/
|
||||
export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
|
||||
const openNumpad = useNumpadUiStore(s => s.open);
|
||||
|
||||
const renderTab = (route: (typeof state.routes)[number]) => {
|
||||
@ -48,7 +44,7 @@ export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps)
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<Ionicons name={TAB_ICONS[route.name] ?? 'ellipse-outline'} size={22} color={color} />
|
||||
<Text style={[styles.tabLabel, { color }]}>{label}</Text>
|
||||
<Text style={{ fontSize: theme.typography.caption.fontSize - 2, fontWeight: '600', color }}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
@ -82,8 +78,7 @@ export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps)
|
||||
|
||||
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' },
|
||||
tab: { flex: 1, alignItems: 'center', paddingTop: 8, gap: 2, minHeight: 44 },
|
||||
plusSlot: { width: 64, alignItems: 'center' },
|
||||
plus: { width: 52, height: 52, borderRadius: 26, alignItems: 'center', justifyContent: 'center', marginTop: -24, borderWidth: 4 },
|
||||
});
|
||||
@ -1,11 +1,11 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { authenticate, RealAuthProvider, hashPin, verifyPin } from '../services/security';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { authenticate, RealAuthProvider, hashPin, verifyPin } from '../../services/security/security';
|
||||
|
||||
/** SecureStore 中存储 PIN 哈希的 key。 */
|
||||
const PIN_HASH_KEY = 'app_pin_hash';
|
||||
@ -17,6 +17,26 @@ interface LockScreenProps {
|
||||
|
||||
export function LockScreen({ onUnlock }: LockScreenProps) {
|
||||
const { theme } = useTheme();
|
||||
// 主题化样式:解锁键圆角 radii.md、圆形/胶囊用 radii.full、键盘数字 h2、间距取 spacing tokens
|
||||
/* eslint-disable react-native/no-unused-styles */
|
||||
const styles = useMemo(() => StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
content: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: theme.spacing.lg },
|
||||
centerSection: { alignItems: 'center', justifyContent: 'center' },
|
||||
iconWrap: { width: 96, height: 96, borderRadius: theme.radii.full, alignItems: 'center', justifyContent: 'center' },
|
||||
actionRow: { flexDirection: 'row', gap: 12, marginTop: theme.spacing.xl },
|
||||
unlockBtn: { paddingVertical: 14, paddingHorizontal: 28, borderRadius: theme.radii.md, minWidth: 120, alignItems: 'center' },
|
||||
pinSection: { alignItems: 'center', width: '100%' },
|
||||
dotsRow: { flexDirection: 'row', gap: 20, marginBottom: theme.spacing.lg },
|
||||
dot: { width: 16, height: 16, borderRadius: theme.radii.full },
|
||||
grid: { width: '80%', gap: 12 },
|
||||
gridRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 12 },
|
||||
keyBtn: { flex: 1, minHeight: 60, borderRadius: theme.radii.full, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
keyText: { fontSize: theme.typography.h2.fontSize, fontWeight: '700' },
|
||||
utilityBtn: { flex: 1, minHeight: 60, alignItems: 'center', justifyContent: 'center' },
|
||||
switchBackBtn: { marginTop: theme.spacing.xl, padding: theme.spacing.sm },
|
||||
}), [theme]);
|
||||
/* eslint-enable react-native/no-unused-styles */
|
||||
const t = useT();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
@ -245,20 +265,4 @@ export async function hasPinSet(): Promise<boolean> {
|
||||
return hash !== null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
content: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
|
||||
centerSection: { alignItems: 'center', justifyContent: 'center' },
|
||||
iconWrap: { width: 96, height: 96, borderRadius: 48, alignItems: 'center', justifyContent: 'center' },
|
||||
actionRow: { flexDirection: 'row', gap: 12, marginTop: 32 },
|
||||
unlockBtn: { paddingVertical: 14, paddingHorizontal: 28, borderRadius: 12, minWidth: 120, alignItems: 'center' },
|
||||
pinSection: { alignItems: 'center', width: '100%' },
|
||||
dotsRow: { flexDirection: 'row', gap: 20, marginBottom: 24 },
|
||||
dot: { width: 16, height: 16, borderRadius: 8 },
|
||||
grid: { width: '80%', gap: 12 },
|
||||
gridRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 12 },
|
||||
keyBtn: { flex: 1, height: 60, borderRadius: 30, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
keyText: { fontSize: 24, fontWeight: '700' },
|
||||
utilityBtn: { flex: 1, height: 60, alignItems: 'center', justifyContent: 'center' },
|
||||
switchBackBtn: { marginTop: 32, padding: 8 },
|
||||
});
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Alert, ScrollView, StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
||||
import { ScrollView, StyleSheet, View, type ViewStyle } 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 type { ComponentProps } from 'react';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { ScreenHeader } from './ScreenHeader';
|
||||
import { FormModal, type FormField } from './FormModal';
|
||||
import { Touchable } from './Touchable';
|
||||
import { FormModal, type FormField } from '../form/FormModal';
|
||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||
import { Touchable } from '../ui/Touchable';
|
||||
|
||||
/**
|
||||
* 管理页模板(P2):统一「列表 + +新增 + 点按编辑 + 长按删除 + FormModal」模式。
|
||||
@ -20,6 +23,8 @@ interface ManagementScreenProps<T> {
|
||||
renderItem: (item: T, handlers: { openEdit: () => void; confirmDelete: () => void }) => React.ReactNode;
|
||||
addLabel: string;
|
||||
emptyText?: string;
|
||||
/** 空状态图标(Ionicons 名),默认 file-tray-outline。 */
|
||||
emptyIcon?: ComponentProps<typeof Ionicons>['name'];
|
||||
formTitle: (editing: T | null) => string;
|
||||
formFields: (editing: T | null) => FormField[];
|
||||
/** 返回 true 关闭弹窗(校验失败 Alert 后返回 false 保持打开)。 */
|
||||
@ -46,6 +51,7 @@ export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
|
||||
const t = useT();
|
||||
const [editing, setEditing] = useState<T | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState<T | null>(null);
|
||||
|
||||
const openForm = (item: T | null) => {
|
||||
setEditing(item);
|
||||
@ -53,12 +59,7 @@ export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = (item: T) => {
|
||||
Alert.alert(props.deleteConfirmTitle ?? t('common.delete'), props.deleteConfirmText(item), [
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{ text: t('common.delete'), style: 'destructive', onPress: () => props.onDelete(item) },
|
||||
]);
|
||||
};
|
||||
const confirmDelete = (item: T) => setDeleting(item);
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
@ -82,7 +83,7 @@ export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
{props.headerContent}
|
||||
{props.items.length === 0 && props.emptyText ? (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{props.emptyText}</Text>
|
||||
<EmptyState icon={props.emptyIcon ?? 'file-tray-outline'} title={props.emptyText} />
|
||||
) : null}
|
||||
<View style={props.listStyle}>
|
||||
{props.items.map(item => (
|
||||
@ -106,6 +107,18 @@ export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
|
||||
>
|
||||
{props.formExtra?.(editing)}
|
||||
</FormModal>
|
||||
<ConfirmDialog
|
||||
visible={deleting !== null}
|
||||
title={props.deleteConfirmTitle ?? t('common.delete')}
|
||||
message={deleting ? props.deleteConfirmText(deleting) : ''}
|
||||
confirmLabel={t('common.delete')}
|
||||
destructive
|
||||
onConfirm={() => {
|
||||
if (deleting) props.onDelete(deleting);
|
||||
setDeleting(null);
|
||||
}}
|
||||
onCancel={() => setDeleting(null)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@ -2,8 +2,9 @@ import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTheme } from '../theme';
|
||||
import { Touchable } from './Touchable';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { Touchable } from '../ui/Touchable';
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
title: string;
|
||||
@ -16,16 +17,17 @@ interface ScreenHeaderProps {
|
||||
}
|
||||
|
||||
/** 统一二级页头:返回 + 标题 + 右操作位(替换各页手写 header)。 */
|
||||
export function ScreenHeader({ title, right, onBack, backLabel = '返回' }: ScreenHeaderProps) {
|
||||
export function ScreenHeader({ title, right, onBack, backLabel }: ScreenHeaderProps) {
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
const t = useT();
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Touchable
|
||||
onPress={onBack ?? (() => router.back())}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={backLabel}
|
||||
accessibilityLabel={backLabel ?? t('common.back')}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
|
||||
</Touchable>
|
||||
@ -1,10 +1,10 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { getMonthGrid, type DayCell } from '../domain/calendarGrid';
|
||||
import { dailyExpenseMap } from '../domain/chartStats';
|
||||
import type { Transaction } from '../domain/types';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { getMonthGrid, type DayCell } from '../../domain/stats/calendarGrid';
|
||||
import { dailyExpenseMap } from '../../domain/stats/chartStats';
|
||||
import type { Transaction } from '../../domain/core/types';
|
||||
|
||||
interface CalendarViewProps {
|
||||
transactions: Transaction[];
|
||||
@ -14,12 +14,20 @@ interface CalendarViewProps {
|
||||
month: number;
|
||||
/** 选中日期变化回调(YYYY-MM-DD)。 */
|
||||
onDayPress?: (date: string) => void;
|
||||
/** 显示模式:interactive(默认,可交互日历)| heatmap(只读热力图) */
|
||||
variant?: 'interactive' | 'heatmap';
|
||||
/** heatmap 模式下的标题文本。 */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function CalendarView({ transactions, year, month, onDayPress }: CalendarViewProps) {
|
||||
/**
|
||||
* 统一月历组件(合并原 CalendarView + CalendarHeatmap)。
|
||||
* - variant="interactive":可交互日历,支持选中日期、收支圆点、热力背景。
|
||||
* - variant="heatmap":只读热力图,按日期展示消费强度。
|
||||
*/
|
||||
export function CalendarView({ transactions, year, month, onDayPress, variant = 'interactive', title }: CalendarViewProps) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
// 星期头周日起(与 getMonthGrid 网格一致),文案走 i18n
|
||||
const weekdays = useMemo(() => t('calendar.weekdays').split(','), [t]);
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
|
||||
@ -33,8 +41,13 @@ export function CalendarView({ transactions, year, month, onDayPress }: Calendar
|
||||
onDayPress?.(cell.date);
|
||||
}, [onDayPress]);
|
||||
|
||||
const isHeatmap = variant === 'heatmap';
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.sm }]}>
|
||||
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: isHeatmap ? theme.radii.lg : theme.radii.md, padding: theme.spacing.sm }]}>
|
||||
{isHeatmap && title ? (
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, marginBottom: 4 }]}>{title}</Text>
|
||||
) : null}
|
||||
{/* 星期表头 */}
|
||||
<View style={styles.weekdayRow}>
|
||||
{weekdays.map(d => (
|
||||
@ -45,17 +58,42 @@ export function CalendarView({ transactions, year, month, onDayPress }: Calendar
|
||||
</View>
|
||||
{/* 日期网格 */}
|
||||
{weeks.map((week, wi) => (
|
||||
<View key={wi} style={styles.weekRow}>
|
||||
<View key={wi} style={[styles.weekRow, isHeatmap ? { gap: 2 } : undefined]}>
|
||||
{week.map(cell => {
|
||||
const isSelected = cell.date === selectedDate;
|
||||
const expense = dailyMap.get(cell.date) ?? 0;
|
||||
const intensity = expense / maxDaily;
|
||||
|
||||
// ---- heatmap 只读模式 ----
|
||||
if (isHeatmap) {
|
||||
return (
|
||||
<View key={cell.date} style={[
|
||||
styles.heatCell,
|
||||
{
|
||||
backgroundColor: cell.isCurrentMonth && expense > 0
|
||||
? theme.colors.financial.expense
|
||||
: 'transparent',
|
||||
borderColor: theme.colors.border,
|
||||
opacity: cell.isCurrentMonth ? (expense > 0 ? Math.min(0.2 + intensity * 0.8, 1) : 1) : 0.3,
|
||||
borderRadius: theme.radii.sm / 2,
|
||||
},
|
||||
]}>
|
||||
<Text style={[theme.typography.caption, { fontSize: theme.typography.caption.fontSize - 2, color: theme.colors.fgSecondary }]}>
|
||||
{cell.day}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- interactive 交互模式 ----
|
||||
const isSelected = cell.date === selectedDate;
|
||||
return (
|
||||
<Pressable
|
||||
key={cell.date}
|
||||
onPress={() => handlePress(cell)}
|
||||
disabled={!cell.isCurrentMonth}
|
||||
hitSlop={{ top: 2, bottom: 2, left: 2, right: 2 }}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={cell.isCurrentMonth ? year + '-' + String(month).padStart(2, '0') + '-' + String(cell.day).padStart(2, '0') : undefined}
|
||||
style={[
|
||||
styles.dayCell,
|
||||
{
|
||||
@ -64,22 +102,18 @@ export function CalendarView({ transactions, year, month, onDayPress }: Calendar
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* 消费热力图背景叠加层(未选中、当前月且有支出时显示) */}
|
||||
{/* 消费热力图背景叠加层 */}
|
||||
{cell.isCurrentMonth && expense > 0 && !isSelected && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
left: 2,
|
||||
right: 2,
|
||||
top: 2, bottom: 2, left: 2, right: 2,
|
||||
borderRadius: theme.radii.sm,
|
||||
backgroundColor: theme.colors.financial.expense,
|
||||
opacity: 0.12 + intensity * 0.68, // 12% 浅红 ~ 80% 深红
|
||||
opacity: 0.12 + intensity * 0.68,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text style={[
|
||||
theme.typography.bodySmall,
|
||||
{
|
||||
@ -94,14 +128,12 @@ export function CalendarView({ transactions, year, month, onDayPress }: Calendar
|
||||
]}>
|
||||
{cell.day}
|
||||
</Text>
|
||||
|
||||
{/* 交易标记圆点 */}
|
||||
{cell.count > 0 && cell.isCurrentMonth && (
|
||||
<View style={[styles.dots, { zIndex: 1 }]}>
|
||||
{cell.hasIncome && (
|
||||
<View style={[styles.dot, { backgroundColor: isSelected ? theme.colors.fgInverse : theme.colors.financial.income }]} />
|
||||
)}
|
||||
{/* 如果没有支出热力图背景,或者选中了,才渲染支出的点,防止视觉元素重复 */}
|
||||
{cell.hasExpense && (expense === 0 || isSelected) && (
|
||||
<View style={[styles.dot, { backgroundColor: isSelected ? theme.colors.fgInverse : theme.colors.financial.expense }]} />
|
||||
)}
|
||||
@ -121,6 +153,7 @@ const styles = StyleSheet.create({
|
||||
weekdayRow: { flexDirection: 'row', marginBottom: 4 },
|
||||
weekRow: { flexDirection: 'row' },
|
||||
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 4, position: 'relative' },
|
||||
heatCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', borderWidth: 0.5 },
|
||||
dots: { flexDirection: 'row', gap: 3, marginTop: 2 },
|
||||
dot: { width: 5, height: 5, borderRadius: 2.5 },
|
||||
});
|
||||
36
src/components/stats/PeriodSwitcher.tsx
Normal file
36
src/components/stats/PeriodSwitcher.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface PeriodSwitcherProps {
|
||||
/** 当前周期文案(如「2026年7月」) */
|
||||
label: string;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
/** 周期切换器:上一周期 / 周期文案 / 下一周期。hitSlop 扩大箭头触控区。 */
|
||||
export const PeriodSwitcher = React.memo(function PeriodSwitcher({ label, onPrev, onNext }: PeriodSwitcherProps) {
|
||||
const { theme } = useTheme();
|
||||
const btnStyle = ({ pressed }: { pressed: boolean }) => [
|
||||
styles.btn,
|
||||
{ borderRadius: theme.radii.lg, borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 },
|
||||
];
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable onPress={onPrev} hitSlop={8} accessibilityRole="button" style={btnStyle}>
|
||||
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{label}</Text>
|
||||
<Pressable onPress={onNext} hitSlop={8} accessibilityRole="button" style={btnStyle}>
|
||||
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20, paddingBottom: 12 },
|
||||
btn: { width: 32, height: 32, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
54
src/components/stats/RangeStatsCard.tsx
Normal file
54
src/components/stats/RangeStatsCard.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../ui/Card';
|
||||
|
||||
interface RangeStatsCardProps {
|
||||
/** 周期类型(年度报表不渲染本卡片) */
|
||||
period: 'weekly' | 'monthly';
|
||||
incomeText: string;
|
||||
expenseText: string;
|
||||
netText: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 周/月收支看板:收入 / 支出 / 结余三栏 + 笔数说明。 */
|
||||
export const RangeStatsCard = React.memo(function RangeStatsCard({ period, incomeText, expenseText, netText, count }: RangeStatsCardProps) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const weekly = period === 'weekly';
|
||||
return (
|
||||
<Card title={t(weekly ? 'report.weeklyTitle' : 'report.monthlyTitle')}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-around', marginVertical: theme.spacing.sm, width: '100%' }}>
|
||||
<View style={{ alignItems: 'center', gap: theme.spacing.xs }}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||
{t(weekly ? 'report.weeklyIncome' : 'report.monthlyIncome')}
|
||||
</Text>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>
|
||||
{incomeText}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ alignItems: 'center', gap: theme.spacing.xs }}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||
{t(weekly ? 'report.weeklyExpense' : 'report.monthlyExpense')}
|
||||
</Text>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>
|
||||
{expenseText}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ alignItems: 'center', gap: theme.spacing.xs }}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||
{t(weekly ? 'report.weeklyNet' : 'report.monthlyNet')}
|
||||
</Text>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>
|
||||
{netText}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: theme.spacing.sm }]}>
|
||||
{t(weekly ? 'report.weeklyStats' : 'report.monthlyStats', { count })}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@ -6,17 +6,17 @@ import React, { useMemo } from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { Card } from './Card';
|
||||
import { useLedgerStore } from '../store/ledgerStore';
|
||||
import { useMetadataStore } from '../store/metadataStore';
|
||||
import { useAutomationStore } from '../store/automationStore';
|
||||
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../domain/recurring';
|
||||
import { calculateBillingPeriod } from '../domain/creditCards';
|
||||
import type { CreditCard } from '../domain/creditCards';
|
||||
import { toDateString } from '../domain/decimal';
|
||||
import { shiftAnchor } from '../domain/periodNav';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../ui/Card';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useMetadataStore } from '../../store/metadataStore';
|
||||
import { useAutomationStore } from '../../store/automationStore';
|
||||
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/finance/recurring';
|
||||
import { calculateBillingPeriod } from '../../domain/finance/creditCards';
|
||||
import type { CreditCard } from '../../domain/finance/creditCards';
|
||||
import { toDateString } from '../../domain/core/decimal';
|
||||
import { shiftAnchor } from '../../domain/stats/periodNav';
|
||||
|
||||
export function TodoStrip() {
|
||||
const { theme } = useTheme();
|
||||
@ -76,11 +76,12 @@ export function TodoStrip() {
|
||||
{action ? (
|
||||
<Pressable
|
||||
onPress={action.onPress}
|
||||
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
|
||||
style={[styles.actionBtn, { backgroundColor: theme.colors.accent, borderRadius: theme.radii.full }]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={action.label}
|
||||
>
|
||||
<Text style={[styles.actionText, { color: theme.colors.fgInverse }]}>{action.label}</Text>
|
||||
<Text style={[styles.actionText, { color: theme.colors.fgInverse, fontSize: theme.typography.caption.fontSize }]}>{action.label}</Text>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Ionicons name="chevron-forward" size={16} color={theme.colors.fgSecondary} />
|
||||
@ -105,6 +106,6 @@ export function TodoStrip() {
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth },
|
||||
rowText: { flex: 1 },
|
||||
actionBtn: { paddingHorizontal: 12, paddingVertical: 5 },
|
||||
actionText: { fontSize: 12, fontWeight: '700' },
|
||||
actionBtn: { paddingHorizontal: 12, paddingVertical: 5, minHeight: 32 },
|
||||
actionText: { fontWeight: '700' },
|
||||
});
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
interface BalanceIndicatorProps {
|
||||
/** 各币种余额。 */
|
||||
@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import type { DedupResult } from '../domain/dedup';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import type { DedupResult } from '../../domain/transaction/dedup';
|
||||
|
||||
interface DedupBannerProps {
|
||||
result: DedupResult;
|
||||
@ -1,9 +1,9 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { useTheme, createCommonStyles } from '../theme';
|
||||
import { StyleSheet, TextInput, View } from 'react-native';
|
||||
import { useTheme, createCommonStyles } from '../../theme';
|
||||
import { BalanceIndicator } from './BalanceIndicator';
|
||||
import type { Posting } from '../domain/types';
|
||||
import { addDecimals } from '../domain/decimal';
|
||||
import type { Posting } from '../../domain/core/types';
|
||||
import { addDecimals } from '../../domain/core/decimal';
|
||||
|
||||
interface PostingEditorProps {
|
||||
postings: Posting[];
|
||||
@ -6,15 +6,16 @@ import React, { useRef } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Swipeable } from 'react-native-gesture-handler';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { TransactionCard } from './TransactionCard';
|
||||
import type { Transaction } from '../domain/types';
|
||||
import type { Transaction } from '../../domain/core/types';
|
||||
|
||||
interface SwipeableTransactionCardProps {
|
||||
transaction: Transaction;
|
||||
categoryId?: string;
|
||||
showDate?: boolean;
|
||||
highlight?: string;
|
||||
onPress: (t: Transaction) => void;
|
||||
/** 复制:跳转录入页预填(由调用方实现)。 */
|
||||
onDuplicate: (t: Transaction) => void;
|
||||
@ -22,7 +23,7 @@ interface SwipeableTransactionCardProps {
|
||||
onDelete: (t: Transaction) => void;
|
||||
}
|
||||
|
||||
export function SwipeableTransactionCard({ transaction, categoryId, showDate, onPress, onDuplicate, onDelete }: SwipeableTransactionCardProps) {
|
||||
export function SwipeableTransactionCard({ transaction, categoryId, showDate, highlight, onPress, onDuplicate, onDelete }: SwipeableTransactionCardProps) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const swipeRef = useRef<Swipeable>(null);
|
||||
@ -52,7 +53,7 @@ export function SwipeableTransactionCard({ transaction, categoryId, showDate, on
|
||||
|
||||
return (
|
||||
<Swipeable ref={swipeRef} renderRightActions={renderRightActions} overshootRight={false}>
|
||||
<TransactionCard transaction={transaction} categoryId={categoryId} showDate={showDate} onPress={onPress} />
|
||||
<TransactionCard transaction={transaction} categoryId={categoryId} showDate={showDate} highlight={highlight} onPress={onPress} />
|
||||
</Swipeable>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { CategoryIcon } from './CategoryIcon';
|
||||
import { Touchable } from './Touchable';
|
||||
import type { Transaction } from '../domain/types';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { CategoryIcon } from '../category/CategoryIcon';
|
||||
import { Touchable } from '../ui/Touchable';
|
||||
import type { Transaction } from '../../domain/core/types';
|
||||
|
||||
interface TransactionCardProps {
|
||||
transaction: Transaction;
|
||||
@ -13,10 +13,40 @@ interface TransactionCardProps {
|
||||
categoryId?: string;
|
||||
/** 时间线分组下隐藏日期(默认 true 保持旧行为)。 */
|
||||
showDate?: boolean;
|
||||
/** 搜索关键词(在摘要中高亮匹配) */
|
||||
highlight?: string;
|
||||
}
|
||||
|
||||
/** 交易卡片:CategoryIcon 圆底 + 摘要/账户小字 + 右侧等宽金额(支出黑、收入绿+、转账蓝)。 */
|
||||
export const TransactionCard = React.memo(function TransactionCard({ transaction, onPress, categoryId, showDate = true }: TransactionCardProps) {
|
||||
/** 图标尺寸与图标-正文间距:tags 行左缩进由此推导,避免魔法数字。 */
|
||||
const ICON_SIZE = 36;
|
||||
const ICON_GAP = 10;
|
||||
|
||||
// 搜索关键词高亮:大小写不敏感,命中片段用语义色加粗
|
||||
function highlightText(text: string, keyword: string | undefined, highlightColor: string): React.ReactNode {
|
||||
const kw = keyword?.trim().toLowerCase();
|
||||
if (!kw) return text;
|
||||
const lower = text.toLowerCase();
|
||||
if (!lower.includes(kw)) return text;
|
||||
const hitStyle = { color: highlightColor, fontWeight: '700' as const };
|
||||
const parts: React.ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
let pos = lower.indexOf(kw);
|
||||
let key = 0;
|
||||
while (pos !== -1) {
|
||||
if (pos > cursor) parts.push(text.slice(cursor, pos));
|
||||
parts.push(
|
||||
<Text key={key++} style={hitStyle}>
|
||||
{text.slice(pos, pos + kw.length)}
|
||||
</Text>,
|
||||
);
|
||||
cursor = pos + kw.length;
|
||||
pos = lower.indexOf(kw, cursor);
|
||||
}
|
||||
if (cursor < text.length) parts.push(text.slice(cursor));
|
||||
return parts;
|
||||
}
|
||||
export const TransactionCard = React.memo(function TransactionCard({ transaction, onPress, categoryId, showDate = true, highlight }: TransactionCardProps) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const isExpense = transaction.postings.some(p => p.account.startsWith('Expenses'));
|
||||
@ -52,10 +82,10 @@ export const TransactionCard = React.memo(function TransactionCard({ transaction
|
||||
},
|
||||
]}
|
||||
>
|
||||
<CategoryIcon categoryId={categoryId ?? direction} size={36} />
|
||||
<CategoryIcon categoryId={categoryId ?? direction} size={ICON_SIZE} />
|
||||
<View style={styles.body}>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]} numberOfLines={1}>
|
||||
{transaction.narration || transaction.payee || t('transaction.noSummary')}
|
||||
{highlightText(transaction.narration || transaction.payee || t('transaction.noSummary'), highlight, theme.colors.accent)}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]} numberOfLines={1}>
|
||||
@ -63,13 +93,13 @@ export const TransactionCard = React.memo(function TransactionCard({ transaction
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[theme.typography.body, { color: amountColor, fontWeight: '700', fontSize: 15, fontVariant: ['tabular-nums'] }]}>
|
||||
<Text style={[theme.typography.body, { color: amountColor, fontWeight: '700', fontSize: theme.typography.body.fontSize - 1, fontVariant: ['tabular-nums'] }]}>
|
||||
{amountText}
|
||||
</Text>
|
||||
{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' }]}>
|
||||
<Text key={tag} style={[styles.tag, { backgroundColor: theme.colors.accentLight, color: theme.colors.accent, fontWeight: '600', fontSize: theme.typography.caption.fontSize - 1, borderRadius: theme.radii.sm / 2 }]}>
|
||||
#{tag}
|
||||
</Text>
|
||||
))}
|
||||
@ -80,8 +110,8 @@ export const TransactionCard = React.memo(function TransactionCard({ transaction
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { marginBottom: 8, flexDirection: 'row', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
|
||||
card: { marginBottom: 8, flexDirection: 'row', alignItems: 'center', gap: ICON_GAP, flexWrap: 'wrap' },
|
||||
body: { flex: 1 },
|
||||
tags: { flexDirection: 'row', gap: 4, marginLeft: 46, flexBasis: '100%', flexWrap: 'wrap' },
|
||||
tag: { fontSize: 11, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, overflow: 'hidden' },
|
||||
tags: { flexDirection: 'row', gap: 4, marginLeft: ICON_SIZE + ICON_GAP, flexBasis: '100%', flexWrap: 'wrap' },
|
||||
tag: { paddingHorizontal: 6, paddingVertical: 2, overflow: 'hidden' },
|
||||
});
|
||||
@ -2,8 +2,8 @@
|
||||
* 交易按日期分组(P4 时间线):组内保持输入顺序,组头带当日收支小计。
|
||||
* 输入需已由调用方按日期倒序排列。
|
||||
*/
|
||||
import type { Transaction } from '../domain/types';
|
||||
import { addDecimals, negateDecimal } from '../domain/decimal';
|
||||
import type { Transaction } from '../../domain/core/types';
|
||||
import { addDecimals, negateDecimal } from '../../domain/core/decimal';
|
||||
|
||||
export interface DayGroup {
|
||||
date: string; // YYYY-MM-DD
|
||||
@ -24,15 +24,15 @@ export function groupTransactionsByDate(txs: Transaction[]): DayGroup[] {
|
||||
groups.push(group);
|
||||
}
|
||||
group.items.push(t);
|
||||
const incomeAmounts: string[] = [];
|
||||
const expenseAmounts: string[] = [];
|
||||
// 直接累加到 group 字段,避免每笔交易创建临时数组
|
||||
for (const p of t.postings) {
|
||||
if (!p.amount) continue;
|
||||
if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount));
|
||||
if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount);
|
||||
if (p.account.startsWith('Income')) {
|
||||
group.income = addDecimals([group.income, negateDecimal(p.amount)]);
|
||||
} else if (p.account.startsWith('Expenses')) {
|
||||
group.expense = addDecimals([group.expense, p.amount]);
|
||||
}
|
||||
}
|
||||
if (incomeAmounts.length) group.income = addDecimals([group.income, ...incomeAmounts]);
|
||||
if (expenseAmounts.length) group.expense = addDecimals([group.expense, ...expenseAmounts]);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
130
src/components/ui/BottomSheet.tsx
Normal file
130
src/components/ui/BottomSheet.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Animated, Modal, PanResponder, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useKeyboardHeight } from '../../hooks/useKeyboardAvoiding';
|
||||
|
||||
interface BottomSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
/** 内容可滚动(maxHeight 480),适合长表单。 */
|
||||
scrollable?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** 下滑关闭阈值(px):超过此距离松手即关闭。 */
|
||||
const DISMISS_THRESHOLD = 120;
|
||||
|
||||
/**
|
||||
* 通用底部弹窗容器:顶部 xl 圆角 + 把手、遮罩点击关闭、Android 返回键关闭、手势下滑关闭。
|
||||
*
|
||||
* 外层 BottomSheet 负责 Modal + SafeAreaProvider 包裹。
|
||||
* 关键:RN <Modal> 会脱离主窗口的 SafeAreaProvider context,导致内部
|
||||
* useSafeAreaInsets() 返回全 0,底部留白与键盘避让均失效。
|
||||
* 故在 Modal 内重新包一层 SafeAreaProvider 恢复 context。
|
||||
*/
|
||||
export function BottomSheet({ visible, onClose, title, scrollable, children }: BottomSheetProps) {
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<SafeAreaProvider>
|
||||
<BottomSheetContent onClose={onClose} title={title} scrollable={scrollable}>
|
||||
{children}
|
||||
</BottomSheetContent>
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function BottomSheetContent({ onClose, title, scrollable, children }: Omit<BottomSheetProps, 'visible'>) {
|
||||
const { theme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomInset = insets.bottom;
|
||||
// 手势下滑关闭的位移(仅手势,键盘避让改用响应式 paddingBottom)
|
||||
const translateY = useRef(new Animated.Value(0)).current;
|
||||
// 键盘高度:做响应式 paddingBottom(参考 BeeCount 方案,无残留)
|
||||
const kbHeight = useKeyboardHeight();
|
||||
const sheetHeight = useRef(0);
|
||||
|
||||
// 手势下滑关闭
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => false,
|
||||
onMoveShouldSetPanResponder: (_evt, gestureState) => {
|
||||
// 仅在下滑且位移明显时拦截(避免与内部 ScrollView 冲突)
|
||||
return gestureState.dy > 10 && Math.abs(gestureState.dy) > Math.abs(gestureState.dx);
|
||||
},
|
||||
onPanResponderMove: (_evt, gestureState) => {
|
||||
if (gestureState.dy > 0) {
|
||||
translateY.setValue(gestureState.dy);
|
||||
}
|
||||
},
|
||||
onPanResponderRelease: (_evt, gestureState) => {
|
||||
if (gestureState.dy > DISMISS_THRESHOLD || gestureState.vy > 0.8) {
|
||||
// 下滑超过阈值或速度足够快 → 关闭
|
||||
Animated.timing(translateY, {
|
||||
toValue: sheetHeight.current || 600,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
translateY.setValue(0);
|
||||
onClose();
|
||||
});
|
||||
} else {
|
||||
// 回弹
|
||||
Animated.spring(translateY, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
tension: 150,
|
||||
friction: 8,
|
||||
}).start();
|
||||
}
|
||||
},
|
||||
}),
|
||||
).current;
|
||||
|
||||
return (
|
||||
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onClose}>
|
||||
<Animated.View
|
||||
style={{ transform: [{ translateY }], width: '100%' }}
|
||||
onLayout={(e) => { sheetHeight.current = e.nativeEvent.layout.height; }}
|
||||
>
|
||||
<Pressable
|
||||
style={[styles.sheet, {
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderTopLeftRadius: theme.radii.xl,
|
||||
borderTopRightRadius: theme.radii.xl,
|
||||
// 响应式 paddingBottom:基础留白(取安全区与24较大值) + 键盘高度
|
||||
paddingBottom: Math.max(bottomInset, 24) + kbHeight,
|
||||
}]}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 把手区域:手势拖拽热区 */}
|
||||
<View {...panResponder.panHandlers} style={styles.handleArea}>
|
||||
<View style={[styles.handle, { backgroundColor: theme.colors.border }]} />
|
||||
</View>
|
||||
{title ? (
|
||||
<Text style={[theme.typography.h3, styles.title, { color: theme.colors.fgPrimary }]}>{title}</Text>
|
||||
) : null}
|
||||
{scrollable ? (
|
||||
<ScrollView style={styles.scroll} keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive">
|
||||
{children}
|
||||
</ScrollView>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: { flex: 1, justifyContent: 'flex-end' },
|
||||
// paddingBottom 由内联动态计算(36 + 底部安全区),见上方 Pressable style
|
||||
sheet: { maxHeight: '85%', paddingHorizontal: 20 },
|
||||
handleArea: { alignItems: 'center', paddingTop: 8, paddingBottom: 4 },
|
||||
handle: { width: 40, height: 4, borderRadius: 2 },
|
||||
title: { marginTop: 8, marginBottom: 12 },
|
||||
scroll: { maxHeight: 480 },
|
||||
});
|
||||
@ -1,27 +1,32 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { Animated, Pressable, StyleSheet, Text } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
import { ActivityIndicator, Animated, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface ButtonProps {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
disabled?: boolean;
|
||||
/** 加载状态:显示 spinner 并禁止重复点击。 */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/** 按钮(主题化,并有触控动效)。primary=主题色,secondary=次级容器,danger=错误色。 */
|
||||
export function Button({ label, onPress, variant = 'primary', disabled }: ButtonProps) {
|
||||
/** 按钮(主题化,并有触控动效)。primary=主题色,secondary=次级容器,danger=错误色。支持 loading 状态。 */
|
||||
export function Button({ label, onPress, variant = 'primary', disabled, loading }: ButtonProps) {
|
||||
const { theme } = useTheme();
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
const handlePressIn = useCallback(() => {
|
||||
if (isDisabled) return;
|
||||
Animated.spring(scale, {
|
||||
toValue: 0.96,
|
||||
useNativeDriver: true,
|
||||
tension: 180,
|
||||
friction: 7,
|
||||
}).start();
|
||||
}, [scale]);
|
||||
}, [scale, isDisabled]);
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
Animated.spring(scale, {
|
||||
@ -41,11 +46,11 @@ export function Button({ label, onPress, variant = 'primary', disabled }: Button
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
accessibilityState={{ disabled: !!disabled }}
|
||||
accessibilityState={{ disabled: !!isDisabled, busy: !!loading }}
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled}
|
||||
disabled={isDisabled}
|
||||
style={{ marginTop: 6 }}
|
||||
>
|
||||
<Animated.View
|
||||
@ -54,14 +59,21 @@ export function Button({ label, onPress, variant = 'primary', disabled }: Button
|
||||
{
|
||||
backgroundColor: bg,
|
||||
borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
opacity: isDisabled ? theme.opacities.disabled : 1,
|
||||
borderWidth: variant === 'secondary' ? StyleSheet.hairlineWidth : 0,
|
||||
borderColor: theme.colors.border,
|
||||
transform: [{ scale }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.text, { color: fg }]}>{label}</Text>
|
||||
{loading ? (
|
||||
<View style={styles.loadingRow}>
|
||||
<ActivityIndicator size="small" color={fg} />
|
||||
<Text style={[styles.text, { color: fg, fontSize: theme.typography.body.fontSize }]}>{label}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.text, { color: fg, fontSize: theme.typography.body.fontSize }]}>{label}</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
@ -69,5 +81,6 @@ export function Button({ label, onPress, variant = 'primary', disabled }: Button
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: { paddingVertical: 12, paddingHorizontal: 16, alignItems: 'center' },
|
||||
text: { fontWeight: '700', fontSize: 16 },
|
||||
text: { fontWeight: '700' },
|
||||
loadingRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
});
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Animated, Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../theme';
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface CardProps {
|
||||
title?: string;
|
||||
87
src/components/ui/ConfirmDialog.tsx
Normal file
87
src/components/ui/ConfirmDialog.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Modal, Text, View } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { createCommonStyles } from '../../theme/commonStyles';
|
||||
import { useT } from '../../i18n';
|
||||
import { Touchable } from './Touchable';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** 危险操作(如删除):确认按钮使用语义错误色。 */
|
||||
destructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可定制确认弹窗(主题化)。
|
||||
* 替代系统 Alert.alert:统一视觉风格,支持危险操作语义色与自定义按钮文案。
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
visible,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
destructive,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const { theme } = useTheme();
|
||||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||
const t = useT();
|
||||
const confirmBg = destructive ? theme.colors.error : theme.colors.accent;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||
<View style={commonStyles.modalOverlay}>
|
||||
<View style={commonStyles.modalCard}>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{title}</Text>
|
||||
{message ? (
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, marginTop: theme.spacing.sm }]}>
|
||||
{message}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={{ flexDirection: 'row', gap: theme.spacing.sm, marginTop: theme.spacing.lg }}>
|
||||
<Touchable
|
||||
onPress={onCancel}
|
||||
accessibilityRole="button"
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: theme.spacing.sm + theme.spacing.xs,
|
||||
borderRadius: theme.radii.md,
|
||||
backgroundColor: theme.colors.bgTertiary,
|
||||
}}
|
||||
>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
||||
{cancelLabel ?? t('common.cancel')}
|
||||
</Text>
|
||||
</Touchable>
|
||||
<Touchable
|
||||
onPress={onConfirm}
|
||||
accessibilityRole="button"
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: theme.spacing.sm + theme.spacing.xs,
|
||||
borderRadius: theme.radii.md,
|
||||
backgroundColor: confirmBg,
|
||||
}}
|
||||
>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgInverse, fontWeight: '600' }]}>
|
||||
{confirmLabel ?? t('common.confirm')}
|
||||
</Text>
|
||||
</Touchable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
100
src/components/ui/EmptyState.tsx
Normal file
100
src/components/ui/EmptyState.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { Text, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useTheme } from '../../theme';
|
||||
import { Touchable } from './Touchable';
|
||||
|
||||
interface EmptyStateProps {
|
||||
/** Ionicons 图标名 */
|
||||
icon: ComponentProps<typeof Ionicons>['name'];
|
||||
/** 主标题 */
|
||||
title: string;
|
||||
/** 补充描述(可选) */
|
||||
description?: string;
|
||||
/** 操作按钮文案(可选,与 onAction 同时提供时渲染) */
|
||||
actionLabel?: string;
|
||||
/** 操作按钮点击回调 */
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 空状态占位组件。
|
||||
* 用于列表/页面无数据时的友好提示:圆形底托图标 + 标题 + 描述 + 可选操作按钮。
|
||||
*/
|
||||
export function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
padding: theme.spacing.xl,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{/* 图标底托:圆形次级容器弱化空洞感,图标 48px 次级前景色 */}
|
||||
<View
|
||||
style={{
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 44,
|
||||
backgroundColor: theme.colors.bgTertiary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Ionicons name={icon} size={48} color={theme.colors.fgSecondary} />
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
...theme.typography.h3,
|
||||
color: theme.colors.fgPrimary,
|
||||
marginTop: theme.spacing.lg,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{description ? (
|
||||
<Text
|
||||
style={{
|
||||
...theme.typography.bodySmall,
|
||||
color: theme.colors.fgSecondary,
|
||||
marginTop: theme.spacing.xs,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{actionLabel && onAction ? (
|
||||
<Touchable
|
||||
onPress={onAction}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={actionLabel}
|
||||
style={{
|
||||
marginTop: theme.spacing.lg,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
paddingHorizontal: theme.spacing.lg,
|
||||
borderRadius: theme.radii.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: theme.typography.body.fontSize,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.fgInverse,
|
||||
}}
|
||||
>
|
||||
{actionLabel}
|
||||
</Text>
|
||||
</Touchable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
64
src/components/ui/ErrorBoundary.tsx
Normal file
64
src/components/ui/ErrorBoundary.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
import { Text, View, Pressable, StyleSheet } from 'react-native';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** 降级 UI(可选) */
|
||||
fallback?: (error: Error, reset: () => void) => ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局错误边界:捕获子组件渲染异常,防止白屏崩溃。
|
||||
* 提供重试按钮和错误信息展示。
|
||||
*/
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false, error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
// 记录错误(可接入 crash 上报)
|
||||
console.error('[ErrorBoundary]', error.message, info.componentStack);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback(this.state.error, this.handleReset);
|
||||
}
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.icon}>⚠️</Text>
|
||||
<Text style={styles.title}>{'页面出现问题'}</Text>
|
||||
<Text style={styles.message} numberOfLines={4}>
|
||||
{this.state.error.message}
|
||||
</Text>
|
||||
<Pressable style={styles.button} onPress={this.handleReset}>
|
||||
<Text style={styles.buttonText}>{'重试'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, gap: 12 },
|
||||
icon: { fontSize: 48 },
|
||||
title: { fontSize: 18, fontWeight: '700', color: '#1f2937' },
|
||||
message: { fontSize: 13, color: '#6b7280', textAlign: 'center' },
|
||||
button: { marginTop: 8, paddingHorizontal: 24, paddingVertical: 10, backgroundColor: '#1f2937', borderRadius: 8 },
|
||||
buttonText: { color: '#fff', fontSize: 15, fontWeight: '600' },
|
||||
});
|
||||
@ -1,15 +1,17 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, TextInput, View } from 'react-native';
|
||||
import { Pressable, StyleSheet, TextInput, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../theme';
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder?: string;
|
||||
/** 提交(回车/搜索键)回调,用于记录搜索历史 */
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
export function SearchBar({ value, onChangeText, placeholder }: SearchBarProps) {
|
||||
export function SearchBar({ value, onChangeText, placeholder, onSubmit }: SearchBarProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<View
|
||||
@ -28,11 +30,15 @@ export function SearchBar({ value, onChangeText, placeholder }: SearchBarProps)
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
returnKeyType="search"
|
||||
onSubmitEditing={onSubmit}
|
||||
placeholderTextColor={theme.colors.fgSecondary}
|
||||
style={[styles.input, { color: theme.colors.fgPrimary }]}
|
||||
style={[styles.input, { color: theme.colors.fgPrimary, fontSize: theme.typography.body.fontSize }]}
|
||||
/>
|
||||
{value ? (
|
||||
<Ionicons name="close-circle" size={18} color={theme.colors.fgSecondary} onPress={() => onChangeText('')} />
|
||||
<Pressable onPress={() => onChangeText('')} hitSlop={{ top: 12, bottom: 12, left: 8, right: 8 }} accessibilityRole="button" accessibilityLabel="清除搜索">
|
||||
<Ionicons name="close-circle" size={18} color={theme.colors.fgSecondary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
@ -41,5 +47,5 @@ export function SearchBar({ value, onChangeText, placeholder }: SearchBarProps)
|
||||
const styles = StyleSheet.create({
|
||||
container: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 8, marginHorizontal: 16, marginBottom: 8 },
|
||||
icon: { marginRight: 8 },
|
||||
input: { flex: 1, padding: 0, fontSize: 16 },
|
||||
input: { flex: 1, padding: 0 },
|
||||
});
|
||||
113
src/components/ui/SegmentedControl.tsx
Normal file
113
src/components/ui/SegmentedControl.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Animated, Text, View } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { Touchable } from './Touchable';
|
||||
|
||||
export interface SegmentedOption {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps {
|
||||
options: SegmentedOption[];
|
||||
/** 当前选中项的 key */
|
||||
value: string;
|
||||
onChange: (key: string) => void;
|
||||
}
|
||||
|
||||
/** 指示器与容器之间的内缩间距(dp) */
|
||||
const INDICATOR_INSET = 3;
|
||||
|
||||
/**
|
||||
* 分段选择器(主题化,带 Animated spring 滑动指示器)。
|
||||
* 用于替代手写 tab 切换:等宽分段、胶囊容器、选中项高亮滑动。
|
||||
*/
|
||||
export function SegmentedControl({ options, value, onChange }: SegmentedControlProps) {
|
||||
const { theme } = useTheme();
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
/** 首次布局时直接落位,不播放滑动动画 */
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
const count = options.length;
|
||||
const innerWidth = Math.max(0, containerWidth - INDICATOR_INSET * 2);
|
||||
const segmentWidth = count > 0 ? innerWidth / count : 0;
|
||||
const activeIndex = Math.max(0, options.findIndex(option => option.key === value));
|
||||
|
||||
// 选中项或布局宽度变化时,弹性滑动指示器到目标分段
|
||||
useEffect(() => {
|
||||
if (segmentWidth <= 0) return;
|
||||
const toValue = activeIndex * segmentWidth;
|
||||
if (!initializedRef.current) {
|
||||
initializedRef.current = true;
|
||||
translateX.setValue(toValue);
|
||||
return;
|
||||
}
|
||||
Animated.spring(translateX, {
|
||||
toValue,
|
||||
tension: 170,
|
||||
friction: 14,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [activeIndex, segmentWidth, translateX]);
|
||||
|
||||
return (
|
||||
<View
|
||||
accessibilityRole="tablist"
|
||||
onLayout={event => setContainerWidth(event.nativeEvent.layout.width)}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
backgroundColor: theme.colors.bgTertiary,
|
||||
borderRadius: theme.radii.full,
|
||||
padding: INDICATOR_INSET,
|
||||
}}
|
||||
>
|
||||
{/* 滑动指示器:完成布局测量后才渲染,避免首帧宽度为 0 */}
|
||||
{segmentWidth > 0 && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: INDICATOR_INSET,
|
||||
bottom: INDICATOR_INSET,
|
||||
left: INDICATOR_INSET,
|
||||
width: segmentWidth,
|
||||
borderRadius: theme.radii.full,
|
||||
backgroundColor: theme.colors.accent,
|
||||
transform: [{ translateX }],
|
||||
...theme.shadows.sm,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.map(option => {
|
||||
const active = option.key === value;
|
||||
return (
|
||||
<Touchable
|
||||
key={option.key}
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={option.label}
|
||||
accessibilityState={{ selected: active }}
|
||||
onPress={() => onChange(option.key)}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: theme.spacing.xs,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
fontWeight: '600',
|
||||
color: active ? theme.colors.fgInverse : theme.colors.fgSecondary,
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Touchable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
79
src/components/ui/Skeleton.tsx
Normal file
79
src/components/ui/Skeleton.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Animated, View, type DimensionValue, type StyleProp, type ViewStyle } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface SkeletonProps {
|
||||
/** 宽度:数字(dp)或百分比字符串(如 '100%') */
|
||||
width: number | string;
|
||||
/** 高度(dp) */
|
||||
height: number;
|
||||
/** 圆角,默认 theme.radii.sm */
|
||||
borderRadius?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 骨架屏占位块。
|
||||
* 背景取 theme.colors.skeleton,opacity 在 0.3 ~ 1 之间循环闪烁模拟加载呼吸感。
|
||||
*/
|
||||
export function Skeleton({ width, height, borderRadius, style }: SkeletonProps) {
|
||||
const { theme } = useTheme();
|
||||
const opacity = useRef(new Animated.Value(0.3)).current;
|
||||
|
||||
// 闪烁动画:随组件挂载启动、卸载停止,避免泄漏
|
||||
useEffect(() => {
|
||||
const loop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(opacity, { toValue: 1, duration: 750, useNativeDriver: true }),
|
||||
Animated.timing(opacity, { toValue: 0.3, duration: 750, useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [opacity]);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
accessible={false}
|
||||
style={[
|
||||
{
|
||||
width: width as DimensionValue,
|
||||
height,
|
||||
borderRadius: borderRadius ?? theme.radii.sm,
|
||||
backgroundColor: theme.colors.skeleton,
|
||||
opacity,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonTextProps {
|
||||
/** 行数,默认 3 */
|
||||
lines?: number;
|
||||
/** 每行高度(dp),默认 14 */
|
||||
lineHeight?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多行文字骨架。
|
||||
* 末行缩短为 60% 宽,模拟自然段落的参差节奏。
|
||||
*/
|
||||
export function SkeletonText({ lines = 3, lineHeight = 14, style }: SkeletonTextProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
{Array.from({ length: Math.max(0, lines) }, (_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
width={index === lines - 1 && lines > 1 ? '60%' : '100%'}
|
||||
height={lineHeight}
|
||||
style={index > 0 ? { marginTop: theme.spacing.sm } : undefined}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
@ -19,6 +19,7 @@ export function StatCard({ label, value, caption, valueColor }: StatCardProps) {
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderRadius: theme.radii.xl,
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing.md,
|
||||
}, theme.shadows.sm]}
|
||||
>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{label}</Text>
|
||||
@ -36,6 +37,6 @@ export function StatCard({ label, value, caption, valueColor }: StatCardProps) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { padding: 16, gap: 4, borderWidth: StyleSheet.hairlineWidth },
|
||||
card: { gap: 4, borderWidth: StyleSheet.hairlineWidth },
|
||||
value: { fontWeight: '800', fontVariant: ['tabular-nums'] },
|
||||
});
|
||||
167
src/components/ui/TimePicker.tsx
Normal file
167
src/components/ui/TimePicker.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Modal, ScrollView, Text, View } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { Touchable } from './Touchable';
|
||||
|
||||
/** 滚轮单项高度(dp) */
|
||||
const ITEM_H = 44;
|
||||
/** 滚轮可视项数(奇数,居中一项高亮) */
|
||||
const VISIBLE = 3;
|
||||
|
||||
interface WheelProps {
|
||||
items: string[];
|
||||
selectedIndex: number;
|
||||
onSelectIndex: (index: number) => void;
|
||||
}
|
||||
|
||||
/** 单列滚轮:snap 吸附 + 居中高亮条。 */
|
||||
function Wheel({ items, selectedIndex, onSelectIndex }: WheelProps) {
|
||||
const { theme } = useTheme();
|
||||
const ref = useRef<ScrollView>(null);
|
||||
|
||||
// 选中项变化(含打开时同步外部值)时滚动定位
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => ref.current?.scrollTo({ y: selectedIndex * ITEM_H, animated: false }), 0);
|
||||
return () => clearTimeout(id);
|
||||
}, [selectedIndex]);
|
||||
|
||||
const pad = ITEM_H * ((VISIBLE - 1) / 2);
|
||||
|
||||
return (
|
||||
<View>
|
||||
{/* 居中选中高亮条 */}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: pad,
|
||||
height: ITEM_H,
|
||||
borderRadius: theme.radii.md,
|
||||
backgroundColor: theme.colors.bgTertiary,
|
||||
}}
|
||||
/>
|
||||
<ScrollView
|
||||
ref={ref}
|
||||
style={{ height: ITEM_H * VISIBLE, width: 72 }}
|
||||
contentContainerStyle={{ paddingVertical: pad }}
|
||||
snapToInterval={ITEM_H}
|
||||
decelerationRate="fast"
|
||||
showsVerticalScrollIndicator={false}
|
||||
onMomentumScrollEnd={e => onSelectIndex(Math.round(e.nativeEvent.contentOffset.y / ITEM_H))}
|
||||
>
|
||||
{items.map((it, i) => (
|
||||
<View key={i} style={{ height: ITEM_H, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: theme.typography.h3.fontSize,
|
||||
fontVariant: ['tabular-nums'],
|
||||
color: i === selectedIndex ? theme.colors.fgPrimary : theme.colors.fgSecondary,
|
||||
fontWeight: i === selectedIndex ? '700' : '400',
|
||||
}}
|
||||
>
|
||||
{it}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface TimePickerProps {
|
||||
visible: boolean;
|
||||
/** 当前小时 0-23 */
|
||||
hour: number;
|
||||
/** 当前分钟 0-59 */
|
||||
minute: number;
|
||||
/** 分钟步长,默认 5 */
|
||||
minuteStep?: number;
|
||||
title?: string;
|
||||
onConfirm: (hour: number, minute: number) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间选择器(主题化)。
|
||||
* 时 / 分双滚轮替代简陋的 +/- 按钮:snap 吸附选择,底部确认 / 取消。
|
||||
*/
|
||||
export function TimePicker({ visible, hour, minute, minuteStep = 5, title, onConfirm, onCancel }: TimePickerProps) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
|
||||
const hours = useMemo(() => Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')), []);
|
||||
const minutes = useMemo(
|
||||
() => Array.from({ length: Math.max(1, Math.floor(60 / minuteStep)) }, (_, i) => String(i * minuteStep).padStart(2, '0')),
|
||||
[minuteStep],
|
||||
);
|
||||
const clampM = (m: number) => Math.max(0, Math.min(Math.round(m / minuteStep), minutes.length - 1));
|
||||
|
||||
const [hIdx, setHIdx] = useState(hour);
|
||||
const [mIdx, setMIdx] = useState(() => clampM(minute));
|
||||
|
||||
// 每次打开时同步外部当前值
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setHIdx(hour);
|
||||
setMIdx(clampM(minute));
|
||||
}
|
||||
}, [visible, hour, minute]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
|
||||
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: theme.colors.overlay }}>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderTopLeftRadius: theme.radii.xl,
|
||||
borderTopRightRadius: theme.radii.xl,
|
||||
padding: theme.spacing.lg,
|
||||
...theme.shadows.lg,
|
||||
}}
|
||||
>
|
||||
{title ? (
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, textAlign: 'center' }]}>{title}</Text>
|
||||
) : null}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginTop: theme.spacing.lg }}>
|
||||
<Wheel items={hours} selectedIndex={hIdx} onSelectIndex={setHIdx} />
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, marginHorizontal: theme.spacing.xs }]}>:</Text>
|
||||
<Wheel items={minutes} selectedIndex={mIdx} onSelectIndex={setMIdx} />
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', gap: theme.spacing.sm, marginTop: theme.spacing.lg }}>
|
||||
<Touchable
|
||||
onPress={onCancel}
|
||||
accessibilityRole="button"
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: theme.spacing.sm + theme.spacing.xs,
|
||||
borderRadius: theme.radii.md,
|
||||
backgroundColor: theme.colors.bgTertiary,
|
||||
}}
|
||||
>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>{t('common.cancel')}</Text>
|
||||
</Touchable>
|
||||
<Touchable
|
||||
onPress={() => onConfirm(hIdx, mIdx * minuteStep)}
|
||||
accessibilityRole="button"
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: theme.spacing.sm + theme.spacing.xs,
|
||||
borderRadius: theme.radii.md,
|
||||
backgroundColor: theme.colors.accent,
|
||||
}}
|
||||
>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgInverse, fontWeight: '600' }]}>{t('common.confirm')}</Text>
|
||||
</Touchable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
227
src/components/ui/Toast.tsx
Normal file
227
src/components/ui/Toast.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useTheme } from '../../theme';
|
||||
|
||||
/** Toast 类型:分别映射主题语义色 success / error / info */
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface ToastAction {
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
interface ToastItem {
|
||||
/** 自增 id:区分连续触发,保证 effect 可靠重跑 */
|
||||
key: number;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (message: string, type?: ToastType, action?: ToastAction) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
/** Toast 展示时长(ms) */
|
||||
const TOAST_DURATION = 2500;
|
||||
/** 带操作按钮(如撤销)时的展示时长(ms),更长以便用户反应 */
|
||||
const ACTION_DURATION = 4500;
|
||||
/** 淡入淡出动画时长(ms) */
|
||||
const FADE_DURATION = 220;
|
||||
|
||||
/** 各类型对应的 Ionicons 图标 */
|
||||
const TOAST_ICONS: Record<ToastType, ComponentProps<typeof Ionicons>['name']> = {
|
||||
success: 'checkmark-circle',
|
||||
error: 'alert-circle',
|
||||
info: 'information-circle',
|
||||
};
|
||||
|
||||
/**
|
||||
* 全局轻量 Toast 提供者。
|
||||
*
|
||||
* 使用方式:
|
||||
* <ToastProvider>...</ToastProvider> // 需位于 ThemeProvider 与 SafeAreaProvider 之内
|
||||
* const { showToast } = useToast();
|
||||
* showToast('保存成功', 'success');
|
||||
*
|
||||
* - 自动 2.5s 消失,带淡入淡出 + 轻微下滑入场动画(Animated)
|
||||
* - 定位在屏幕顶部安全区下方,覆盖层 pointerEvents="none" 不拦截底层交互
|
||||
* - 连续触发时取消旧计时器并直接替换文案,避免闪烁
|
||||
*/
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const { theme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [toast, setToast] = useState<ToastItem | null>(null);
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
const translateY = useRef(new Animated.Value(-12)).current;
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
/** 当前是否有可见 Toast(决定新消息是否播放入场动画) */
|
||||
const visibleRef = useRef(false);
|
||||
const keyRef = useRef(0);
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType = 'info', action?: ToastAction) => {
|
||||
keyRef.current += 1;
|
||||
setToast({ key: keyRef.current, message, type, action });
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => ({ showToast }), [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
|
||||
// 取消上一条尚未结束的隐藏计时器
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
|
||||
if (visibleRef.current) {
|
||||
// 已有可见 Toast:仅替换文案并保持可见,避免闪烁
|
||||
opacity.setValue(1);
|
||||
translateY.setValue(0);
|
||||
} else {
|
||||
// 入场动画:淡入 + 轻微下滑
|
||||
visibleRef.current = true;
|
||||
opacity.setValue(0);
|
||||
translateY.setValue(-12);
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration: FADE_DURATION,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(translateY, {
|
||||
toValue: 0,
|
||||
tension: 140,
|
||||
friction: 10,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
|
||||
// 2.5s 后自动淡出并卸载
|
||||
hideTimer.current = setTimeout(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0,
|
||||
duration: FADE_DURATION,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: -12,
|
||||
duration: FADE_DURATION,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
visibleRef.current = false;
|
||||
setToast(null);
|
||||
});
|
||||
}, toast.action ? ACTION_DURATION : TOAST_DURATION);
|
||||
|
||||
return () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, [toast, opacity, translateY]);
|
||||
|
||||
// 类型 → 语义背景色映射
|
||||
const toastBg: Record<ToastType, string> = {
|
||||
success: theme.colors.success,
|
||||
error: theme.colors.error,
|
||||
info: theme.colors.info,
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={contextValue}>
|
||||
<View style={styles.root}>
|
||||
{children}
|
||||
|
||||
{toast && (
|
||||
<View
|
||||
pointerEvents={toast.action ? 'box-none' : 'none'}
|
||||
style={[styles.host, { top: insets.top + theme.spacing.sm }]}
|
||||
>
|
||||
<Animated.View
|
||||
accessibilityRole="alert"
|
||||
accessibilityLiveRegion="polite"
|
||||
style={[
|
||||
styles.toast,
|
||||
{
|
||||
opacity,
|
||||
transform: [{ translateY }],
|
||||
backgroundColor: toastBg[toast.type],
|
||||
borderRadius: theme.radii.md,
|
||||
padding: theme.spacing.md,
|
||||
gap: theme.spacing.xs,
|
||||
...theme.shadows.md,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={TOAST_ICONS[toast.type]}
|
||||
size={theme.typography.bodySmall.fontSize + 4}
|
||||
color={theme.colors.fgInverse}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: theme.colors.fgInverse,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
fontWeight: theme.typography.bodySmall.fontWeight,
|
||||
lineHeight: theme.typography.bodySmall.lineHeight,
|
||||
}}
|
||||
>
|
||||
{toast.message}
|
||||
</Text>
|
||||
{toast.action ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={toast.action.label}
|
||||
onPress={() => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
visibleRef.current = false;
|
||||
const act = toast.action;
|
||||
setToast(null);
|
||||
act?.onPress();
|
||||
}}
|
||||
style={{ marginLeft: theme.spacing.sm, paddingVertical: theme.spacing.xs, paddingHorizontal: theme.spacing.sm, borderRadius: theme.radii.sm, backgroundColor: 'rgba(255,255,255,0.22)' }}
|
||||
>
|
||||
<Text style={{ color: theme.colors.fgInverse, fontSize: theme.typography.bodySmall.fontSize, fontWeight: '700' }}>
|
||||
{toast.action.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取全局 Toast 派发器(必须在 ToastProvider 内使用) */
|
||||
export function useToast(): ToastContextValue {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useToast 必须在 ToastProvider 内部使用');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: { flex: 1 },
|
||||
host: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
zIndex: 999,
|
||||
elevation: 999,
|
||||
},
|
||||
toast: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
maxWidth: '86%',
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user