feat: OCR 模型按需下载 + 三层独立开关 + 日志持久化 + 原生浮层主题同步 + 文档重构

OCR 模型按需下载(P8 瘦身):
- 移除 plugins/ppocr/assets/ 内置模型(det 9.8MB + rec 21MB + dict),APK 减包 ~31MB
- 新增 modelDownloader.ts:优先从 HuggingFace/CDN 下载,兜底从 APK assets 拷贝
- OcrModule.kt 新增 setModelDir,支持从 filesystem 加载模型,回退 assets 兼容旧用户
- settingsStore 持久化 ocrModelVersion / ocrModelDir
- 自动化页集成模型状态检查与一键下载 UI

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

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

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

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

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

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

其他:
- i18n 新增权限/信用卡详情/日志中心/AI 设置等翻译键
- ppocr Config Plugin 修复 import 注入去重;size-optimization 增强
- 新增测试:logger.test.ts、floating-ui-config.test.ts
This commit is contained in:
fengmengqi 2026-07-23 19:03:38 +08:00
parent 2e73b5a2c6
commit bf04400852
93 changed files with 5989 additions and 19678 deletions

62
AGENTS.md Normal file
View File

@ -0,0 +1,62 @@
# Repository Guidelines
## Project Overview
DriftLedger (浮记) is an offline-first mobile client for [Beancount](https://beancount.github.io/) double-entry accounting, built with React Native + Expo (CNG), TypeScript, Zustand, and expo-sqlite. The app treats desktop-maintained `.bean` files as the read-only source of truth and appends new entries to `main.bean`.
## Project Structure & Module Organization
| Directory | Purpose |
|-----------|---------|
| `src/domain/` | Pure business logic (ledger parser, bill pipeline, dedup, rules, OCR processor). No React/RN imports. |
| `src/app/` | Expo Router file-based routes; `(tabs)/` is bottom navigation. |
| `src/components/` | Reusable React Native UI components and charts. |
| `src/store/` | Zustand stores (ledger, import, settings, metadata, automation). |
| `src/services/` | Platform-facing concerns (sync, backup, security, OCR bridge). |
| `src/storage/` | SQLite read-cache with versioned migrations. |
| `src/theme/` | Token-based theming system. |
| `src/i18n/` | zh/en localization dictionaries. |
| `plugins/` | Expo Config Plugins for native modules (ppocr, accessibility, sms-receiver, etc.). |
| `tests/` | Vitest unit tests (mock-backed, domain-focused). |
## Build, Test, and Development Commands
```bash
npm install --legacy-peer-deps # Install dependencies (legacy-peer-deps required)
npm run start # Start Metro dev server
npm run android # Build & run on Android device/emulator
npm test # Run full Vitest suite
npm run typecheck # TypeScript strict-mode check (tsc --noEmit)
# Run a single test file:
npx vitest run tests/ledger.test.ts
```
## Coding Style & Naming Conventions
- **TypeScript strict mode** is enabled; path alias `@/*` maps to `src/*`.
- **Money math** uses string decimals (`src/domain/decimal.ts`) — never raw JS floats.
- **Domain layer** (`src/domain/`) must remain pure: no React, RN, or Expo imports. Inject external dependencies via interfaces.
- Code comments and `plan.md` are written in **Chinese**; match that convention.
- New domain modules must be re-exported from `src/domain/index.ts`.
- Native code lives exclusively in `plugins/<name>/` as Expo Config Plugins — never edit generated `android/` files directly.
## Testing Guidelines
- Framework: **Vitest** (no config file; picks up `tests/**/*.test.ts` by default).
- Tests target the domain layer using mock backends (`MemoryBackend`, `MockOcrEngine`).
- Name test files descriptively: `<module-or-feature>.test.ts` (e.g., `decimal.test.ts`, `billPipeline.test.ts`).
- Run `npm run typecheck` after non-trivial changes to catch type regressions.
## Commit & Pull Request Guidelines
- Commit messages follow the pattern: `feat: <concise summary in Chinese>` with a detailed multi-line body listing changes by area.
- Example: `feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构`
- PRs should describe what changed and why; link related issues. Include screenshots for UI changes.
## Key Architectural Invariants
1. `.bean` files are the source of truth; SQLite is a disposable read-cache.
2. All bill ingestion funnels through `BillPipeline` (`src/domain/billPipeline.ts`) under a serialized mutex.
3. Every Config Plugin function **must `return config`** at the end.
4. IDs/checksums use FNV-1a hashing (`hash()` in `ledger.ts`), not crypto hashes.

View File

@ -1,26 +1,54 @@
# Bean Mobile # DriftLedger (浮记)
面向 Beancount 用户的离线移动端原型:桌面账本只读,移动端仅写入 `mobile.bean`CSV 账单先生成复式分录草稿,再由用户确认 面向 [Beancount](https://beancount.github.io/) 用户的离线移动记账客户端。桌面账本只读,移动端仅追加写入 `main.bean`;支持 OCR、无障碍服务、通知监听、短信等多渠道自动记账
## 启动 **技术栈**React Native + Expo (CNG) · TypeScript (strict) · Zustand · expo-sqlite · PP-OCRv6 (ONNX Runtime)
## 快速开始
```bash ```bash
npm install npm install --legacy-peer-deps # 安装依赖(必须带 --legacy-peer-deps
npm run start npm run start # 启动 Metro 开发服务器
npm run android # 构建并运行 Android
npm test # 运行 Vitest 全量测试
npm run typecheck # TypeScript 类型检查
``` ```
使用 Expo CNG 开发构建运行:`npm run android` 或 `npm run ios`。应用配置启用了 `expo-sqlite` 的 SQLCipher 构建选项,实际密钥管理应接入 SecureStore/Keychain 后再发布。 > 本应用为侧载开源项目不上架应用商店。Android 构建详见 [docs/android-build-guide.md](docs/android-build-guide.md)。
## 模块概览
| 目录 | 职责 |
|------|------|
| `src/domain/` | 纯业务逻辑:`.bean` 解析器、账单管道、去重、规则引擎、OCR 处理器 |
| `src/app/` | Expo Router 文件路由(`(tabs)/` 底部导航) |
| `src/components/` | React Native UI 组件与图表 |
| `src/store/` | Zustand 状态管理ledger / import / settings / metadata / automation |
| `src/services/` | 平台服务同步、备份、安全、OCR 桥接) |
| `src/storage/` | SQLite 读缓存 + 版本化迁移 |
| `src/theme/` | Token 主题系统(浅色 / OLED 暗色) |
| `src/i18n/` | 中英文国际化 |
| `plugins/` | Expo Config Plugins 原生模块OCR、无障碍、通知、短信、截图 |
| `tests/` | Vitest 单元测试mock 后端,覆盖 domain 层) |
## 文档导航
| 文档 | 内容 |
|------|------|
| [docs/architecture.md](docs/architecture.md) | 系统架构与数据流 |
| [docs/development.md](docs/development.md) | 开发指南(环境、命令、规范、测试) |
| [docs/android-build-guide.md](docs/android-build-guide.md) | Android 打包与体积优化 |
| [docs/design/](docs/design/) | UI 重设计规格与分阶段实施计划 |
| [design-system/](design-system/beancount-mobile/MASTER.md) | 设计系统 Token 定义 |
| [AGENTS.md](AGENTS.md) | AI 编程助手贡献指南 |
| [plan.md](plan.md) | 设计决策与实施路线图(权威文档) |
## 账本约定 ## 账本约定
在主账本添加一次: 桌面主账本添加一次:
```beancount ```beancount
include "mobile.bean" include "main.bean"
``` ```
应用不改写主账本或其他桌面维护文件;确认的交易只追加到 `mobile.bean`。导出的账本包由用户再同步到 Git、WebDAV 或 iCloud。 应用不改写主账本或其他桌面维护文件;确认的交易只追加到 `main.bean`
## 当前边界
已实现账务校验、基础 `.bean` 解析、CSV 账单规范化、去重、规则分类、转账候选和 `mobile.bean` 序列化。Tree-sitter 原生模块、真实文件选择/ZIP 导出、正式支付宝/微信/各银行字段映射、SecureStore 密钥和多设备冲突处理仍需在真机集成阶段接入。

116
docs/architecture.md Normal file
View File

@ -0,0 +1,116 @@
# 系统架构
## 核心不变量
`.bean` 文件是唯一事实来源SQLite 仅为可丢弃的读缓存。
## 数据流
```mermaid
flowchart LR
subgraph DataSource[数据源]
A[".bean 文件"]
B["OCR / 无障碍 / 通知 / 短信"]
C[“手动录入 / CSV 导入"]
end
subgraph Pipeline[处理层]
D["BillPipeline 串行互斥锁"]
E[“转账识别"]
F[“去重"]
G[“规则分类"]
end
subgraph Storage[存储层]
H["main.bean 追加写入"]
I["SQLite 缓存"]
J["Zustand Store 内存索引"]
end
subgraph UI[展示层]
K["Expo Router 页面"]
L[“报表 / 图表"]
end
A -->|parseLedger| J
B -->|原始事件| D
C -->|TransactionDraft| D
D --> E --> F --> G
G -->|确认写入| H
H -->|reparse| J
J --> K
J --> L
I -.->|搜索查询| J
```
## 模块分层
```mermaid
graph TB
subgraph UILayer["UI 层: src/app + src/components"]
R[“Expo Router 页面"]
CO[“可复用组件"]
end
subgraph StateLayer["状态层: src/store"]
S[“Zustand Stores"]
end
subgraph DomainLayer["领域层: src/domain — 纯 TS 零 RN 依赖"]
LE["ledger.ts 解析器"]
P["billPipeline.ts"]
D2["dedup / transfer / rules"]
O["ocrProcessor.ts"]
end
subgraph Infra[“基础设施"]
FS["expo-file-system"]
DB["expo-sqlite"]
NAT["plugins/ 原生模块"]
end
R --> S
CO --> S
S --> LE
S --> P
P --> D2
P --> O
LE --> FS
D2 --> DB
O --> NAT
```
## BillPipeline 处理顺序
所有导入渠道手动、CSV、OCR、无障碍、通知、短信、截图统一经过 `BillPipeline`,严格按序执行:
1. **转账识别** — 配对收入+支出 → 单笔转账(必须先于去重)
2. **批内去重** — 时间窗口 + 金额 + 交易对手
3. **历史去重** — 按日期索引比对已提交交易
4. **规则分类** — 规则匹配 → 关键词 → `Uncategorized` 兆底
## 原生模块
原生功能以 Expo Config Plugin 形式封装在 `plugins/<name>/`,详见 [plugins/README.md](../plugins/README.md)。
原生服务**不直接写入**数据库或文件,而是通过 `NativeEventEmitter` 将原始事件推送到 JS 层管道。
## OCR 三层级联
```mermaid
flowchart LR
A[“图像输入"] --> B["Layer 1: 正则规则"]
B -->|未命中| C["Layer 2: 本地 OCR PP-OCRv6"]
C -->|未命中| D["Layer 3: AI Vision 付费"]
```
## 双轨映射
Beancount 无原生“分类/预算”概念,应用在本地 SQLite 维护 UI 元数据,写入时映射回 Beancount 语义:
| 概念 | 本地表 | 映射到 `.bean` |
|------|--------|----------------|
| 分类 | `categories` | `linkedAccount` → posting 账户 |
| 标签 | `tags` | narration 中的 `#tag` |
| 预算 | `budgets` | 仅本地,不影响余额 |
| 信用卡 | `credit_cards` | 账户在 `.bean`UI 字段本地 |

View File

@ -9,6 +9,7 @@
**与 spec 的偏差**①account 页是「账户浏览器」(类型 Tab + 开户/调余额/关户三个交互),不套 ManagementScreen只做 ScreenHeader 统一;②`numpadGlobalEntry` 翻默认只影响新安装(已持久化的 false 不覆盖,尊重用户选择)。 **与 spec 的偏差**①account 页是「账户浏览器」(类型 Tab + 开户/调余额/关户三个交互),不套 ManagementScreen只做 ScreenHeader 统一;②`numpadGlobalEntry` 翻默认只影响新安装(已持久化的 false 不覆盖,尊重用户选择)。
**模板适配决策**(已通读 7 页源码): **模板适配决策**(已通读 7 页源码):
- budget/rules/remark-template → 直套T1 - budget/rules/remark-template → 直套T1
- category → `headerContent` 放支出/收入 chips新增按当前类型T2 - category → `headerContent` 放支出/收入 chips新增按当前类型T2
- recurring → 卡片保留显式编辑/删除按钮(用 handlers.openEdit/confirmDelete不用长按T2 - recurring → 卡片保留显式编辑/删除按钮(用 handlers.openEdit/confirmDelete不用长按T2
@ -19,6 +20,7 @@
### Task 1: 管理页模板化批次 1budget / rules / remark-template ### Task 1: 管理页模板化批次 1budget / rules / remark-template
**Files:** **Files:**
- Modify: `src/app/budget/index.tsx`、`src/app/rules/index.tsx`、`src/app/remark-template/index.tsx` - Modify: `src/app/budget/index.tsx`、`src/app/rules/index.tsx`、`src/app/remark-template/index.tsx`
- [ ] **Step 1: 三页套 ManagementScreen**(参照试点 `src/app/tag/index.tsx` 与模板 `src/components/ManagementScreen.tsx` - [ ] **Step 1: 三页套 ManagementScreen**(参照试点 `src/app/tag/index.tsx` 与模板 `src/components/ManagementScreen.tsx`
@ -26,6 +28,7 @@
通用映射:页面 state 消失modal/editing 由模板持有);手写 header/addBtn/Alert/FormModal 全删;`footer` 传 `common.clickEditLongDelete` 提示;`deleteConfirmTitle` 传各页 `t('xxx.deleteTitle')` 通用映射:页面 state 消失modal/editing 由模板持有);手写 header/addBtn/Alert/FormModal 全删;`footer` 传 `common.clickEditLongDelete` 提示;`deleteConfirmTitle` 传各页 `t('xxx.deleteTitle')`
**budget/index.tsx** **budget/index.tsx**
- `items={budgets}`、`keyExtractor={b => b.id}`、`addLabel={t('budget.add')}`、`emptyText={t('budget.empty')}` - `items={budgets}`、`keyExtractor={b => b.id}`、`addLabel={t('budget.add')}`、`emptyText={t('budget.empty')}`
- renderItem保留现有 Card + 进度条结构Pressable onPress={openEdit} onLongPress={confirmDelete} 包裹)。`today` 改用 `toDateString(new Date())`(修掉 `toISOString().slice(0,10)` 的 UTC 偏移) - renderItem保留现有 Card + 进度条结构Pressable onPress={openEdit} onLongPress={confirmDelete} 包裹)。`today` 改用 `toDateString(new Date())`(修掉 `toISOString().slice(0,10)` 的 UTC 偏移)
- formFields5 字段name/amount decimal-pad/period/categoryAccount/startDatedefaultValue 从 editing 取 - formFields5 字段name/amount decimal-pad/period/categoryAccount/startDatedefaultValue 从 editing 取
@ -33,12 +36,14 @@
- onDelete`removeBudget(budget.id)`deleteConfirmText`t('budget.deleteConfirm', { name: budget.name })` - onDelete`removeBudget(budget.id)`deleteConfirmText`t('budget.deleteConfirm', { name: budget.name })`
**rules/index.tsx** **rules/index.tsx**
- `items={[...rules].sort((a, b) => b.priority - a.priority)}` - `items={[...rules].sort((a, b) => b.priority - a.priority)}`
- renderItem保留规则行narration/P 徽标/条件/目标账户+命中次数);**删除 `fontFamily: theme.typography.caption.fontFamily`**(审计项) - renderItem保留规则行narration/P 徽标/条件/目标账户+命中次数);**删除 `fontFamily: theme.typography.caption.fontFamily`**(审计项)
- formFields7 字段priority numeric/counterpartyContains/memoContains/sourceAccount/categoryAccount/narration/tags - formFields7 字段priority numeric/counterpartyContains/memoContains/sourceAccount/categoryAccount/narration/tags
- onSubmit保留现有 Rule 组装逻辑addRule `{...rule, id: generateId('rule'), hits: 0}` / updateRule - onSubmit保留现有 Rule 组装逻辑addRule `{...rule, id: generateId('rule'), hits: 0}` / updateRule
**remark-template/index.tsx** **remark-template/index.tsx**
- 2 字段name/templaterenderItem 的模板文本 `fontFamily: 'monospace'``fontVariant: ['tabular-nums']`(审计项) - 2 字段name/templaterenderItem 的模板文本 `fontFamily: 'monospace'``fontVariant: ['tabular-nums']`(审计项)
- onSubmitname 空回退 `t('common.untitled')` - onSubmitname 空回退 `t('common.untitled')`
@ -52,6 +57,7 @@ Run: `grep -ln "FormModal\|arrow-back" src/app/budget/index.tsx src/app/rules/in
### Task 2: 管理页模板化批次 2category / recurring / credit-card ### Task 2: 管理页模板化批次 2category / recurring / credit-card
**Files:** **Files:**
- Modify: `src/app/category/index.tsx`、`src/app/recurring/index.tsx`、`src/app/credit-card/index.tsx` - Modify: `src/app/category/index.tsx`、`src/app/recurring/index.tsx`、`src/app/credit-card/index.tsx`
- [ ] **Step 1: category/index.tsx — headerContent 类型 chips** - [ ] **Step 1: category/index.tsx — headerContent 类型 chips**
@ -87,6 +93,7 @@ Run: `grep -ln "FormModal\|arrow-back" src/app/category/index.tsx src/app/recurr
### Task 3: ScreenHeader 统一account + settings 三页) ### Task 3: ScreenHeader 统一account + settings 三页)
**Files:** **Files:**
- Modify: `src/app/account/index.tsx`、`src/app/settings/sync.tsx`、`src/app/settings/ai.tsx`、`src/app/settings/preferences.tsx` - Modify: `src/app/account/index.tsx`、`src/app/settings/sync.tsx`、`src/app/settings/ai.tsx`、`src/app/settings/preferences.tsx`
- [ ] **Step 1: account/index.tsx** - [ ] **Step 1: account/index.tsx**
@ -107,6 +114,7 @@ Run: `grep -rn "arrow-back" src/app/ | grep -v ScreenHeader` → 无输出
### Task 4: 图表与杂项清理fontFamily / emoji / 星期头 i18n ### Task 4: 图表与杂项清理fontFamily / emoji / 星期头 i18n
**Files:** **Files:**
- Modify: `src/components/charts/CategoryPie.tsx`、`src/components/charts/TrendLine.tsx`、`src/components/charts/NetWorthChart.tsx`、`src/components/CalendarView.tsx`、`src/components/charts/CalendarHeatmap.tsx`、`src/components/DatePickerField.tsx`、`src/components/NumpadKeyboard.tsx`、`src/components/NumpadSheet.tsx`、`src/ai/monthlySummary.ts`、`src/app/ai/chat.tsx`、`src/app/transaction/[id].tsx`、`src/app/_error.tsx`、`src/app/import/index.tsx`、`src/components/PostingEditor.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts` - Modify: `src/components/charts/CategoryPie.tsx`、`src/components/charts/TrendLine.tsx`、`src/components/charts/NetWorthChart.tsx`、`src/components/CalendarView.tsx`、`src/components/charts/CalendarHeatmap.tsx`、`src/components/DatePickerField.tsx`、`src/components/NumpadKeyboard.tsx`、`src/components/NumpadSheet.tsx`、`src/ai/monthlySummary.ts`、`src/app/ai/chat.tsx`、`src/app/transaction/[id].tsx`、`src/app/_error.tsx`、`src/app/import/index.tsx`、`src/components/PostingEditor.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
- [ ] **Step 1: fontFamily 清零** - [ ] **Step 1: fontFamily 清零**
@ -142,6 +150,7 @@ Run: `grep -rn "fontFamily" src/ --include="*.tsx" | grep -v "_error.tsx"` →
### Task 5: 收尾(翻默认 + modal 守卫 + MonthlyReport 删除 + budgets 时区修复 + duplicate 增强) ### Task 5: 收尾(翻默认 + modal 守卫 + MonthlyReport 删除 + budgets 时区修复 + duplicate 增强)
**Files:** **Files:**
- Modify: `src/store/settingsStore.ts`、`src/components/NumpadSheet.tsx`、`src/components/NumpadSheetHost.tsx`、`src/domain/budgets.ts`、`src/app/(tabs)/transactions.tsx`、`tests/budgets.test.ts`(或现有 budget 测试文件) - Modify: `src/store/settingsStore.ts`、`src/components/NumpadSheet.tsx`、`src/components/NumpadSheetHost.tsx`、`src/domain/budgets.ts`、`src/app/(tabs)/transactions.tsx`、`tests/budgets.test.ts`(或现有 budget 测试文件)
- Delete: `src/components/charts/MonthlyReport.tsx` - Delete: `src/components/charts/MonthlyReport.tsx`
@ -207,6 +216,7 @@ Run: `npm run typecheck` → 无错误
Run: `npm run android` Run: `npm run android`
走查清单(浅/暗双主题): 走查清单(浅/暗双主题):
1. 6 个管理页(分类/预算/规则/周期/信用卡/备注模板新增、点按编辑、删除recurring 为显式按钮,其余长按)、空态文案 1. 6 个管理页(分类/预算/规则/周期/信用卡/备注模板新增、点按编辑、删除recurring 为显式按钮,其余长按)、空态文案
2. account 页:类型 Tab、开户、调余额、关户 2. account 页:类型 Tab、开户、调余额、关户
3. settings/sync、ai、preferencesheader 统一有返回箭头 3. settings/sync、ai、preferencesheader 统一有返回箭头

View File

@ -0,0 +1,317 @@
# UI 重设计 P6原生浮层悬浮球/悬浮窗)重设计 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。
**Goal:** 原生浮层FloatingHelper 悬浮球 / FloatingBillView 浮窗 / FloatingTip 提示)视觉与文案对齐新设计系统:颜色与文案由 JS 层theme tokens + i18n通过 bridge 下发,原生不再硬编码中文与旧靛蓝;悬浮球位置持久化;浮窗补币种字段;前台账单 Alert i18n 化并去 emoji。
**背景决策(已确认):**
- 原生 View 无法直接用 RN theme采用 **`FloatingUiConfig` 下发模式**JS 侧从当前 theme + i18n 构建 config → bridge `setFloatingUiConfig()` → 原生存 SharedPreferencesJSON→ 三个浮层组件读取。原生保留现有硬编码值作为**默认值兜底**JS 未推送时行为不变)。
- 浮窗卡片**跟随 App 主题**(浅色=白卡近黑 accent深色=OLED 黑卡白 accent卡片用实色不再半透明磨砂保证在其他 App 上的可读性。
- 浮窗金额校验从 `toDoubleOrNull` 改正则 + BigDecimal自动消失 15s → 30s。
- 改动范围:`plugins/accessibility/`Kotlin+ `src/services/`bridge/config+ `src/app/_layout.tsx` + `src/services/automationPipeline.ts` + `src/i18n/`。**不碰 domain 写路径。**
---
## FloatingUiConfig 契约T1-T5 共同遵守,先读这里)
### JS → 原生:`setFloatingUiConfig(config: ReadableMap): Promise<boolean>`
```typescript
interface FloatingUiConfig {
colors: {
accent: string; // 按钮/选中态背景(= theme accentlight #111318 / dark #F3F4F6
accentFg: string; // accent 上的文字色(= fgInverse
cardBg: string; // 卡片底色(= bgSecondary
inputBg: string; // 输入框/未选中 chip 底色(= bgTertiary
fgPrimary: string;
fgSecondary: string;
border: string;
income: string; // financial.income
expense: string; // financial.expense
transfer: string; // financial.transfer
};
labels: {
billTitle: string; // 浮窗标题
dirExpense: string; dirIncome: string; dirTransfer: string;
amountLabel: string; payeeLabel: string;
narrationLabel: string; narrationHint: string;
categoryExpense: string; // 「交易分类」
categoryIncome: string; // 「收入分类」
transferTarget: string; // 「转入账户」
accountExpense: string; // 「资金来源」
accountIncome: string; // 「存入账户」
accountTransfer: string; // 「转出账户」
openApp: string; dismiss: string; confirm: string;
ballOcr: string; // 悬浮球「识别账单」
ballRemember: string; // 「记住此页」
rememberSuccess: string; // 记住页面成功提示(不含 emoji
rememberFail: string; // 失败提示前缀(原生拼接 ': ' + e.message
pageRemembered: string; // BillingAccessibilityService Toast「已记住页面签名」前缀
pageSignatureExists: string; // 「该页面签名已存在」前缀
};
}
```
- 颜色一律 `"#RRGGBB"` 六位数 hextheme tokens 里有 rgba 的字段不要直接下发——accent/cardBg/fg/border 等都是纯色,确认无 rgbaborder 深色主题是 `rgba(255,255,255,0.08)`,下发前换算为 hex深色用 `#14202429`? **不许发明值**——正确做法:原生 `Color.parseColor` 支持 `#AARRGGBB`JS 侧把 rgba(255,255,255,0.08) 换算为 `#14FFFFFF`alpha 0.08≈0x14。写一个 `rgbaToHex` 小工具处理这种情况,纯色 hex 原样通过)。
- 原生端用 `Color.parseColor` 解析,异常时回退默认值。
### 原生持久化
- SharedPreferences 文件名:`floating_ui_config`,单键 `config_json` 存整个 JSON。
- 新增 `FloatingUiConfigStore.kt`object`save(context, ReadableMap)` / `load(context): FloatingUiConfig`data class字段缺省值=现状硬编码值,保证旧 JS 行为不变)。
- `FloatingUiConfig` data class 字段名与上面契约一致camelCase
### 事件契约变更T3+T5
- `showFloatingBill` 增加参数 `currency: String`(放在 `direction` 之后、`draftId` 之前)。
- `billingConfirmed` / `billingOpenApp` 事件 map 增加 `putString("currency", currentCurrency)`
- 浮窗金额行左侧显示当前币种 chip点击在 `listOf("CNY","USD","HKD","JPY","EUR","GBP")` 中循环切换;初始值 = 传入 currency不在列表则插入到首位
---
### Task 1: JS 侧 — i18n 键 + floatingUiConfig 服务 + bridge 接口 + 推送接线 + 前台 Alert i18n
**Files:**
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`、`src/services/accessibilityBridge.ts`、`src/app/_layout.tsx`、`src/services/automationPipeline.ts`
- Create: `src/services/floatingUiConfig.ts`、`tests/floating-ui-config.test.ts`
- [ ] **Step 1: i18n 新增 `floating.*` 键组zh/en 对等,`%{name}` 插值语法)**
```
floating.billTitle 调整交易草稿 / Adjust Draft
floating.dirExpense 支出 / Expense (复用现有方向键也行,但浮层独立一组避免耦合)
floating.dirIncome 收入 / Income
floating.dirTransfer 转账 / Transfer
floating.amountLabel 金额 / Amount
floating.payeeLabel 交易对手 / Payee
floating.narrationLabel 描述/备注 / Narration
floating.narrationHint 输入交易叙述 / Enter narration
floating.categoryExpense 交易分类 / Category
floating.categoryIncome 收入分类 / Income Category
floating.transferTarget 转入账户 / To Account
floating.accountExpense 资金来源 / From Account
floating.accountIncome 存入账户 / To Account
floating.accountTransfer 转出账户 / From Account
floating.openApp 打开应用 / Open App
floating.dismiss 忽略 / Dismiss
floating.confirm 确认入账 / Confirm
floating.ballOcr 识别账单 / Scan Bill
floating.ballRemember 记住此页 / Remember Page
floating.rememberSuccess 已将当前页面加入识别白名单 / Page added to recognition whitelist
floating.rememberFail 记录失败 / Failed to save
floating.pageRemembered 已记住页面签名 / Page signature saved
floating.pageSignatureExists 该页面签名已存在 / Page signature already exists
```
(若现有键已有同文案可复用,但键组保持独立 `floating.*`;先 grep 避免重复键名。)
同时给前台 Alert 加键(放 `automation.*` 下,先读现有 automation 节):
```
automation.billDetectedTitle 识别到新账单 / New Bill Detected (无 emoji
automation.billDetectedReject 拒绝/丢弃 / Discard
automation.billDetectedEdit 修改并入账 / Edit & Save
automation.billDetectedConfirm 确认入账 / Confirm
```
Alert 的多行消息体(日期/商户/金额/分类/账户/叙述)逐行用既有标签键拼,不为每行加新键(行标签可复用表单/详情现有键,先 grep `transaction.payee` 之类;没有合适的就在 automation 节加 `billDetectedLine*` 键)。
- [ ] **Step 2: `src/services/floatingUiConfig.ts`**
```typescript
export interface FloatingUiConfig { colors: {...}; labels: {...} } // 按契约
/** rgba(r,g,b,a) → #AARRGGBB#RRGGBB 原样返回。 */
export function colorToHex(color: string): string;
/** 从 theme tokens + t() 构建完整 config。 */
export function buildFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): FloatingUiConfig;
/** 构建并推送到原生bridge 不可用时静默返回 false。 */
export async function pushFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): Promise<boolean>;
```
- [ ] **Step 3: bridge 接口扩展**
`accessibilityBridge.ts``NativeAccessibilityBridge` 加 `setFloatingUiConfig(config: FloatingUiConfig): Promise<boolean>;`,文件头注释方法清单补一行。`showFloatingBill` 签名加 `currency: string`direction 之后、draftId 之前)。
- [ ] **Step 4: _layout.tsx 推送接线**
- 启动时setFloatingBallEnabled 同一处,`src/app/_layout.tsx:139` 附近)也 `pushFloatingUiConfig(...)`。注意此处拿不到 useTheme 的 theme在 ThemeProvider 外层?先读 _layout 结构确认)——若拿不到,启动这次推送可移到下一步的组件里统一做,避免重复。
- 在 ThemeProvider **内部**挂一个小组件(如 `FloatingUiConfigSyncer`,可直接写在 _layout.tsx 里):`const { theme } = useTheme(); const t = useT(); useEffect(() => { pushFloatingUiConfig(theme, t); }, [theme, t])` —— 主题切换/语言切换/启动都会重推。t 引用随 locale 变化(确认 useT 返回的 t 在语言切换时引用变化,先读 src/i18n 实现;若 t 引用稳定,则依赖里加 locale
- [ ] **Step 5: automationPipeline.ts 前台 Alert i18n + 去 emoji**
`src/services/automationPipeline.ts:298``Alert.alert('🌟 识别到新账单', ...)`:标题/按钮全部改 t();消息体保留原信息结构。该文件是 service 层非组件——确认文件里如何拿 t若没有`i18n.t(...)` 直接调,读 src/i18n/index.ts 导出了什么)。`showFloatingBill` 调用处(:262传第 7 个参数 `currency`(用 :225 已提取的 currency 变量)。
- [ ] **Step 6: 测试 + 验证**
`tests/floating-ui-config.test.ts`
- `colorToHex``'#111318'` 原样;`'rgba(255,255,255,0.08)'` → `#14FFFFFF``'rgba(17,19,24,0.4)'` → `#66111318`alpha 四舍五入 Math.round(a*255)
- `buildFloatingUiConfig`:用 lightTheme + 真实 zh t() 构建,断言 colors.accent === '#111318'、labels.billTitle === '调整交易草稿'、所有契约字段非空(遍历 keys
Run: `npm run typecheck && npm test` → 全绿i18n parity 测试覆盖新键)
---
### Task 2: 原生 config 基础设施FloatingUiConfigStore + bridge 方法 + FloatingTip/Toast 改读)
**Files:**
- Create: `plugins/accessibility/android/FloatingUiConfigStore.kt`
- Modify: `plugins/accessibility/android/AccessibilityBridgeModule.kt`、`plugins/accessibility/android/FloatingTip.kt`、`plugins/accessibility/android/BillingAccessibilityService.kt`
- [ ] **Step 1: FloatingUiConfigStore.kt**
- `data class FloatingUiConfig(...)`colors/labels 全部字段,默认值 = 现状硬编码accent `#5E6AD2`、cardBg `#FF050506`? —— **不**:默认值应是「现状视觉」,但现状 cardBg 是 0x8C050506 半透明。默认 cardBg 用 `#F2050506`? 简化:默认值就用现状各硬编码颜色换算成 `#AARRGGBB`/`#RRGGBB` 字符串,逐字段列注释对应原 Kotlin 常量。labels 默认值 = 现状中文硬编码文案(去 emoji
- `object FloatingUiConfigStore``private const val PREFS = "floating_ui_config"``fun save(context: Context, map: ReadableMap)`遍历契约字段缺失字段跳过不覆盖JSONObject 组装后写入);`fun load(context: Context): FloatingUiConfig`JSONObject 读取,缺失/解析失败逐字段回退默认;`optString`)。颜色字段提供 `fun parseColorOr(value: String, fallback: Int): Int` 工具(`Color.parseColor` try/catch
- org.json 可用Android 内置),无需新依赖。
- [ ] **Step 2: AccessibilityBridgeModule.setFloatingUiConfig**
```kotlin
@ReactMethod
fun setFloatingUiConfig(config: ReadableMap, promise: Promise) {
try {
FloatingUiConfigStore.save(reactContext, config)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("CONFIG_SAVE_FAIL", e.message)
}
}
```
- [ ] **Step 3: FloatingTip/RepeatToast 改读 config**
- FloatingTip.show():背景色 `0xF0333333` → config accent不透明化解析后 `or 0xFF000000.toInt()`),文字色 → accentFgProgressBar `progressTintList` 设 accentFg。构造签名加可选 `config: FloatingUiConfig? = null`null 时内部 `FloatingUiConfigStore.load(context)`
- RepeatToast 的 `"⚠ $message"` 前缀 emoji 去掉,只留 message⚠ 属 emoji 审计范围)。
- FloatingTip 调用处FloatingHelper.kt:209/211改传 config 文案:`labels.rememberSuccess`、`"${labels.rememberFail}: ${e.message}"`T4 做也可以此处先改文案读取FloatingHelper 的彻底重设计在 T4
- [ ] **Step 4: BillingAccessibilityService 两处 Toast 文案**
:387 `"已记住页面签名:\n$sig"` 与 :419 `"该页面签名已存在:\n$sig"` → 改读 `FloatingUiConfigStore.load(this).labels` 的 pageRemembered / pageSignatureExists + `":\n$sig"`
- [ ] **Step 5: 编译验证**
`android/` 已生成。把改动的 .kt 手动拷到 `android/app/src/main/java/com/beancount/mobile/accessibility/`(与 plugins 目录同名文件覆盖),然后:
Run: `cd android && ./gradlew :app:compileDebugKotlin -q` → BUILD SUCCESSFUL
(若编译环境不可用,记录为设备验证项并在报告中说明。)
---
### Task 3: FloatingBillView 重设计 + 币种
**Files:**
- Modify: `plugins/accessibility/android/FloatingBillView.kt`、`plugins/accessibility/android/AccessibilityBridgeModule.kt`showFloatingBill 加 currency 参数透传)
**视觉规格(对齐 docs/ui-redesign-design.md §3 Bento**
- 卡片:实色 cardBg、圆角 16dp、1dp border 描边、padding 16dp原 12/8 加宽)
- 标题fgPrimary 13sp bold分段选择器选中=accent 底 accentFg 字,未选中=透明 fgSecondary 字,圆角 6dp
- 字段标签fgSecondary 10sp bold输入框inputBg 底、圆角 10dp、1dp border、fgPrimary 字、hint fgSecondary
- chips圆角 999用 dp(14f) 近似胶囊)、未选中=inputBg+border 描边+fgSecondary 字、选中:分类行支出/收入=accent转账方向第一行=transfer 色;账户行按方向=income/expense 色(语义色保留,但改从 config 读)
- 按钮:高 40dp、圆角 12dpconfirm=accent 底 accentFg 字 boldopenApp/dismiss=inputBg 底 fgPrimary 字
- 所有颜色经 `FloatingUiConfigStore.load(context)` + parseColorOr 兜底;所有文案经 labels
- [ ] **Step 1: 构造签名 + showFloatingBill 参数**
`FloatingBillView` 构造加 `initialCurrency: String = "CNY"``AccessibilityBridgeModule.showFloatingBill` 加 `currency: String` 参数direction 后 draftId 前)并透传。
- [ ] **Step 2: 币种 chip**
金额行:水平布局,左侧币种 chipinputBg+borderfgPrimary 字,显示 currentCurrency右侧金额输入框weight 1。点击 chip 在 `listOf("CNY","USD","HKD","JPY","EUR","GBP")` 循环initialCurrency 不在列表则临时插到首位。sendSaveEvent/sendOpenAppEvent 的 map 加 `putString("currency", currentCurrency)`
- [ ] **Step 3: 金额校验改正则 + BigDecimal**
`afterTextChanged``Regex("^\\d+(\\.\\d{1,2})?$")` 匹配且 `BigDecimal(text) > BigDecimal.ZERO` 才启用保存BigDecimal 构造 try/catch。禁用时按钮底色 inputBg + fgSecondary 字。
- [ ] **Step 4: 全面替换颜色与文案 + 自动消失 30s**
逐段按上面视觉规格重写 show() 与 rebuildChips()(结构不变,只换色值来源、文案来源、尺寸);`15000L` → `30000L`。标签按方向切换的文案(交易分类/收入分类/转入账户、资金来源/存入账户/转出账户)全部走 labels。
- [ ] **Step 5: 编译验证**(同 T2 Step 5 流程)
---
### Task 4: FloatingHelper 重设计 + 位置持久化
**Files:**
- Modify: `plugins/accessibility/android/FloatingHelper.kt`
- [ ] **Step 1: 颜色与文案走 config**
- 指示竖线accent保持 70% 透明度:解析后 alpha 设为 0xB0
- 菜单:实色 cardBg、圆角 12dp、1dp border
- 「识别账单」按钮accent 底 accentFg 字主操作「记住此页」inputBg 底 fgPrimary 字;按压态透明度变化保留(按下时 alpha 0.8
- ScanIconDrawable/PinIconDrawable 颜色Scan 用 accentFg在 accent 底按钮上Pin 用 fgSecondary
- 文案 labels.ballOcr / ballRemember / rememberSuccess / rememberFail带 ': ' + message 拼接)
- [ ] **Step 2: 位置持久化**
- SharedPreferences 复用 `billing_accessibility_prefs`:键 `floating_ball_x` / `floating_ball_y`
- `show()` 初始化:`params.x/y = prefs.getInt(...)`无键时用现状默认0/400拖动抬起吸附后写 prefs
- companion 的 lastX/lastY 保留为进程内缓存但初始从 prefs 读(或直接用局部变量+prefs简化则删 companion——注意多实例语义BillingAccessibilityService 单例,安全)
- [ ] **Step 3: 编译验证**(同 T2 Step 5 流程)
---
### Task 5: 币种链路 JS 消费侧
**Files:**
- Modify: `src/services/automationPipeline.ts`、`src/app/_layout.tsx`
- [ ] **Step 1: billingConfirmed 处理器用 res.currency**
`src/services/automationPipeline.ts:50-110` 的 handleBillingConfirmed`const currency = pending?.event?.currency || 'CNY'`:78→ 优先 `res.currency`(浮窗用户改过),`res.currency || pending?.event?.currency || 'CNY'`。确认 res 类型定义处加 currency 字段NativeOpenAppEvent 之类grep 类型声明一起改)。
- [ ] **Step 2: billingOpenApp 处理器**
`src/app/_layout.tsx:255` `const currency = 'CNY'``const currency = res.currency || 'CNY'`
- [ ] **Step 3: 验证**
Run: `npm run typecheck && npm test` → 全绿
---
### Task 6: P6 审计 + 验收
- [ ] **Step 1: 审计**
```bash
grep -rn "0xFF5E6AD2\|0x995E6AD2\|0xB05E6AD2\|0x1F5E6AD2\|0x405E6AD2" plugins/accessibility/android/ # 无输出旧靛蓝清零默认值除外——FloatingUiConfigStore 默认值允许保留并注释)
grep -rn '"[^"]*[一-鿿]' plugins/accessibility/android/*.kt | grep -v "FloatingUiConfigStore\|//" # 除 Store 默认值与注释外无硬编码中文 UI 字符串
grep -rn "⚠\|📌\|🌟" src/ plugins/ # 无输出
grep -n "currency" src/services/accessibilityBridge.ts # showFloatingBill 含 currency
```
- [ ] **Step 2: 全量测试 + typecheck + Kotlin 编译**
Run: `npm test` → 全绿;`npm run typecheck` → 无错误;`cd android && ./gradlew :app:compileDebugKotlin -q` → 成功
- [ ] **Step 3: 手工走查(需设备)**
Run: `npm run android`
1. 设置里切换 浅/深主题 → 触发一次浮窗(可用 automation 页调试入口或真实账单)→ 浮窗颜色跟随主题
2. 切换英文 → 浮窗/悬浮球/提示文案全英文
3. 拖动悬浮球 → 杀进程重启无障碍服务 → 位置保持
4. 浮窗点币种 chip 切换 USD → 确认入账 → 交易币种为 USD
5. 金额输入 `1e10` / `0` / `0.005` → 保存按钮禁用;`12.50` → 可用
6. 浮窗 30 秒无操作自动消失;触摸后不消失
---
## 终审修订(已实施,以实际代码为准)
**终审日期**2026-07-22审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
| # | 级别 | 问题 | 修复 |
|---|---|---|---|
| C1 | Critical | FloatingBillView 标题 `setTextColor(colorAccentFg)` — 浅色主题下白卡白字、深色主题下黑底黑字,标题不可见 | 改为 `colorFgPrimary` |
| I1 | Important | 容器边框从 accent 派生 alpha 而非用 config.border | 删除 `colorContainerBorder`,直接使用 `colorBorder` |
| I2 | Important | FloatingHelper btnRemember 背景从 fgPrimary 派生,计划要求 inputBg | 加 `colorInputBg` 解析btnRemember 改从 inputBg 派生 |
| I3 | Important | 悬浮球菜单背景半透明65% cardBg计划要求实色 | 菜单背景直接使用 `colorCardBg`full opacity仅描边保留半透明 |
| M1 | Minor | FloatingTip 背景未强制不透明化plan §T2 Step 3 | 加 `or 0xFF000000.toInt()` 保护 |
| M2 | Minor | 金额校验先 BigDecimal 解析再正则,正则先匹配可早期短路 | 调整为先 `pattern.matches(text)``value != null && value > BigDecimal.ZERO` |
**偏差说明(无需修复)**
- FloatingHelper 菜单背景 / 按钮背景 / 指示线的 alpha 分量组合(`(colorX and 0x00FFFFFF) or alpha`)是原生浮层叠加其他 App 所必需的透明度控制,不是颜色硬编码。颜色 RGB 分量完全来自 config。
- FloatingTip 默认 fallback `#FF000000` 是纯黑(非靛蓝),因为 accent 在极浅色主题下是 `#111318`近黑fallback 用黑色是安全的极限退化。
- ScanIconDrawable laserColor `0xFFF87171` 保留为语义色(扫描红光),不受主题控制。

View File

@ -0,0 +1,192 @@
# P7权限快捷引导 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax. **不执行任何 git 操作**(用户要求,改动留工作区)。不创建额外任务清单。
**Goal:** 引导页权限步骤从纯文字改可操作状态清单automation 页补齐通知监听/短信/存储三项权限的状态检测与快速跳转按钮。不新增原生权限,只加 UI 入口和一座通知监听状态检测 bridge 方法。
---
## 背景决策
- **不新增权限**。所有权限已在 AndroidManifest 中声明(通过 Config PluginP7 只加 UI 入口。
- **通知监听状态检测**需要一个原生 bridge 方法(`isNotificationListenerEnabled`),因为 RN 侧无法直接检查。用 `NotificationManager.getEnabledListenerPackages()`
- **短信 + 存储**用 React Native 内置 `PermissionsAndroid` API。
- **引导页 UI** 沿用现有 Card + Button 组件,与 P1-P5 设计系统一致。
---
### Task 1: 新增桥接方法 `isNotificationListenerEnabled`
**Files:**
- Modify: `plugins/accessibility/android/AccessibilityBridgeModule.kt`
- Modify: `src/services/accessibilityBridge.ts`
- [ ] **Step 1: AccessibilityBridgeModule.kt 加方法**
```kotlin
@ReactMethod
fun isNotificationListenerEnabled(promise: Promise) {
try {
val pm = reactContext.packageManager
val enabledListeners = android.app.NotificationManager::class.java
.getMethod("getEnabledListenerPackages")
.invoke(reactContext.getSystemService(Context.NOTIFICATION_SERVICE)) as? List<String>
val myPkg = reactContext.packageName
promise.resolve(enabledListeners?.any { it.startsWith(myPkg) } == true)
} catch (e: Exception) {
promise.resolve(false)
}
}
```
**注意**`getEnabledListenerPackages()` 在 Android 11+ 需 `isNotificationListenerEnabled(ComponentName)`;优先用反射避免 API level lint 错误,反射失败返回 false。
- [ ] **Step 2: JS 侧 bridge 接口**
`NativeAccessibilityBridge``isNotificationListenerEnabled(): Promise<boolean>;`
- [ ] **Step 3: 编译验证**
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
---
### Task 2: 引导页权限步骤(`_onboarding.tsx` 重写 permissions 步骤)
**Files:**
- Modify: `src/app/_onboarding.tsx`
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`(少量新键)
- [ ] **Step 1: i18n 新键zh/en 对等)**
`onboarding` section 加:
```
permAccessibility 无障碍服务 / Accessibility Service
permNotification 通知监听 / Notification Listener
permOverlay 悬浮窗 / Overlay
permSms 短信读取 / SMS
permStorage 相册/存储 / Photos & Storage
permGranted 已授权 / Granted
permNotGranted 未授权 / Not Granted
permOpenSettings 前往设置 / Open Settings
permRequest 请求权限 / Request
```
- [ ] **Step 2: 重写 permissions 步骤**
替换 `_onboarding.tsx:48``key: 'permissions'` 步骤内容。在当前 description 下方加权限状态清单Card 包裹,每行:图标 + 名称 + 状态点 + 操作按钮)。
权限列表:
1. **无障碍服务**`BIND_ACCESSIBILITY_SERVICE`)—— 最核心。检测 `serviceRunning`(从 `getAccessibilityBridge().isServiceRunning()` 异步取)。未授权 → 跳转 `ACCESSIBILITY_SETTINGS`
2. **通知监听**`BIND_NOTIFICATION_LISTENER_SERVICE`)—— 检测 `bridge.isNotificationListenerEnabled()`。未授权 → 跳转 `ACTION_NOTIFICATION_LISTENER_SETTINGS`
3. **短信**`RECEIVE_SMS`)—— `PermissionsAndroid.check()`。未授权 → `PermissionsAndroid.request()`
4. **存储**`READ_MEDIA_IMAGES`Android 13+ 用 `READ_MEDIA_IMAGES`,否则 `READ_EXTERNAL_STORAGE`)—— 同上
**实现细节**
- `const [permStates, setPermStates] = useState<Record<string, boolean | null>>({})` —— null = 加载中
- `useEffect(() => { checkAllPermissions(); }, [])` —— 步骤切到 permissions 时触发
- `checkAllPermissions`:调 `PermissionsAndroid.check()` 检查短信和存储;调 bridge 检查无障碍和通知监听
- 每行渲染:`Ionicons` 图标 + `permLabel` + 右侧状态文字(绿「已授权」/ 灰「未授权」)+ 未授权时显示操作按钮
- 「全部已授权」时显示一个大绿色勾 + 提示文字,按钮不显示
**权限 API 版本适配**
- `PermissionsAndroid.PERMISSIONS` 里 Android 13+ 是 `READ_MEDIA_IMAGES`,低版本用 `READ_EXTERNAL_STORAGE`
- 统一用 `Platform.Version >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE'`
- [ ] **Step 3: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -rn "onboarding.perm" src/i18n/zh.ts src/i18n/en.ts` → 确认键对等
---
### Task 3: automation 页补齐权限入口
**Files:**
- Modify: `src/app/automation/index.tsx`
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`(少量新键)
- [ ] **Step 1: i18n 新键**
`automation` section 加:
```
notificationTitle 通知监听服务 / Notification Listener
notificationEnabled 已启用 / Enabled
notificationDisabled 未启用 / Disabled
notificationOpenSettings 前往系统设置 / Open Settings
smsPermissionTitle 短信读取权限 / SMS Permission
smsPermissionGranted 已授权 / Granted
smsPermissionRequest 请求权限 / Request
storagePermissionTitle 存储权限 / Storage Permission
storagePermissionGranted 已授权 / Granted
storagePermissionRequest 请求权限 / Request
```
- [ ] **Step 2: automation 页加权限卡片**
在现有「无障碍服务状态与控制」Card **之后**加一个新 Card`<Card title={t('automation.otherPermissions')}>`。
内容三行:
1. **通知监听**:异步 `bridge.isNotificationListenerEnabled()` → 状态文字 + 未启用时「前往系统设置」按钮(`Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS')`
2. **短信**`PermissionsAndroid.check('android.permission.RECEIVE_SMS')` + `PermissionsAndroid.request()`
3. **存储**`PermissionsAndroid.check(StoragePermission)` + `PermissionsAndroid.request()`
**实现模式**
```tsx
const [notifEnabled, setNotifEnabled] = useState(false);
const [smsGranted, setSmsGranted] = useState(false);
const [storageGranted, setStorageGranted] = useState(false);
useEffect(() => {
// 异步获取各权限状态
checkNotif(); checkSms(); checkStorage();
}, []);
```
每行渲染:`Ionicons` 图标 + 权限名称 + 状态文字 + 未授权时的按钮。
- [ ] **Step 3: 验证**
Run: `npm run typecheck && npm test` → 全绿
---
### Task 4: 编译 + 测试 + 终审
- [ ] **Step 1: 编译验证**
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
- [ ] **Step 2: 全量测试**
`npm test` → 全绿;`npm run typecheck` → 0 错误
- [ ] **Step 3: 审计**
```bash
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
grep -rn "fontFamily" src/app/_onboarding.tsx src/app/automation/index.tsx # 无输出
grep -rnE "📊|💰|💸|📝|🔄|🎉|✨|⚠|📌|🌟" src/i18n/ src/app/_onboarding.tsx src/app/automation/index.tsx # 无输出(预存 i18n fallbackWarn ⚠ 除外)
```
- [ ] **Step 4: 手工走查(需设备)**
1. 清数据 → 启动应用 → 引导页第 5 步(权限)→ 看到 4 个权限状态
2. 点未授权的无障碍 → 跳系统无障碍设置 → 返回看到状态已变
3. 点未授权的通知监听 → 跳系统通知设置
4. 点短信 → 弹出系统权限对话框
5. 引导页全部完成后 → 进入 automation 页 → 看到新增的权限卡片
---
## 终审修订(已实施,以实际代码为准)
**终审日期**2026-07-22审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
| # | 级别 | 问题 | 处理 |
|---|---|---|---|
| — | — | 无 Critical / Important 发现 | — |
**偏差说明**
1. `onboarding.permOverlay`(悬浮窗)未加入引导页——计划 Task 2 决策列为可选项实际未实现。不影响核心功能sideload 自动授予 + 无障碍运行时浮窗走 ACCESSIBILITY_OVERLAY 不依赖此权限)。
2. T3 中 `Platform.Version >= 33` 改为 `Number(Platform.Version) >= 33`——React Native 的 `Platform.Version` 类型为 `string | number`,直接比较会 TS 报错。
3. T1 的 `isNotificationListenerEnabled` 使用反射而非 `enabledListenerPackages` 属性——minSdk=24 < API 27反射是兼容方案

View File

@ -0,0 +1,338 @@
# P8自动记账可配置化 + 模型按需下载 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git 操作**。不创建额外任务清单。
**Goal:** automation 页统一管控所有自动记账能力(规则/OCR/AI Vision 三层独立开关 + 模型下载管理 + AI 配置整合),`settings/ai.tsx` 删除并重定向OCR 模型从 APK assets 改为应用内下载filesystem 路径加载 ONNX
**背景决策**
- 三层开关默认值L1 规则 `true`、L2 OCR `true`、L3 AI Vision `false`(需用户配 API Key 后才可开启)
- 模型下载:首次启用 L2 时检查模型是否存在,无则弹出下载提示
- `settings/ai.tsx` 删除,原入口重定向到 automation 页
- 去重/转账识别两个 setting 字段不动(它们不是「层」而是管道的独立步骤),但在 automation 页加开关
- 模型从 assets 复制到 filesystem预构建时仍打 APK assets 以兼容旧用户,应用首次启动将模型文件从 assets 拷贝到 documentDirectoryOcrModule 从 filesystem 加载。**这样之后卸载 assets 打包就变成了纯删除**(未来版本切到纯下载只需移除 assets + app.plugin.js 禁拷贝)。
---
### Task 1: settingsStore 新增字段 + 迁移
**Files:** `src/store/settingsStore.ts`
- [ ] **Step 1: 接口 + DEFAULTS + 持久化 + setter**
`SettingsState` 接口加 5 个新字段:
```typescript
/** 自动记账 L1正则规则匹配 */
layer1RuleEnabled: boolean;
/** 自动记账 L2本地 OCR 识别 */
layer2OcrEnabled: boolean;
/** 自动记账 L3云端 AI Vision */
layer3AiEnabled: boolean;
/** OCR 模型版本(已下载),空字符串表示未安装 */
ocrModelVersion: string;
/** OCR 模型下载路径 */
ocrModelDir: string;
```
DEFAULTS
- `layer1RuleEnabled: true`
- `layer2OcrEnabled: true`
- `layer3AiEnabled: false`
- `ocrModelVersion: ''`
- `ocrModelDir: ''`
`PersistableSettings` 加对应 5 字段;`toPersistable` 加 5 行;新增 5 个 setter 函数。现有 `aiEnabled` 保留作为 LLM 的总开关语义(`layer3AiEnabled` 是在 automation 页独立控制,「启用」需同时满足 `aiEnabled && aiApiKey`)。
**注意**`isNotificationListenerEnabled` 这类 bridge 方法在 `setFloatingBallEnabled` 附近——新字段与它们无关,不加 bridge 方法。
- [ ] **Step 2: 验证**
`npm run typecheck` → 0 错误;`npm test` → 全绿
---
### Task 2: OcrProcessor 逐级回退逻辑
**Files:** `src/domain/ocrProcessor.ts`、`src/services/ocrBridge.ts`、`src/services/automationPipeline.ts`
- [ ] **Step 1: OcrProcessorConfig 扩展**
`OcrProcessorConfig` 接口加:
```typescript
layer1Enabled: boolean; // 默认 true
layer2Enabled: boolean; // 默认 true
layer3Enabled: boolean; // 默认 false
```
- [ ] **Step 2: doProcess() 逐级跳过**
`OcrProcessor.doProcess()` 中修改流程(约 :103-162
```
// 1. 横屏 / 图片去重 守卫不变
// 2. L1+L2 需要 OCR 文本
if (config.layer1Enabled || config.layer2Enabled) {
if (!ocrEngine) 返回 none引擎不可用
ocrText = await ocrEngine.recognizeText(imageBase64)
if (!ocrText) → 如果 L3 启用则 goto L3否则返回 none
} else {
ocrText = ''
}
// 3. Layer 1仅当启用
if (config.layer1Enabled && ocrText && !isDetailPage) {
result = matchOcrRule(ocrText, packageName)
if (result) return { layer: 'layer1-rule', ... }
}
// 4. Layer 2仅当启用
if (config.layer2Enabled && ocrText) {
result = parseOcrBill(ocrText, packageName)
if (result) return { layer: 'layer2-ocr', ... }
}
// 5. Layer 3仅当启用
if (config.layer3Enabled) {
return tryLayer3(imageBase64, ocrText)
}
// 6. 全部未命中或全部关闭
return { event: null, layer: 'none', skipped: 'non-bill' }
```
- [ ] **Step 3: automationPipeline.ts 的 getOcrProcessor 改读新字段**
`getOcrProcessor()` 约 :116-147 行:从 `settingsStore` 读取 `layer1RuleEnabled` / `layer2OcrEnabled` / `layer3AiEnabled` 传入 `OcrProcessorConfig`
- [ ] **Step 4: 验证**
`npm run typecheck` → 0 错误;`npm test` → 全绿
---
### Task 3: 模型文件从 assets 拷贝到 filesystem + OcrModule 改路径
**Files:** `plugins/ppocr/android/OcrModule.kt`、`plugins/ppocr/app.plugin.js`、`plugins/accessibility/android/AccessibilityBridgeModule.kt`(新 bridge 方法)
- [ ] **Step 1: AccessibilityBridgeModule 加 copyModelFromAssets 方法**
```kotlin
@ReactMethod
fun copyOcrModelsFromAssets(promise: Promise) {
try {
val assetManager = reactContext.assets
val destDir = java.io.File(reactContext.filesDir, "ocr_models_v6")
if (!destDir.exists()) destDir.mkdirs()
val files = listOf("ppocrv6_det.onnx", "ppocrv6_rec.onnx", "ppocrv6_dict.txt")
for (filename in files) {
val destFile = java.io.File(destDir, filename)
if (destFile.exists()) continue // skip existing
val input = assetManager.open(filename)
val output = java.io.FileOutputStream(destFile)
val buffer = ByteArray(8192)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
}
input.close()
output.close()
}
promise.resolve(destDir.absolutePath)
} catch (e: Exception) {
promise.reject("COPY_MODEL_FAIL", e.message)
}
}
```
- [ ] **Step 2: OcrModule.kt 改为 filesystem 路径加载**
`OcrModule.kt``createSession` 调用的位置,改从传入路径加载而非 asset 文件名。当前构造可能接受 asset 路径——改为接收 filesystem 绝对路径。新增一个 `@ReactMethod setModelDir(dir: String)` 存储路径,或直接在 `initialize(assetManager: ...)` 的参数中改为 `initialize(modelDir: String, ...)`
**注意**ONNX Runtime Android 的 `OrtEnvironment.createSession(filePath)` 接受文件系统路径String不需要 asset 特殊处理——只需确认 `createSession` 的参数类支持文件路径,否则改用 `OrtEnvironment.createSession(byte[] modelBytes)` 从内存加载。**最终方案**:保持 `OrtEnvironment.createSession(filePath)` ,将 `modelDir + "/ppocrv6_det.onnx"` 作为绝对路径传入。
- [ ] **Step 3: JS 侧 bridge 接口 + modelDownloader 服务**
`NativeAccessibilityBridge``copyOcrModelsFromAssets(): Promise<string>` → 返回模型目录绝对路径。
新建 `src/services/modelDownloader.ts`
```typescript
export async function ensureOcrModels(): Promise<string> {
// 1. 如果 settingsStore.ocrModelDir 有效且文件存在 → 直接返回
// 2. 尝试从 assets 拷贝到 documentDirectory/ocr_models_v6
// 3. 返回 modelsDir 绝对路径,存储到 settingsStore.ocrModelDir + ocrModelVersion
}
```
`ocrModelVersion` 在从 assets 拷贝成功后写 `'v6-assets'`(区分下载版本和打包版本)。
- [ ] **Step 4: automationPipeline.ts 的 OcrProcessor 从 filesystem 初始化**
`getNativeOcrBridge()``getOcrProcessor()` 中,在初始化 OCR 引擎前调用 `ensureOcrModels()` 获取模型路径,传入 `NativeOcrBridge` 构造(或 OcrModule initialize
- [ ] **Step 5: 验证**
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
---
### Task 4: automation 页 UI —— 三层开关 + 模型管理 + AI 配置整合
**Files:** `src/app/automation/index.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
- [ ] **Step 1: i18n 新键zh/en 对等)**
`automation` section 加:
```
autoBookkeeping 自动记账层级 / Auto Bookkeeping Layers
layer1Rule 规则匹配 / Rule Matching
layer1RuleDesc 基于正则规则的快速账单识别 / Fast regex-based bill recognition
layer2Ocr OCR 识别 / OCR Recognition
layer2OcrDesc 本地 PP-OCRv6 模型识别 / On-device PP-OCRv6 model
layer3Ai AI 视觉识别 / AI Vision
layer3AiDesc 云端多模态大模型识别 / Cloud multimodal LLM
layer3AiDisabled 请先配置 AI Key / Configure AI Key first
ocrModelTitle OCR 模型 / OCR Model
ocrModelNotInstalled 未安装 / Not Installed
ocrModelInstalled 已安装 ({version}) / Installed ({version})
ocrModelInstall 安装模型 / Install Model
ocrModelInstalling 安装中... / Installing...
ocrModelCopying 正在复制模型文件... / Copying model files...
dedupSwitch 多通道联合去重 / Cross-channel Dedup
transferSwitch 转账智能识别 / Transfer Recognition
```
- [ ] **Step 2: automation 页 UI 重组织**
在现有截图监控按钮 Card **之后**加以下新 Card 区块:
**Card A「自动记账层级」**
- 三行 SwitchL1 规则匹配always enabled不依赖外部条件
- L2 OCR 识别Switch开启时检查模型→无模型则弹下载提示 Dialog
- L3 AI 视觉Switch如果 aiEnabled=false 或 aiApiKey 为空则 disabled+提示文字;开启后展开 Provider/Key/Model 输入)
**Card B「OCR 模型」**
- 状态行:版本号(未安装显示灰色「未安装」;已安装显示绿色「已安装 (v6-assets)」)
- 操作按钮:安装/重新安装(从 assets 拷贝 → 进度状态 → 完成)
**Card C「管道设置」**(复用现有 Card B 和 C 部分)
- `dedupEnabled` Switch
- `transferRecognitionEnabled` Switch
- [ ] **Step 3: 操作逻辑**
```typescript
// 模型安装
const handleInstallModel = async () => {
setModelStatus('installing');
try {
const bridge = getAccessibilityBridge();
if (!bridge) throw new Error('Bridge unavailable');
const modelDir = await bridge.copyOcrModelsFromAssets();
setOcrModelVersion('v6-assets');
setOcrModelDir(modelDir);
setModelStatus('done');
} catch (e) {
setModelStatus('error');
Alert.alert('安装失败', String(e));
}
};
// L2 开关变化时检查模型
const handleL2Toggle = (val: boolean) => {
setLayer2OcrEnabled(val);
if (val && !ocrModelVersion) {
Alert.alert(t('automation.ocrModelTitle'), t('automation.ocrModelNotInstalled'), [
{ text: t('common.cancel'), onPress: () => setLayer2OcrEnabled(false) },
{ text: t('automation.ocrModelInstall'), onPress: handleInstallModel },
]);
}
};
// L3 开关变化时检查 AI Key
const handleL3Toggle = (val: boolean) => {
if (val && (!aiEnabled || !aiApiKey)) {
Alert.alert(t('automation.layer3Ai'), t('automation.layer3AiDisabled'));
return;
}
setLayer3AiEnabled(val);
};
```
- [ ] **Step 4: AI 配置内联**
L3 启用时展开的子区域:
- `aiProviderId`:三选一 chipopenai/gemini/deepseek
- `aiApiKey`输入框secureTextEntry
- `aiBaseUrl`:输入框(默认值 placeholder
- `aiModel`:输入框(默认值 placeholder
- `aiEnabled`Switch总开关控制 L3 的实际可用性)
- [ ] **Step 5: 验证**
`npm run typecheck` → 0 错误;`npm test` → 全绿
---
### Task 5: AI 设置页清理 + settings 导航更新
**Files:** `src/app/settings/ai.tsx`(重写为跳转)、`src/app/(tabs)/settings.tsx`(更新入口文案)
- [ ] **Step 1: settings/ai.tsx 改为 redirect**
不删除文件(保留路由),但内容改为:`useEffect(() => { router.replace('/automation'); }, [])` 跳转到 automation 页,期间显示 loading。
- [ ] **Step 2: settings.tsx 入口文案**
`src/app/(tabs)/settings.tsx` 中「智能记账 & AI」入口的文案保持不变但实际点击跳转已由 expo-router 自动处理ai.tsx → redirect → automation不需要改路由注册。
- [ ] **Step 3: 验证**
`npm run typecheck` → 0 错误;`npm test` → 全绿
`grep -rn "settings/ai" src/app/(tabs)/settings.tsx` → 确认无需改动即可跳转
---
### Task 6: 编译 + 测试 + 终审
- [ ] **Step 1: Kotlin 编译**
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
- [ ] **Step 2: 全量测试**
`npm test` → 全绿;`npm run typecheck` → 0 错误
- [ ] **Step 3: 审计**
```bash
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
grep -rn "fontFamily" src/app/automation/index.tsx # 无输出
grep -rn "📊|💰|💸|📝" src/app/automation/ # 无输出
```
- [ ] **Step 4: 手工走查清单**
1. settings 页点「智能记账」→ 跳转到 automation 页
2. automation 页看到三个层级开关L1 默认开 / L2 默认开 / L3 默认关灰)
3. 关闭 L1 → 仍可记账(走 L2 OCR
4. 关闭 L2 → L1 失败不再调用 OCR
5. 开启 L3 → 提示配 AI Key → 配置后可用
6. OCR 模型未安装 → 点安装 → 从 assets 拷贝 → 显示绿色已安装
7. L2 开关关闭再打开 → 已有模型直接可用,不再提示
8. AI Provider 切换 openai/gemini/deepseek → 对应的 baseUrl 默认值变化
---
## 终审修订(已实施,以实际代码为准)
**日期**2026-07-22审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
| # | 级别 | 问题 | 处理 |
|---|---|---|---|
| — | — | 无 Critical / Important 发现 | — |
**偏差说明**
1. 管道设置 Card 复用现有 `settings.dedupLabel`/`settings.transferLabel` i18n 键(已在 settings/ai.tsx 中使用),未新键 `dedupSwitch`/`transferSwitch`——避免重复键。
2. OcrModule.kt 从 assets 加载路径改为 `modelDir` 字段 + `setModelDir` 方法——保留原 assets 加载作为兜底modelDir 为空时走原路径),保证向后兼容。
3. `ensureOcrModels()``_layout.tsx` ready 前调用一次(异步非阻塞),`getOcrProcessor()` 中也传 `ocrModelDir` 作为安全网。

68
docs/development.md Normal file
View File

@ -0,0 +1,68 @@
# 开发指南
## 环境要求
- Node.js 18+
- JDK 21 + Android SDK 35 + NDK 27.1.12297006(仅 Android 构建需要)
- 详见 [android-build-guide.md](android-build-guide.md)
## 常用命令
```bash
npm install --legacy-peer-deps # 安装依赖(必须带 --legacy-peer-deps
npm run start # 启动 Metro 开发服务器
npm run android # expo run:android设备/模拟器)
npm test # Vitest 全量测试
npm run typecheck # tsc --noEmit 类型检查
# 单文件测试
npx vitest run tests/ledger.test.ts
# 按名称运行测试
npx vitest run -t "拒绝不平衡交易"
# 重新生成原生项目
npx expo prebuild --platform android
```
## 编码规范
### TypeScript
- **strict 模式**已开启,路径别名 `@/*``src/*`
- 非平凡改动后必须运行 `npm run typecheck`
### 金额计算
- 使用字符串十进制运算(`src/domain/decimal.ts`**禁止**用 JS 浮点数处理金额
- 关键 API`addDecimals`、`subtractDecimals`、`multiplyDecimal`、`divideDecimals`、`negateDecimal`
### 领域层纯净性
- `src/domain/` 禁止导入 React / React Native / Expo 任何模块
- 外部依赖通过接口注入:`MobileBeanBackend`、`OcrEngine`、`AiVisionProvider`
- 新模块必须在 `src/domain/index.ts` 重导出
### 原生代码
- 原生 Kotlin/XML 只能放在 `plugins/<name>/` 下,不可直接编辑 `android/`
- 每个 Config Plugin 函数**必须 `return config`**
- `expo prebuild` 后检查 `onnxruntime-android:1.20.0` 版本
### 注释与文档
- 代码注释和 `plan.md` 使用**中文**
- ID/校验和使用 FNV-1a 哈希(`hash()` in `ledger.ts`),不用加密哈希
## 测试
- 框架:**Vitest**(无配置文件,默认拾取 `tests/**/*.test.ts`
- 测试对象:主要覆盖 `src/domain/` 层,使用 mock 后端(`MemoryBackend`、`MockOcrEngine`
- 命名:`<模块或功能>.test.ts`,如 `decimal.test.ts`、`billPipeline.test.ts`
- 新增 domain 逻辑时必须同步添加测试
## 提交规范
- 提交消息格式:`feat: <中文摘要>`,多行 body 按模块分区列举变更
- 示例:`feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构`
- PR 需描述变更内容与原因UI 变更附截图,关联相关 issue

31
plugins/README.md Normal file
View File

@ -0,0 +1,31 @@
# Expo Config Plugins
本目录包含所有原生模块的 Expo Config Plugin。由于 `android/` 目录由 `expo prebuild` 自动生成且被 Git 忽略,所有原生代码必须以 Config Plugin 形式存在。
## 插件结构
```
plugins/<name>/
app.plugin.js # prebuild 时执行,注入 Kotlin/XML、修改 gradle、注册服务
package.json # 插件元数据
android/*.kt # 原生 Kotlin 代码
assets/ # 模型文件等静态资源(仅 ppocr
```
## 当前插件
| 插件 | 功能 | 备注 |
|------|------|------|
| `ppocr` | PP-OCRv6 检测+识别 | ONNX Runtime模型在 `assets/` |
| `accessibility` | 无障碍服务账单抓取 + 悬浮窗 + OCR 贴片 | 仅侧载场景 |
| `notification-listener` | 通知栏账单监听 | |
| `sms-receiver` | 短信账单解析 | |
| `screenshot-monitor` | 截图触发 OCR | |
| `size-optimization` | ABI 分包 + NDK 版本统一 | 仅影响 gradle 配置 |
## 开发须知
1. 每个插件函数**必须 `return config`**,否则后续插件会崩溃
2. 修改插件后需重新运行 `npx expo prebuild --platform android`
3. 原生服务通过 `NativeEventEmitter` 向 JS 层推送事件,**不直接写入**数据库或文件
4. `ppocr` 依赖 `onnxruntime-android:1.20.0`prebuild 后请验证版本

View File

@ -1,5 +1,6 @@
package com.beancount.mobile.accessibility package com.beancount.mobile.accessibility
import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReactMethod
@ -7,6 +8,7 @@ import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.WritableNativeArray import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
/** /**
* 无障碍服务 RN 桥接模块plan.md3.6 无障碍服务JS 接线 * 无障碍服务 RN 桥接模块plan.md3.6 无障碍服务JS 接线
@ -20,6 +22,10 @@ import com.facebook.react.bridge.ReadableArray
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) : class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) { ReactContextBaseJavaModule(reactContext) {
companion object {
private const val TAG = "AccessibilityBridge"
}
init { init {
ReactContextHolder.context = reactContext ReactContextHolder.context = reactContext
} }
@ -188,17 +194,18 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 显示账单浮窗(直接在当前其他应用上方渲染,不返回 App 内) */ /** 显示账单浮窗(直接在当前其他应用上方渲染,不返回 App 内) */
@ReactMethod @ReactMethod
fun showFloatingBill( fun showFloatingBill(
amount: String, amount: String,
merchant: String, merchant: String,
time: String, time: String,
packageName: String, packageName: String,
categories: ReadableArray, categories: ReadableArray,
accounts: ReadableArray, accounts: ReadableArray,
direction: String, direction: String,
currency: String,
draftId: String, draftId: String,
promise: Promise promise: Promise
) { ) {
val context = reactContext.currentActivity ?: BillingAccessibilityService.instance val context = BillingAccessibilityService.instance ?: reactContext.currentActivity
if (context == null) { if (context == null) {
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例") promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
return return
@ -222,7 +229,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
android.os.Handler(android.os.Looper.getMainLooper()).post { android.os.Handler(android.os.Looper.getMainLooper()).post {
try { try {
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction) val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction, currency)
floatingView.show() floatingView.show()
promise.resolve(true) promise.resolve(true)
} catch (e: Exception) { } catch (e: Exception) {
@ -247,4 +254,71 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
} }
promise.resolve(true) promise.resolve(true)
} }
/** 从 APK assets 复制 OCR 模型文件到 filesDir/ocr_models_v6。 */
@ReactMethod
fun copyOcrModelsFromAssets(promise: Promise) {
try {
val destDir = java.io.File(reactContext.filesDir, "ocr_models_v6")
if (!destDir.exists()) destDir.mkdirs()
val assetManager = reactContext.assets
val files = listOf("ppocrv6_det.onnx", "ppocrv6_rec.onnx", "ppocrv6_dict.txt")
for (filename in files) {
val destFile = java.io.File(destDir, filename)
if (destFile.exists() && destFile.length() > 0) continue
assetManager.open(filename).use { input ->
java.io.FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
}
promise.resolve(destDir.absolutePath)
} catch (e: Exception) {
promise.reject("COPY_MODEL_FAIL", e.message)
}
}
/** 检查通知监听服务是否已启用(反射调用 getEnabledListenerPackages避免 API level lint 错误)。 */
@ReactMethod
fun isNotificationListenerEnabled(promise: Promise) {
try {
// 通过 Settings.Secure 读取系统设置(公开 API无需反射不受 hidden API 限制)
// enabled_notification_listeners 格式:
// "com.pkg1/com.pkg1.Service:com.pkg2/com.pkg2.Service"
val flat = android.provider.Settings.Secure.getString(
reactContext.contentResolver,
"enabled_notification_listeners"
) ?: ""
val myPkg = reactContext.packageName
promise.resolve(flat.split(":").any { it.startsWith(myPkg) })
} catch (e: Exception) {
Log.w(TAG, "isNotificationListenerEnabled 检测失败", e)
promise.resolve(false)
}
}
/** 检查悬浮窗权限SYSTEM_ALERT_WINDOW是否已授予。 */
@ReactMethod
fun canDrawOverlays(promise: Promise) {
try {
promise.resolve(android.provider.Settings.canDrawOverlays(reactContext))
} catch (e: Exception) {
promise.resolve(false)
}
}
/**
* 下发浮层 UI 配置颜色 + 文案
* JS 侧从 theme tokens + i18n 构建 config
* 原生存 SharedPreferences三个浮层组件读取
*/
@ReactMethod
fun setFloatingUiConfig(config: ReadableMap, promise: Promise) {
try {
FloatingUiConfigStore.save(reactContext, config)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("CONFIG_SAVE_FAIL", e.message)
}
}
} }

View File

@ -192,7 +192,7 @@ class BillingAccessibilityService : AccessibilityService() {
handler.post { handler.post {
val shouldShow = floatingBallEnabled val shouldShow = floatingBallEnabled
&& pkg != null && pkg != null
&& !filterPackage(pkg, "") && !filterPackage(pkg, topActivity ?: "")
&& !isLandscape() && !isLandscape()
if (shouldShow) { if (shouldShow) {
@ -380,11 +380,12 @@ class BillingAccessibilityService : AccessibilityService() {
val pkg = topPackage ?: return val pkg = topPackage ?: return
val activity = topActivity ?: "" val activity = topActivity ?: ""
val sig = "$pkg|$activity" val sig = "$pkg|$activity"
val uiLabels = FloatingUiConfigStore.load(this).labels
if (pageSignatures.add(sig)) { if (pageSignatures.add(sig)) {
savePageSignatures() savePageSignatures()
Log.i(TAG, "已记住页面: $sig") Log.i(TAG, "已记住页面: $sig")
handler.post { handler.post {
android.widget.Toast.makeText(this, "已记住页面签名:\n$sig", android.widget.Toast.LENGTH_LONG).show() android.widget.Toast.makeText(this, "${uiLabels.pageRemembered}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
} }
// 抓取并提取屏幕所有无障碍文本 // 抓取并提取屏幕所有无障碍文本
@ -416,7 +417,7 @@ class BillingAccessibilityService : AccessibilityService() {
} }
} else { } else {
handler.post { handler.post {
android.widget.Toast.makeText(this, "该页面签名已存在:\n$sig", android.widget.Toast.LENGTH_LONG).show() android.widget.Toast.makeText(this, "${uiLabels.pageSignatureExists}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
} }
} }
} }
@ -507,6 +508,7 @@ class BillingAccessibilityService : AccessibilityService() {
val p = pkg.lowercase() val p = pkg.lowercase()
// 自身 App只放行 MainActivity避免悬浮球容器触发自毁式关闭 // 自身 App只放行 MainActivity避免悬浮球容器触发自毁式关闭
if (pkg == packageName) { if (pkg == packageName) {
if (className.isEmpty()) return false
return !className.endsWith(".MainActivity") return !className.endsWith(".MainActivity")
} }
// 桌面启动器(悬浮球在桌面无意义) // 桌面启动器(悬浮球在桌面无意义)

View File

@ -17,9 +17,11 @@ import android.widget.LinearLayout
import android.text.InputType import android.text.InputType
import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.modules.core.DeviceEventManagerModule
import java.math.BigDecimal
/** /**
* 浮窗账单提示直接呈现高度优化支持三方向切换的修改入账面板 * 浮窗账单提示直接呈现高度优化支持三方向切换的修改入账面板
* P6 重设计颜色/文案走 FloatingUiConfigStore新增币种 chip金额校验改正则+BigDecimal
*/ */
class FloatingBillView( class FloatingBillView(
private val context: Context, private val context: Context,
@ -30,12 +32,27 @@ class FloatingBillView(
private val packageName: String, private val packageName: String,
private val categories: List<Map<String, String>> = emptyList(), private val categories: List<Map<String, String>> = emptyList(),
private val accounts: List<String> = emptyList(), private val accounts: List<String> = emptyList(),
private val initialDirection: String = "expense" private val initialDirection: String = "expense",
private val initialCurrency: String = "CNY"
) { ) {
companion object { companion object {
private const val TAG = "FloatingBillView" private const val TAG = "FloatingBillView"
private val DEFAULT_CURRENCY_LIST = listOf("CNY", "USD", "HKD", "JPY", "EUR", "GBP")
} }
// 从 JS 下发的配置读取颜色与文案(缺失字段回退默认值,保证旧 JS 行为不变)
private val config = FloatingUiConfigStore.load(context)
// 显示币种列表;若 initialCurrency 不在默认列表中则临时插入首位
private val currencyList: List<String> = run {
val list = DEFAULT_CURRENCY_LIST.toMutableList()
if (!list.contains(initialCurrency)) {
list.add(0, initialCurrency)
}
list
}
private var currentCurrency: String = initialCurrency
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private var view: View? = null private var view: View? = null
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
@ -49,6 +66,20 @@ class FloatingBillView(
private var accountContainer: LinearLayout? = null private var accountContainer: LinearLayout? = null
private var saveBtn: Button? = null private var saveBtn: Button? = null
// 预解析常用颜色parseColorOr 异常时回退默认值)
private val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
private val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
private val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
private val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
private val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
private val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
private val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
private val colorIncome = FloatingUiConfigStore.parseColorOr(config.colors.income, "#FF10B981")
private val colorExpense = FloatingUiConfigStore.parseColorOr(config.colors.expense, "#FFE11D48")
private val colorTransfer = FloatingUiConfigStore.parseColorOr(config.colors.transfer, "#FF10B981")
// 容器边框直接使用 config border不再从 accent 派生 alpha
private fun dp(value: Float): Int { private fun dp(value: Float): Int {
val density = context.resources.displayMetrics.density val density = context.resources.displayMetrics.density
return (value * density).toInt() return (value * density).toInt()
@ -74,29 +105,34 @@ class FloatingBillView(
currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense" currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense"
// 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部 // 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部
val overlayType = if (context is android.accessibilityservice.AccessibilityService) {
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY
} else {
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
}
val windowParams = WindowManager.LayoutParams( val windowParams = WindowManager.LayoutParams(
dp(310f), dp(310f),
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, overlayType,
0, // 0 标志代表可获取焦点 0, // 0 标志代表可获取焦点
PixelFormat.TRANSLUCENT PixelFormat.TRANSLUCENT
).apply { ).apply {
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
y = dp(12f) // 尽量靠下以留出上方账单对照 y = dp(48f) // 留出底部导航栏空间 + 按钮/键盘安全
} }
// 2. 使用 55% 透明度 OLED 磨砂效果背景,发光靛蓝细边框 // 2. 实色卡片背景(不再半透明磨砂),圆角 16dp1dp 描边
val containerBg = GradientDrawable().apply { val containerBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE shape = GradientDrawable.RECTANGLE
cornerRadius = dp(14f).toFloat() cornerRadius = dp(16f).toFloat()
setColor(0x8C050506.toInt()) // 55% 透明度 OLED 黑色 setColor(colorCardBg)
setStroke(dp(1.2f), 0x995E6AD2.toInt()) // 60% 透明度靛蓝发光边框 setStroke(dp(1f), colorBorder) // 使用 config border 颜色
} }
val container = LinearLayout(context).apply { val container = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
background = containerBg background = containerBg
setPadding(dp(12f), dp(8f), dp(12f), dp(8f)) setPadding(dp(16f), dp(16f), dp(16f), dp(16f))
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT LinearLayout.LayoutParams.WRAP_CONTENT
@ -114,9 +150,9 @@ class FloatingBillView(
} }
val titleText = TextView(context).apply { val titleText = TextView(context).apply {
text = "调整交易草稿" text = config.labels.billTitle
textSize = 12f textSize = 13f
setTextColor(0xFF98A2FF.toInt()) setTextColor(colorFgPrimary)
paint.isFakeBoldText = true paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
} }
@ -126,12 +162,12 @@ class FloatingBillView(
val segmentContainer = LinearLayout(context).apply { val segmentContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
background = GradientDrawable().apply { background = GradientDrawable().apply {
cornerRadius = dp(4f).toFloat() cornerRadius = dp(6f).toFloat()
setColor(0x20FFFFFF.toInt()) // 12% white opacity setColor((colorFgPrimary and 0x00FFFFFF) or 0x12000000) // 7% fg overlay
} }
} }
val tabTexts = listOf("支出", "收入", "转账") val tabTexts = listOf(config.labels.dirExpense, config.labels.dirIncome, config.labels.dirTransfer)
val tabDirections = listOf("expense", "income", "transfer") val tabDirections = listOf("expense", "income", "transfer")
val tabViews = mutableListOf<TextView>() val tabViews = mutableListOf<TextView>()
@ -139,11 +175,11 @@ class FloatingBillView(
for (i in tabViews.indices) { for (i in tabViews.indices) {
val active = tabDirections[i] == currentDirection val active = tabDirections[i] == currentDirection
tabViews[i].apply { tabViews[i].apply {
setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) setTextColor(if (active) colorAccentFg else colorFgSecondary)
background = if (active) { background = if (active) {
GradientDrawable().apply { GradientDrawable().apply {
cornerRadius = dp(4f).toFloat() cornerRadius = dp(6f).toFloat()
setColor(0xFF5E6AD2.toInt()) // Indigo highlight setColor(colorAccent)
} }
} else { } else {
null null
@ -173,40 +209,75 @@ class FloatingBillView(
headerLayout.addView(segmentContainer) headerLayout.addView(segmentContainer)
container.addView(headerLayout) container.addView(headerLayout)
// 金额编辑 // 金额行:币种 chip+ 金额输入框(右)
val amountLabel = TextView(context).apply { val amountLabel = TextView(context).apply {
text = "金额" text = config.labels.amountLabel
textSize = 9f textSize = 10f
paint.isFakeBoldText = true paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt()) setTextColor(colorFgSecondary)
setPadding(0, dp(4f), 0, dp(1f)) setPadding(0, dp(8f), 0, dp(1f))
} }
container.addView(amountLabel) container.addView(amountLabel)
val amountInput = EditText(context).apply { val amountRow = LinearLayout(context).apply {
textSize = 13f orientation = LinearLayout.HORIZONTAL
setTextColor(0xFFFFFFFF.toInt()) gravity = Gravity.CENTER_VERTICAL
background = GradientDrawable().apply {
setColor(0x4012131A.toInt()) // 25% 半透底色
cornerRadius = dp(6f).toFloat()
setStroke(dp(1f), 0x80222433.toInt())
}
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT LinearLayout.LayoutParams.WRAP_CONTENT
) )
} }
container.addView(amountInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) }) // 币种 chip
val currencyChip = TextView(context).apply {
text = currentCurrency
textSize = 11f
setTextColor(colorFgSecondary)
setPadding(dp(8f), dp(4f), dp(8f), dp(4f))
gravity = Gravity.CENTER
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
setOnClickListener {
val idx = currencyList.indexOf(currentCurrency)
currentCurrency = currencyList[(idx + 1) % currencyList.size]
text = currentCurrency
}
}
amountRow.addView(currencyChip)
val amountInput = EditText(context).apply {
textSize = 13f
setTextColor(colorFgPrimary)
setHintTextColor(colorFgSecondary)
background = GradientDrawable().apply {
setColor(colorInputBg)
cornerRadius = dp(10f).toFloat()
setStroke(dp(1f), colorBorder)
}
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
layoutParams = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
).apply {
leftMargin = dp(8f)
}
}
amountRow.addView(amountInput)
container.addView(amountRow)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
// 商户编辑 // 商户编辑
val merchantLabel = TextView(context).apply { val merchantLabel = TextView(context).apply {
text = "交易对手" text = config.labels.payeeLabel
textSize = 9f textSize = 10f
paint.isFakeBoldText = true paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt()) setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(1f)) setPadding(0, 0, 0, dp(1f))
} }
container.addView(merchantLabel) container.addView(merchantLabel)
@ -214,56 +285,57 @@ class FloatingBillView(
val merchantInput = EditText(context).apply { val merchantInput = EditText(context).apply {
setText(merchant) setText(merchant)
textSize = 12f textSize = 12f
setTextColor(0xFFFFFFFF.toInt()) setTextColor(colorFgPrimary)
setHintTextColor(colorFgSecondary)
background = GradientDrawable().apply { background = GradientDrawable().apply {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
cornerRadius = dp(6f).toFloat() cornerRadius = dp(10f).toFloat()
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
setPadding(dp(10f), dp(4f), dp(10f), dp(4f)) setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT LinearLayout.LayoutParams.WRAP_CONTENT
) )
} }
container.addView(merchantInput) container.addView(merchantInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) }) container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
// 叙述备注编辑 // 叙述备注编辑
val narrationLabel = TextView(context).apply { val narrationLabel = TextView(context).apply {
text = "描述/备注" text = config.labels.narrationLabel
textSize = 9f textSize = 10f
paint.isFakeBoldText = true paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt()) setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(1f)) setPadding(0, 0, 0, dp(1f))
} }
container.addView(narrationLabel) container.addView(narrationLabel)
val narrationInput = EditText(context).apply { val narrationInput = EditText(context).apply {
hint = "输入交易叙述" hint = config.labels.narrationHint
setHintTextColor(0xFF6B7280.toInt()) setHintTextColor(colorFgSecondary)
textSize = 12f textSize = 12f
setTextColor(0xFFFFFFFF.toInt()) setTextColor(colorFgPrimary)
background = GradientDrawable().apply { background = GradientDrawable().apply {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
cornerRadius = dp(6f).toFloat() cornerRadius = dp(10f).toFloat()
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
setPadding(dp(10f), dp(4f), dp(10f), dp(4f)) setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT LinearLayout.LayoutParams.WRAP_CONTENT
) )
} }
container.addView(narrationInput) container.addView(narrationInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) }) container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
// Row 1 分类/转入选择 // Row 1 分类/转入选择
categoryLabel = TextView(context).apply { categoryLabel = TextView(context).apply {
text = "交易分类" text = config.labels.categoryExpense
textSize = 9f textSize = 10f
paint.isFakeBoldText = true paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt()) setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(2f)) setPadding(0, 0, 0, dp(2f))
} }
container.addView(categoryLabel) container.addView(categoryLabel)
@ -281,14 +353,14 @@ class FloatingBillView(
) )
} }
container.addView(categoryScroll) container.addView(categoryScroll)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) }) container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
// Row 2 资金出入账户选择 // Row 2 资金出入账户选择
accountLabel = TextView(context).apply { accountLabel = TextView(context).apply {
text = "资金来源账户" text = config.labels.accountExpense
textSize = 9f textSize = 10f
paint.isFakeBoldText = true paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt()) setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(2f)) setPadding(0, 0, 0, dp(2f))
} }
container.addView(accountLabel) container.addView(accountLabel)
@ -306,7 +378,7 @@ class FloatingBillView(
) )
} }
container.addView(accountScroll) container.addView(accountScroll)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(8f)) }) container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(10f)) })
// 底部操作栏 // 底部操作栏
val btnContainer = LinearLayout(context).apply { val btnContainer = LinearLayout(context).apply {
@ -320,16 +392,16 @@ class FloatingBillView(
// 1) 打开应用按钮 // 1) 打开应用按钮
val openAppBtn = Button(context).apply { val openAppBtn = Button(context).apply {
text = "打开应用" text = config.labels.openApp
setTextColor(0xFFD1D5DB.toInt()) setTextColor(colorFgPrimary)
background = GradientDrawable().apply { background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat() cornerRadius = dp(12f).toFloat()
setColor(0xFF1F2937.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0xFF374151.toInt()) setStroke(dp(1f), colorBorder)
} }
textSize = 11f textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) } layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener { setOnClickListener {
val newAmount = amountInput.text.toString().trim() val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim() val newPayee = merchantInput.text.toString().trim()
@ -342,18 +414,17 @@ class FloatingBillView(
} }
// 2) 忽略按钮 // 2) 忽略按钮
val cancelBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat()
setColor(0xFF1F2937.toInt())
setStroke(dp(1f), 0xFF374151.toInt())
}
val cancelBtn = Button(context).apply { val cancelBtn = Button(context).apply {
text = "忽略" text = config.labels.dismiss
setTextColor(0xFFD1D5DB.toInt()) setTextColor(colorFgPrimary)
background = cancelBg background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
textSize = 11f textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) } layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener { setOnClickListener {
sendCancelEvent() sendCancelEvent()
dismiss() dismiss()
@ -361,18 +432,17 @@ class FloatingBillView(
} }
// 3) 确认入账按钮 // 3) 确认入账按钮
val saveBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat()
setColor(0xFF5E6AD2.toInt())
}
saveBtn = Button(context).apply { saveBtn = Button(context).apply {
text = "确认入账" text = config.labels.confirm
setTextColor(0xFFFFFFFF.toInt()) setTextColor(colorAccentFg)
background = saveBg background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorAccent)
}
textSize = 11f textSize = 11f
paint.isFakeBoldText = true paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1.3f) layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1.3f)
setOnClickListener { setOnClickListener {
val newAmount = amountInput.text.toString().trim() val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim() val newPayee = merchantInput.text.toString().trim()
@ -388,21 +458,23 @@ class FloatingBillView(
btnContainer.addView(saveBtn) btnContainer.addView(saveBtn)
container.addView(btnContainer) container.addView(btnContainer)
// 构建金额安全校验 // 金额安全校验BigDecimal + 正则)
val amountWatcher = object : android.text.TextWatcher { val amountWatcher = object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: android.text.Editable?) { override fun afterTextChanged(s: android.text.Editable?) {
try { try {
val text = s?.toString()?.trim() ?: "" val text = s?.toString()?.trim() ?: ""
val value = text.toDoubleOrNull() val pattern = Regex("^\\d+(\\.\\d{1,2})?$")
val isValid = value != null && !value.isNaN() && !value.isInfinite() && value > 0.0 val value = text.toBigDecimalOrNull()
val isValid = pattern.matches(text) && value != null && value > BigDecimal.ZERO
saveBtn?.isEnabled = isValid saveBtn?.isEnabled = isValid
saveBtn?.background = GradientDrawable().apply { saveBtn?.background = GradientDrawable().apply {
cornerRadius = dp(8f).toFloat() shape = GradientDrawable.RECTANGLE
setColor(if (isValid) 0xFF5E6AD2.toInt() else 0xFF374151.toInt()) cornerRadius = dp(12f).toFloat()
setColor(if (isValid) colorAccent else colorInputBg)
} }
saveBtn?.setTextColor(if (isValid) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) saveBtn?.setTextColor(if (isValid) colorAccentFg else colorFgSecondary)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "金额校验异常", e) Log.e(TAG, "金额校验异常", e)
saveBtn?.isEnabled = false saveBtn?.isEnabled = false
@ -419,7 +491,7 @@ class FloatingBillView(
view = container view = container
windowManager.addView(view, windowParams) windowManager.addView(view, windowParams)
Log.i(TAG, "悬浮修改记账面板已显示: ¥$amount") Log.i(TAG, "悬浮修改记账面板已显示: $currentCurrency $amount")
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器 // 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus -> val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
@ -438,11 +510,11 @@ class FloatingBillView(
false false
} }
// 15 秒无操作自动消失(如果用户没有交互的话) // 30 秒无操作自动消失(如果用户没有交互的话)
handler.postDelayed({ handler.postDelayed({
sendCancelEvent() sendCancelEvent()
dismiss() dismiss()
}, 15000L) }, 30000L)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e) Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
@ -459,7 +531,7 @@ class FloatingBillView(
// === 1. 绘制第一行 (分类 / 转入) === // === 1. 绘制第一行 (分类 / 转入) ===
if (currentDirection == "expense") { if (currentDirection == "expense") {
categoryLabel?.text = "交易分类" categoryLabel?.text = config.labels.categoryExpense
val filteredCats = categories.filter { it["type"] == "expense" } val filteredCats = categories.filter { it["type"] == "expense" }
for (cat in filteredCats) { for (cat in filteredCats) {
val catAccount = cat["account"] ?: "" val catAccount = cat["account"] ?: ""
@ -470,14 +542,14 @@ class FloatingBillView(
for (pair in categoryChips) { for (pair in categoryChips) {
val active = pair.first == selected val active = pair.first == selected
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt()) if (active) setColor(colorAccent)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} }
@ -492,18 +564,18 @@ class FloatingBillView(
categoryChips.forEach { pair -> categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt()) if (active) setColor(colorAccent)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} else if (currentDirection == "income") { } else if (currentDirection == "income") {
categoryLabel?.text = "收入分类" categoryLabel?.text = config.labels.categoryIncome
val filteredCats = categories.filter { it["type"] == "income" } val filteredCats = categories.filter { it["type"] == "income" }
for (cat in filteredCats) { for (cat in filteredCats) {
val catAccount = cat["account"] ?: "" val catAccount = cat["account"] ?: ""
@ -514,14 +586,14 @@ class FloatingBillView(
for (pair in categoryChips) { for (pair in categoryChips) {
val active = pair.first == selected val active = pair.first == selected
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt()) if (active) setColor(colorAccent)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} }
@ -536,19 +608,19 @@ class FloatingBillView(
categoryChips.forEach { pair -> categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt()) if (active) setColor(colorAccent)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} else { } else {
// transfer // transfer — 第一行 = 转入账户,选中色 = transfer
categoryLabel?.text = "转入账户" categoryLabel?.text = config.labels.transferTarget
for (acct in accounts) { for (acct in accounts) {
val shortName = acct.split(":").lastOrNull() ?: acct val shortName = acct.split(":").lastOrNull() ?: acct
val chip = createChipView(shortName) val chip = createChipView(shortName)
@ -557,14 +629,14 @@ class FloatingBillView(
for (pair in categoryChips) { for (pair in categoryChips) {
val active = pair.first == selected val active = pair.first == selected
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) setColor(0xFF10B981.toInt()) if (active) setColor(colorTransfer)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} }
@ -579,26 +651,29 @@ class FloatingBillView(
categoryChips.forEach { pair -> categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) setColor(0xFF10B981.toInt()) if (active) setColor(colorTransfer)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} }
// === 2. 绘制第二行 (资金账户) === // === 2. 绘制第二行 (资金账户) ===
if (currentDirection == "expense") { if (currentDirection == "expense") {
accountLabel?.text = "资金来源" accountLabel?.text = config.labels.accountExpense
} else if (currentDirection == "income") { } else if (currentDirection == "income") {
accountLabel?.text = "存入账户" accountLabel?.text = config.labels.accountIncome
} else { } else {
accountLabel?.text = "转出账户" accountLabel?.text = config.labels.accountTransfer
} }
// 账户行选中色按方向expense→expense色income→income色transfer→expense色
val accountSelectedColor = if (currentDirection == "income") colorIncome else colorExpense
for (acct in accounts) { for (acct in accounts) {
val shortName = acct.split(":").lastOrNull() ?: acct val shortName = acct.split(":").lastOrNull() ?: acct
val chip = createChipView(shortName) val chip = createChipView(shortName)
@ -607,15 +682,15 @@ class FloatingBillView(
for (pair in accountChips) { for (pair in accountChips) {
val active = pair.first == selected val active = pair.first == selected
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) { if (active) {
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt()) setColor(accountSelectedColor)
} else { } else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} }
@ -631,14 +706,14 @@ class FloatingBillView(
for (p in categoryChips) { for (p in categoryChips) {
val act = p.first == selectedCategoryAccount val act = p.first == selectedCategoryAccount
p.second.background = GradientDrawable().apply { p.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (act) setColor(0xFF10B981.toInt()) if (act) setColor(colorTransfer)
else { else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
p.second.setTextColor(if (act) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) p.second.setTextColor(if (act) colorAccentFg else colorFgSecondary)
} }
} }
} }
@ -651,15 +726,15 @@ class FloatingBillView(
accountChips.forEach { pair -> accountChips.forEach { pair ->
val active = pair.first == selectedSourceAccount val active = pair.first == selectedSourceAccount
pair.second.background = GradientDrawable().apply { pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat() cornerRadius = dp(14f).toFloat()
if (active) { if (active) {
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt()) setColor(accountSelectedColor)
} else { } else {
setColor(0x4012131A.toInt()) setColor(colorInputBg)
setStroke(dp(1f), 0x80222433.toInt()) setStroke(dp(1f), colorBorder)
} }
} }
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt()) pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
} }
} }
@ -677,6 +752,7 @@ class FloatingBillView(
putString("time", time) putString("time", time)
putString("direction", currentDirection) putString("direction", currentDirection)
putString("packageName", packageName) putString("packageName", packageName)
putString("currency", currentCurrency)
putBoolean("confirmed", true) putBoolean("confirmed", true)
putBoolean("editRequested", false) putBoolean("editRequested", false)
putBoolean("isManualEdit", true) putBoolean("isManualEdit", true)
@ -703,6 +779,7 @@ class FloatingBillView(
putString("time", time) putString("time", time)
putString("direction", currentDirection) putString("direction", currentDirection)
putString("packageName", packageName) putString("packageName", packageName)
putString("currency", currentCurrency)
} }
reactContext reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
@ -736,4 +813,13 @@ class FloatingBillView(
} catch (_: Exception) {} } catch (_: Exception) {}
view = null view = null
} }
/** Kotlin 中缺失的 toBigDecimalOrNull 扩展。 */
private fun String.toBigDecimalOrNull(): BigDecimal? {
return try {
BigDecimal(this)
} catch (_: Exception) {
null
}
}
} }

View File

@ -28,6 +28,9 @@ class FloatingHelper(
) { ) {
companion object { companion object {
private const val TAG = "FloatingHelper" private const val TAG = "FloatingHelper"
private const val PREFS_NAME = "billing_accessibility_prefs"
private const val KEY_X = "floating_ball_x"
private const val KEY_Y = "floating_ball_y"
private var lastX = 0 private var lastX = 0
private var lastY = 400 private var lastY = 400
} }
@ -41,7 +44,7 @@ class FloatingHelper(
private val params = WindowManager.LayoutParams( private val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
PixelFormat.TRANSLUCENT PixelFormat.TRANSLUCENT
).apply { ).apply {
@ -57,6 +60,25 @@ class FloatingHelper(
try { try {
val context = service val context = service
// 加载浮层 UI 配置(颜色与文案均从 JS 下发的 config 读取,未下发时回退默认值)
val config = FloatingUiConfigStore.load(service)
// 预解析颜色
val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
// 位置持久化:从 SharedPreferences 读取,回退到 companion 缓存
val prefs = service.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
params.x = prefs.getInt(KEY_X, lastX)
params.y = prefs.getInt(KEY_Y, lastY)
lastX = params.x
lastY = params.y
val density = service.resources.displayMetrics.density val density = service.resources.displayMetrics.density
fun dp(value: Float) = (value * density).toInt() fun dp(value: Float) = (value * density).toInt()
@ -79,25 +101,31 @@ class FloatingHelper(
layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f)) layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f))
} }
// 边缘指示竖线accent 色取 RGB + 固定 0xB0 alpha保持 70% 透明)
val indicatorColor = (colorAccent and 0x00FFFFFF) or 0xB0000000.toInt()
val indicatorView = View(context).apply { val indicatorView = View(context).apply {
background = GradientDrawable().apply { background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE shape = GradientDrawable.RECTANGLE
cornerRadius = dp(1.5f).toFloat() // 高度圆润 cornerRadius = dp(1.5f).toFloat()
setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框 setColor(indicatorColor)
} }
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply { layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
gravity = Gravity.CENTER_VERTICAL or Gravity.START // 贴左边缘 gravity = Gravity.CENTER_VERTICAL or Gravity.START
} }
} }
bubbleLayout.addView(indicatorView) bubbleLayout.addView(indicatorView)
bubbleView = bubbleLayout bubbleView = bubbleLayout
// 3. 创建展开菜单 (垂直布局,高紧凑度设计) // 3. 创建展开菜单 (垂直布局,高紧凑度设计)
// 菜单背景:实色 cardBgper plan §T4
val menuBgColor = colorCardBg
// 菜单描边border 色 + 半透明 alpha
val menuBorderColor = (colorBorder and 0x00FFFFFF) or 0x22000000.toInt()
val menuBg = GradientDrawable().apply { val menuBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE shape = GradientDrawable.RECTANGLE
cornerRadius = dp(10f).toFloat() cornerRadius = dp(10f).toFloat()
setColor(0xA611131E.toInt()) // 65% OLED high-transparency black setColor(menuBgColor)
setStroke(dp(0.8f), 0x22FFFFFF.toInt()) // subtle 13% white border setStroke(dp(0.8f), menuBorderColor)
} }
menuView = LinearLayout(context).apply { menuView = LinearLayout(context).apply {
@ -107,30 +135,37 @@ class FloatingHelper(
setPadding(dp(4f), dp(4f), dp(4f), dp(4f)) setPadding(dp(4f), dp(4f), dp(4f), dp(4f))
visibility = View.GONE visibility = View.GONE
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
dp(96f), // ultra-compact width: 96dp dp(96f),
LinearLayout.LayoutParams.WRAP_CONTENT LinearLayout.LayoutParams.WRAP_CONTENT
).apply { ).apply {
leftMargin = dp(4f) // spacing with indicator line leftMargin = dp(4f)
} }
} }
// Vector icons // Vector icons
val sizePx = dp(14f) val sizePx = dp(14f)
val strokePx = dp(1.4f).toFloat() val strokePx = dp(1.4f).toFloat()
val ocrIcon = ScanIconDrawable(0xFF818CF8.toInt(), strokePx, 0xFFF87171.toInt(), sizePx) // ScanIconDrawableaccentFg 色(在 accent 底按钮上可见laserColor 保留红色语义
val pinIcon = PinIconDrawable(0xFF34D399.toInt(), strokePx, sizePx) val ocrIcon = ScanIconDrawable(colorAccentFg, strokePx, 0xFFF87171.toInt(), sizePx)
// PinIconDrawablefgSecondary 色
val pinIcon = PinIconDrawable(colorFgSecondary, strokePx, sizePx)
// Button 1: 识别账单主操作accent 底 accentFg 字)
// 背景常时accent 色取 RGB + 固定 0x1F alpha约 12%
val btnOcrBgNormal = (colorAccent and 0x00FFFFFF) or 0x1F000000.toInt()
// 按下加深accent 色取 RGB + 固定 0x40 alpha约 25%
val btnOcrBgPressed = (colorAccent and 0x00FFFFFF) or 0x40000000.toInt()
// Button 1: "识别账单"
val btnOcr = TextView(context).apply { val btnOcr = TextView(context).apply {
text = "识别账单" text = config.labels.ballOcr
setTextColor(0xFFFFFFFF.toInt()) setTextColor(colorAccentFg)
textSize = 11f textSize = 11f
paint.isFakeBoldText = true paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0) setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply { background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat() cornerRadius = dp(7f).toFloat()
setColor(0x1F5E6AD2.toInt()) // translucent indigo background setColor(btnOcrBgNormal)
} }
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
@ -145,13 +180,13 @@ class FloatingHelper(
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply { view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat() cornerRadius = dp(7f).toFloat()
setColor(0x405E6AD2.toInt()) setColor(btnOcrBgPressed)
} }
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply { view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat() cornerRadius = dp(7f).toFloat()
setColor(0x1F5E6AD2.toInt()) setColor(btnOcrBgNormal)
} }
if (event.action == MotionEvent.ACTION_UP) { if (event.action == MotionEvent.ACTION_UP) {
view.performClick() view.performClick()
@ -165,17 +200,22 @@ class FloatingHelper(
} }
} }
// Button 2: "记住此页" // Button 2: 记住此页次要操作inputBg 底 fgPrimary 字per plan §T4
// 背景常时inputBg 色取 RGB + 固定 0x15 alpha
val btnRememberBgNormal = (colorInputBg and 0x00FFFFFF) or 0x15000000.toInt()
// 按下加深inputBg 色取 RGB + 固定 0x30 alpha
val btnRememberBgPressed = (colorInputBg and 0x00FFFFFF) or 0x30000000.toInt()
val btnRemember = TextView(context).apply { val btnRemember = TextView(context).apply {
text = "记住此页" text = config.labels.ballRemember
setTextColor(0xFFE5E7EB.toInt()) setTextColor(colorFgPrimary)
textSize = 11f textSize = 11f
paint.isFakeBoldText = true paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0) setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply { background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat() cornerRadius = dp(7f).toFloat()
setColor(0x15FFFFFF.toInt()) // subtle translucent gray setColor(btnRememberBgNormal)
} }
layoutParams = LinearLayout.LayoutParams( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
@ -188,13 +228,13 @@ class FloatingHelper(
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply { view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat() cornerRadius = dp(7f).toFloat()
setColor(0x30FFFFFF.toInt()) setColor(btnRememberBgPressed)
} }
} }
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply { view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat() cornerRadius = dp(7f).toFloat()
setColor(0x15FFFFFF.toInt()) setColor(btnRememberBgNormal)
} }
if (event.action == MotionEvent.ACTION_UP) { if (event.action == MotionEvent.ACTION_UP) {
view.performClick() view.performClick()
@ -206,9 +246,9 @@ class FloatingHelper(
setOnClickListener { setOnClickListener {
try { try {
service.rememberCurrentPage() service.rememberCurrentPage()
FloatingTip(service, "📌 已将当前页面加入识别白名单!", FloatingTip.TipPosition.TOP, 2500L).show() FloatingTip(service, config.labels.rememberSuccess, FloatingTip.TipPosition.TOP, 2500L).show()
} catch (e: Exception) { } catch (e: Exception) {
FloatingTip(service, "记录失败: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show() FloatingTip(service, "${config.labels.rememberFail}: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show()
} }
collapse() collapse()
} }
@ -246,7 +286,7 @@ class FloatingHelper(
params.x = initialX + dx params.x = initialX + dx
params.y = initialY + dy params.y = initialY + dy
containerView?.let { windowManager.updateViewLayout(it, params) } containerView?.let { windowManager.updateViewLayout(it, params) }
lastX = params.x lastX = params.x
lastY = params.y lastY = params.y
true true
@ -257,14 +297,20 @@ class FloatingHelper(
} else { } else {
// 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘) // 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘)
if (params.x < service.resources.displayMetrics.widthPixels / 2) { if (params.x < service.resources.displayMetrics.widthPixels / 2) {
params.x = -dp(1.5f) // 指示器 3dp 宽,中心对齐边缘 params.x = -dp(1.5f)
} else { } else {
params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f) params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f)
} }
containerView?.let { windowManager.updateViewLayout(it, params) } containerView?.let { windowManager.updateViewLayout(it, params) }
lastX = params.x lastX = params.x
lastY = params.y lastY = params.y
// 位置持久化:吸附后写入 SharedPreferences
prefs.edit()
.putInt(KEY_X, params.x)
.putInt(KEY_Y, params.y)
.apply()
} }
true true
} }

View File

@ -6,14 +6,10 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/** /**
* 浮窗提示plan.md3.8 浮窗账单提示 * 浮窗提示plan.md3.8 浮窗账单提示
@ -47,6 +43,10 @@ class FloatingTip(
/** 显示浮窗提示。 */ /** 显示浮窗提示。 */
fun show() { fun show() {
try { try {
val config = FloatingUiConfigStore.load(context)
val bgColor = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF000000") or 0xFF000000.toInt() // 强制不透明化
val textColor = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
val layoutParams = WindowManager.LayoutParams( val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
@ -65,42 +65,20 @@ class FloatingTip(
val container = LinearLayout(context).apply { val container = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
setBackgroundColor(0xF0333333.toInt()) setBackgroundColor(bgColor)
setPadding(32, 16, 32, 16) setPadding(32, 16, 32, 16)
} }
val text = TextView(context).apply { val text = TextView(context).apply {
text = message text = message
textSize = 13f textSize = 13f
setTextColor(0xFFFFFFFF.toInt()) setTextColor(textColor)
}
// 倒计时进度条
val progress = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal)
progress.max = 100
progress.progress = 100
progress.layoutParams = LinearLayout.LayoutParams(200, 8).apply {
setMargins(16, 0, 0, 0)
} }
container.addView(text) container.addView(text)
container.addView(progress)
view = container view = container
windowManager.addView(view, layoutParams) windowManager.addView(view, layoutParams)
Log.d(TAG, "FloatingTip 显示: $message") Log.d(TAG, "FloatingTip 显示: $message")
// 倒计时动画300ms 内 progress 从 100 到 0
val steps = 30
val stepMs = durationMs / steps
var currentStep = 0
handler.post(object : Runnable {
override fun run() {
currentStep++
progress.progress = 100 - (currentStep * 100 / steps)
if (currentStep < steps) {
handler.postDelayed(this, stepMs)
}
}
})
handler.postDelayed({ dismiss() }, durationMs) handler.postDelayed({ dismiss() }, durationMs)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "FloatingTip 显示失败: ${e.message}") Log.e(TAG, "FloatingTip 显示失败: ${e.message}")
@ -121,7 +99,7 @@ class FloatingTip(
*/ */
class RepeatToast(private val context: Context, private val message: String) { class RepeatToast(private val context: Context, private val message: String) {
fun show() { fun show() {
val tip = FloatingTip(context, "$message", FloatingTip.TipPosition.TOP, 2000L) val tip = FloatingTip(context, message, FloatingTip.TipPosition.TOP, 2000L)
tip.show() tip.show()
Log.d("RepeatToast", "重复提示: $message") Log.d("RepeatToast", "重复提示: $message")
} }

View File

@ -0,0 +1,211 @@
package com.beancount.mobile.accessibility
import android.content.Context
import android.graphics.Color
import android.util.Log
import com.facebook.react.bridge.ReadableMap
import org.json.JSONObject
/**
* 浮层 UI 颜色契约docs/ui-redesign-p6-plan.mdFloatingUiConfig 契约
* 字段名camelCase src/services/floatingUiConfig.ts FloatingUiConfig.colors 一一对应
* 默认值 = 现状硬编码颜色JS 未推送时行为不变注释标明出处
*/
data class FloatingUiConfigColors(
/** 按钮/选中态背景。 */ val accent: String = "#FF5E6AD2", // 原 FloatingBillView saveBg / 分段选中态
/** accent 上的文字色。 */ val accentFg: String = "#FFFFFFFF", // 原 FloatingBillView 按钮文字色
/** 卡片底色(原 0x8C050506 半透明,默认实色化到 F0 保证可读)。 */
val cardBg: String = "#F0050506", // 原 FloatingBillView containerBg 0x8C050506
/** 输入框/未选中 chip 底色。 */ val inputBg: String = "#4012131A", // 原 FloatingBillView 输入框底 0x4012131A
val fgPrimary: String = "#FFFFFFFF", // 原 FloatingBillView 输入框文字色
val fgSecondary: String = "#FF9CA3AF", // 原 FloatingBillView 未选中 tab/chip 文字色
val border: String = "#80222433", // 原 FloatingBillView 输入框/chip 描边 0x80222433
/** financial.income。 */ val income: String = "#FF10B981", // 原 FloatingBillView 收入/转账 chip 选中色
/** financial.expense。 */ val expense: String = "#FFE11D48", // 原 FloatingBillView 支出账户 chip 选中色
/** financial.transfer。 */ val transfer: String = "#FF10B981", // 原 FloatingBillView 转账转入 chip 选中色
)
/**
* 浮层 UI 文案契约
* 字段名camelCase src/services/floatingUiConfig.ts FloatingUiConfig.labels 一一对应
* 默认值 = 现状中文硬编码文案 emoji
*/
data class FloatingUiConfigLabels(
/** 浮窗标题。 */ val billTitle: String = "调整交易草稿",
val dirExpense: String = "支出",
val dirIncome: String = "收入",
val dirTransfer: String = "转账",
val amountLabel: String = "金额",
val payeeLabel: String = "交易对手",
val narrationLabel: String = "描述/备注",
val narrationHint: String = "输入交易叙述",
/** 支出方向分类行标签。 */ val categoryExpense: String = "交易分类",
/** 收入方向分类行标签。 */ val categoryIncome: String = "收入分类",
/** 转账方向第一行标签。 */ val transferTarget: String = "转入账户",
/** 支出方向账户行标签。 */ val accountExpense: String = "资金来源",
/** 收入方向账户行标签。 */ val accountIncome: String = "存入账户",
/** 转账方向账户行标签。 */ val accountTransfer: String = "转出账户",
val openApp: String = "打开应用",
val dismiss: String = "忽略",
val confirm: String = "确认入账",
/** 悬浮球「识别账单」。 */ val ballOcr: String = "识别账单",
/** 悬浮球「记住此页」。 */ val ballRemember: String = "记住此页",
/** 记住页面成功提示(不含 emoji。 */ val rememberSuccess: String = "已将当前页面加入识别白名单",
/** 失败提示前缀(原生拼接 ': ' + e.message。 */ val rememberFail: String = "记录失败",
/** BillingAccessibilityService Toast「已记住页面签名」前缀。 */ val pageRemembered: String = "已记住页面签名",
/** 「该页面签名已存在」前缀。 */ val pageSignatureExists: String = "该页面签名已存在",
)
/** 原生浮层 UI 配置JS 经 AccessibilityBridge.setFloatingUiConfig 下发)。 */
data class FloatingUiConfig(
val colors: FloatingUiConfigColors = FloatingUiConfigColors(),
val labels: FloatingUiConfigLabels = FloatingUiConfigLabels(),
)
/**
* FloatingUiConfig SharedPreferences 持久化plan原生持久化
*
* - 文件名 floating_ui_config单键 config_json 存整个 JSON
* - save 采用合并策略 load 出现存 JSON再覆盖传入键避免部分更新丢字段
* - load 逐字段 optString 缺省回退 data class 默认值任何异常返回全默认
*/
object FloatingUiConfigStore {
private const val TAG = "FloatingUiConfigStore"
private const val PREFS = "floating_ui_config"
private const val KEY = "config_json"
/** 契约内已知 colors 键save 时只写这些键)。 */
private val COLOR_KEYS = listOf(
"accent", "accentFg", "cardBg", "inputBg", "fgPrimary",
"fgSecondary", "border", "income", "expense", "transfer",
)
/** 契约内已知 labels 键save 时只写这些键)。 */
private val LABEL_KEYS = listOf(
"billTitle", "dirExpense", "dirIncome", "dirTransfer", "amountLabel",
"payeeLabel", "narrationLabel", "narrationHint", "categoryExpense",
"categoryIncome", "transferTarget", "accountExpense", "accountIncome",
"accountTransfer", "openApp", "dismiss", "confirm", "ballOcr",
"ballRemember", "rememberSuccess", "rememberFail", "pageRemembered",
"pageSignatureExists",
)
/** 保存(合并):先读出现存 JSON再覆盖传入的已知键。 */
fun save(context: Context, map: ReadableMap) {
val root = readRawJson(context)
if (map.hasKey("colors")) {
val colorsMap = map.getMap("colors")
val colorsJson = root.optJSONObject("colors") ?: JSONObject().also { root.put("colors", it) }
if (colorsMap != null) {
for (key in COLOR_KEYS) {
if (colorsMap.hasKey(key) && !colorsMap.isNull(key)) {
colorsJson.put(key, colorsMap.getString(key))
}
}
}
}
if (map.hasKey("labels")) {
val labelsMap = map.getMap("labels")
val labelsJson = root.optJSONObject("labels") ?: JSONObject().also { root.put("labels", it) }
if (labelsMap != null) {
for (key in LABEL_KEYS) {
if (labelsMap.hasKey(key) && !labelsMap.isNull(key)) {
labelsJson.put(key, labelsMap.getString(key))
}
}
}
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY, root.toString())
.apply()
}
/** 读取配置;缺失字段回退默认值,任何异常返回全默认。 */
fun load(context: Context): FloatingUiConfig {
return try {
val root = readRawJson(context)
val defaultColors = FloatingUiConfigColors()
val defaultLabels = FloatingUiConfigLabels()
val colorsJson = root.optJSONObject("colors")
val labelsJson = root.optJSONObject("labels")
val colors = FloatingUiConfigColors(
accent = colorsJson.optStringOr("accent", defaultColors.accent),
accentFg = colorsJson.optStringOr("accentFg", defaultColors.accentFg),
cardBg = colorsJson.optStringOr("cardBg", defaultColors.cardBg),
inputBg = colorsJson.optStringOr("inputBg", defaultColors.inputBg),
fgPrimary = colorsJson.optStringOr("fgPrimary", defaultColors.fgPrimary),
fgSecondary = colorsJson.optStringOr("fgSecondary", defaultColors.fgSecondary),
border = colorsJson.optStringOr("border", defaultColors.border),
income = colorsJson.optStringOr("income", defaultColors.income),
expense = colorsJson.optStringOr("expense", defaultColors.expense),
transfer = colorsJson.optStringOr("transfer", defaultColors.transfer),
)
val labels = FloatingUiConfigLabels(
billTitle = labelsJson.optStringOr("billTitle", defaultLabels.billTitle),
dirExpense = labelsJson.optStringOr("dirExpense", defaultLabels.dirExpense),
dirIncome = labelsJson.optStringOr("dirIncome", defaultLabels.dirIncome),
dirTransfer = labelsJson.optStringOr("dirTransfer", defaultLabels.dirTransfer),
amountLabel = labelsJson.optStringOr("amountLabel", defaultLabels.amountLabel),
payeeLabel = labelsJson.optStringOr("payeeLabel", defaultLabels.payeeLabel),
narrationLabel = labelsJson.optStringOr("narrationLabel", defaultLabels.narrationLabel),
narrationHint = labelsJson.optStringOr("narrationHint", defaultLabels.narrationHint),
categoryExpense = labelsJson.optStringOr("categoryExpense", defaultLabels.categoryExpense),
categoryIncome = labelsJson.optStringOr("categoryIncome", defaultLabels.categoryIncome),
transferTarget = labelsJson.optStringOr("transferTarget", defaultLabels.transferTarget),
accountExpense = labelsJson.optStringOr("accountExpense", defaultLabels.accountExpense),
accountIncome = labelsJson.optStringOr("accountIncome", defaultLabels.accountIncome),
accountTransfer = labelsJson.optStringOr("accountTransfer", defaultLabels.accountTransfer),
openApp = labelsJson.optStringOr("openApp", defaultLabels.openApp),
dismiss = labelsJson.optStringOr("dismiss", defaultLabels.dismiss),
confirm = labelsJson.optStringOr("confirm", defaultLabels.confirm),
ballOcr = labelsJson.optStringOr("ballOcr", defaultLabels.ballOcr),
ballRemember = labelsJson.optStringOr("ballRemember", defaultLabels.ballRemember),
rememberSuccess = labelsJson.optStringOr("rememberSuccess", defaultLabels.rememberSuccess),
rememberFail = labelsJson.optStringOr("rememberFail", defaultLabels.rememberFail),
pageRemembered = labelsJson.optStringOr("pageRemembered", defaultLabels.pageRemembered),
pageSignatureExists = labelsJson.optStringOr("pageSignatureExists", defaultLabels.pageSignatureExists),
)
FloatingUiConfig(colors, labels)
} catch (e: Exception) {
Log.e(TAG, "读取浮层 UI 配置失败,回退默认值: ${e.message}")
FloatingUiConfig()
}
}
/** 解析颜色字符串;失败/为空时解析 fallbackHexfallback 也不合法则返回 Color.BLACK。 */
fun parseColorOr(value: String, fallbackHex: String): Int {
if (value.isNotBlank()) {
try {
return Color.parseColor(value)
} catch (_: Exception) {}
}
return try {
Color.parseColor(fallbackHex)
} catch (_: Exception) {
Color.BLACK
}
}
/** 读出现存 JSON无数据或解析失败返回空 JSONObject。 */
private fun readRawJson(context: Context): JSONObject {
return try {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY, null)
if (raw.isNullOrBlank()) JSONObject() else JSONObject(raw)
} catch (_: Exception) {
JSONObject()
}
}
/** optString 包装JSON 为 null / 键缺失 / 空串时回退 default。 */
private fun JSONObject?.optStringOr(key: String, default: String): String {
if (this == null) return default
val v = optString(key, default)
return if (v.isBlank()) default else v
}
}

View File

@ -121,12 +121,11 @@ function withAccessibilityService(config) {
let content = modConfig.modResults.contents; let content = modConfig.modResults.contents;
// 2a. 注入 importAccessibilityBridgePackage // 2a. 注入 importAccessibilityBridgePackage
if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) { content = content.replace(/^import\s+[\w.]+\.AccessibilityBridgePackage\s*$/gm, '');
content = content.replace( content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m, /^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`, `$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
); );
}
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage()) // 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
if (!content.includes('add(AccessibilityBridgePackage())')) { if (!content.includes('add(AccessibilityBridgePackage())')) {
@ -166,7 +165,7 @@ function withAccessibilityService(config) {
$: { $: {
'android:name': `${PACKAGE}.BillingAccessibilityService`, 'android:name': `${PACKAGE}.BillingAccessibilityService`,
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE', 'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
'android:label': '账单识别', 'android:label': '浮记-账单识别',
'android:exported': 'false', 'android:exported': 'false',
}, },
'intent-filter': [{ 'intent-filter': [{

View File

@ -8,11 +8,11 @@
**PP-OCRv6 small**det 2.5M 参数 + rec 5.3M 参数,精度显著优于 v5 mobile **PP-OCRv6 small**det 2.5M 参数 + rec 5.3M 参数,精度显著优于 v5 mobile
| 指标 | PP-OCRv5 mobile | PP-OCRv6 small | | 指标 | PP-OCRv5 mobile | PP-OCRv6 small |
|------|----------------|----------------| | ------------- | --------------- | -------------- |
| det Hmean | 75.2% | 84.1% | | det Hmean | 75.2% | 84.1% |
| rec W-Avg | 73.7% | 81.3% | | rec W-Avg | 73.7% | 81.3% |
| rec ONNX 大小 | ~17 MB | ~21 MB | | rec ONNX 大小 | ~17 MB | ~21 MB |
## 文件结构 ## 文件结构
@ -55,14 +55,14 @@ curl -L -o ppocrv6_dict.txt \
## 性能配置(参考 AutoAccounting OcrProcessor.kt ## 性能配置(参考 AutoAccounting OcrProcessor.kt
| 优化项 | 配置 | | 优化项 | 配置 |
|--------|------| | -------- | -------------------------------------- |
| 引擎 | ONNX Runtime Android | | 引擎 | ONNX Runtime Android |
| 执行器 | CPU兼容性最稳部分设备 GPU 会崩溃) | | 执行器 | CPU兼容性最稳部分设备 GPU 会崩溃) |
| 线程 | intraOp=2 / interOp=2 | | 线程 | intraOp=2 / interOp=2 |
| det 图像 | 最大边 960px短边压缩 720px | | det 图像 | 最大边 960px短边压缩 720px |
| rec 图像 | 固定高度 48px | | rec 图像 | 固定高度 48px |
| ABI | arm64-v8a主流设备 | | ABI | arm64-v8a主流设备 |
## 使用 ## 使用

View File

@ -62,6 +62,9 @@ class OcrModule(private val context: ReactApplicationContext) :
@Volatile private var initialized = false @Volatile private var initialized = false
@Volatile private var initFailed = false @Volatile private var initFailed = false
/** 模型文件目录filesystem 绝对路径)。非空时从该目录加载模型,否则回退到 assets。 */
@Volatile private var modelDir: String? = null
override fun getName(): String = OCR_MODULE_NAME override fun getName(): String = OCR_MODULE_NAME
override fun initialize() { override fun initialize() {
@ -70,7 +73,7 @@ class OcrModule(private val context: ReactApplicationContext) :
scope.launch { initEngine() } scope.launch { initEngine() }
} }
/** 从 assets 加载 det/rec ONNX 模型与字典。 */ /** 从 assets 或 filesystem 加载 det/rec ONNX 模型与字典。 */
private fun initEngine() { private fun initEngine() {
lock.lock() lock.lock()
try { try {
@ -83,11 +86,29 @@ class OcrModule(private val context: ReactApplicationContext) :
// 移动端关闭内存优化里的图优化级别过高(部分模型会崩) // 移动端关闭内存优化里的图优化级别过高(部分模型会崩)
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT) setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT)
} }
val detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() } val dir = modelDir
val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() } val (det, rec, dict) = if (dir != null) {
val det = env.createSession(detBytes, opts) // 从 filesystem 加载P8模型按需下载到本地目录
val rec = env.createSession(recBytes, opts) val detPath = dir + java.io.File.separator + ASSET_DET_MODEL
val dict = loadDictionary() val recPath = dir + java.io.File.separator + ASSET_REC_MODEL
val dictPath = dir + java.io.File.separator + ASSET_DICT
Log.i(OCR_MODULE_NAME, "从 filesystem 加载模型: det=$detPath, rec=$recPath")
Triple(
env.createSession(detPath, opts),
env.createSession(recPath, opts),
loadDictionaryFromFile(dictPath)
)
} else {
// 回退:从 assets 加载(兼容未迁移的老用户)
val detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() }
val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() }
Log.i(OCR_MODULE_NAME, "从 assets 加载模型(兼容模式)")
Triple(
env.createSession(detBytes, opts),
env.createSession(recBytes, opts),
loadDictionaryFromAssets()
)
}
detSession = det detSession = det
recSession = rec recSession = rec
@ -98,7 +119,12 @@ class OcrModule(private val context: ReactApplicationContext) :
} catch (e: Exception) { } catch (e: Exception) {
initFailed = true initFailed = true
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e) Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
Log.e(OCR_MODULE_NAME, "请确认 assets 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT") val dir = modelDir
if (dir != null) {
Log.e(OCR_MODULE_NAME, "请确认 $dir 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
} else {
Log.e(OCR_MODULE_NAME, "请确认 assets 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
}
} finally { } finally {
lock.unlock() lock.unlock()
} }
@ -108,13 +134,13 @@ class OcrModule(private val context: ReactApplicationContext) :
private val recEnv: OrtEnvironment? get() = ortEnv private val recEnv: OrtEnvironment? get() = ortEnv
/** /**
* 加载 ppocrv6_dict.txt 字典 * assets 加载 ppocrv6_dict.txt 字典
* *
* PaddleOCR CTC 约定模型输出 logits index 0 blank字符从 index 1 开始 * PaddleOCR CTC 约定模型输出 logits index 0 blank字符从 index 1 开始
* 字典条目 dictionary[i] 对应模型输出 index i+1解码时 dictIdx = argmaxIdx - 1 * 字典条目 dictionary[i] 对应模型输出 index i+1解码时 dictIdx = argmaxIdx - 1
* 字典本身不含 blank运行时固定用 index 0 blank ctcGreedyDecode * 字典本身不含 blank运行时固定用 index 0 blank ctcGreedyDecode
*/ */
private fun loadDictionary(): List<String> { private fun loadDictionaryFromAssets(): List<String> {
val words = mutableListOf<String>() val words = mutableListOf<String>()
context.assets.open(ASSET_DICT).use { stream -> context.assets.open(ASSET_DICT).use { stream ->
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines -> BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines ->
@ -127,6 +153,17 @@ class OcrModule(private val context: ReactApplicationContext) :
return words return words
} }
/** 从 filesystem 路径加载字典文件。 */
private fun loadDictionaryFromFile(path: String): List<String> {
val words = mutableListOf<String>()
java.io.File(path).bufferedReader(Charsets.UTF_8).useLines { lines ->
lines.forEach { line ->
words.add(line.trimEnd('\r', '\n'))
}
}
return words
}
/** /**
* 识别图片文本 * 识别图片文本
* @param imageBase64 base64 编码的图片JPEG/PNG * @param imageBase64 base64 编码的图片JPEG/PNG
@ -209,6 +246,18 @@ class OcrModule(private val context: ReactApplicationContext) :
promise.resolve(initialized) promise.resolve(initialized)
} }
/** 设置模型文件目录(绝对路径)。若引擎已初始化则释放并重新加载。 */
@ReactMethod
fun setModelDir(dir: String, promise: Promise) {
modelDir = dir
if (initialized) {
release()
initFailed = false
scope.launch { initEngine() }
}
promise.resolve(true)
}
// ============== 推理流水线 ============== // ============== 推理流水线 ==============
private fun ensureReady() { private fun ensureReady() {

View File

@ -96,12 +96,11 @@ function withPpOcr(config) {
let content = modConfig.modResults.contents; let content = modConfig.modResults.contents;
// 3a. 注入 import在 package 声明行后插入) // 3a. 注入 import在 package 声明行后插入)
if (!content.includes(`import ${PACKAGE}.OcrPackage`)) { content = content.replace(/^import\s+[\w.]+\.OcrPackage\s*$/gm, '');
content = content.replace( content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m, /^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.OcrPackage`, `$1\nimport ${PACKAGE}.OcrPackage`,
); );
}
// 3b. 在 getPackages() 的 .apply {} 块里注入 add(OcrPackage()) // 3b. 在 getPackages() 的 .apply {} 块里注入 add(OcrPackage())
if (!content.includes('add(OcrPackage())')) { if (!content.includes('add(OcrPackage())')) {

File diff suppressed because it is too large Load Diff

View File

@ -46,12 +46,11 @@ function withScreenshotMonitor(config) {
let content = modConfig.modResults.contents; let content = modConfig.modResults.contents;
// 2a. 注入 import // 2a. 注入 import
if (!content.includes(`import ${PACKAGE}.ScreenshotPackage`)) { content = content.replace(/^import\s+[\w.]+\.ScreenshotPackage\s*$/gm, '');
content = content.replace( content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m, /^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.ScreenshotPackage`, `$1\nimport ${PACKAGE}.ScreenshotPackage`,
); );
}
// 2b. 在 getPackages() 的 .apply {} 块里注入 add(ScreenshotPackage()) // 2b. 在 getPackages() 的 .apply {} 块里注入 add(ScreenshotPackage())
if (!content.includes('add(ScreenshotPackage())')) { if (!content.includes('add(ScreenshotPackage())')) {

View File

@ -3,7 +3,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
function withSizeOptimization(config) { function withSizeOptimization(config) {
// 1. 在 prebuild 时修改 gradle.properties // 1. 在 prebuild 时修改 gradle.properties (开启 R8/ProGuard 代码混淆 + 资源裁剪 + .so 库压缩)
config = withDangerousMod(config, [ config = withDangerousMod(config, [
'android', 'android',
async (modConfig) => { async (modConfig) => {
@ -11,18 +11,42 @@ function withSizeOptimization(config) {
const propertiesPath = path.join(projectRoot, 'gradle.properties'); const propertiesPath = path.join(projectRoot, 'gradle.properties');
if (fs.existsSync(propertiesPath)) { if (fs.existsSync(propertiesPath)) {
let content = fs.readFileSync(propertiesPath, 'utf8'); let content = fs.readFileSync(propertiesPath, 'utf8');
// 限制打包架构为 arm64-v8a
if (content.includes('reactNativeArchitectures=')) { if (content.includes('reactNativeArchitectures=')) {
content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a'); content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a');
} else { } else {
content += '\nreactNativeArchitectures=arm64-v8a\n'; 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'); fs.writeFileSync(propertiesPath, content, 'utf8');
} }
return modConfig; return modConfig;
} }
]); ]);
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包 // 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包 + 字体裁剪
config = withDangerousMod(config, [ config = withDangerousMod(config, [
'android', 'android',
async (modConfig) => { async (modConfig) => {
@ -30,19 +54,71 @@ function withSizeOptimization(config) {
const buildGradlePath = path.join(projectRoot, 'app/build.gradle'); const buildGradlePath = path.join(projectRoot, 'app/build.gradle');
if (fs.existsSync(buildGradlePath)) { if (fs.existsSync(buildGradlePath)) {
let content = fs.readFileSync(buildGradlePath, 'utf8'); let content = fs.readFileSync(buildGradlePath, 'utf8');
// ABI Splits 分包配置
if (!content.includes('splits {')) { if (!content.includes('splits {')) {
content = content.replace( content = content.replace(
/android\s*\{/, /android\s*\{/,
`android {\n splits {\n abi {\n enable true\n reset()\n include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"\n universalApk true\n }\n }` `android {\n splits {\n abi {\n enable true\n reset()\n include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"\n universalApk true\n }\n }`
); );
fs.writeFileSync(buildGradlePath, content, 'utf8'); }
// 3. 裁剪未使用的 @expo/vector-icons 矢量字体
if (!content.includes('variant.mergeAssetsProvider.configure')) {
content += `
android.applicationVariants.all { variant ->
if (variant.buildType.name == "release") {
variant.mergeAssetsProvider.configure {
doLast {
delete fileTree(dir: outputDir, includes: [
"**/fonts/AntDesign.ttf",
"**/fonts/Entypo.ttf",
"**/fonts/EvilIcons.ttf",
"**/fonts/Feather.ttf",
"**/fonts/FontAwesome*.ttf",
"**/fonts/Fontisto.ttf",
"**/fonts/Foundation.ttf",
"**/fonts/MaterialCommunityIcons.ttf",
"**/fonts/MaterialIcons.ttf",
"**/fonts/Octicons.ttf",
"**/fonts/SimpleLineIcons.ttf",
"**/fonts/Zocial.ttf"
])
}
}
}
}
`;
}
fs.writeFileSync(buildGradlePath, content, 'utf8');
}
return modConfig;
}
]);
// 3. 在 prebuild 时修改 proguard-rules.pro 保护原生插件与 ONNX 模块
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const proguardPath = path.join(projectRoot, 'app/proguard-rules.pro');
if (fs.existsSync(proguardPath)) {
let content = fs.readFileSync(proguardPath, 'utf8');
if (!content.includes('com.beancount.mobile')) {
content += `
# Size optimization: keep custom native packages & ONNX
-keep class com.beancount.mobile.** { *; }
-keep class com.example.driftledger.** { *; }
-keep class ai.onnxruntime.** { *; }
`;
fs.writeFileSync(proguardPath, content, 'utf8');
} }
} }
return modConfig; return modConfig;
} }
]); ]);
// 3. 在 prebuild 时修改 root build.gradle 强制指定所有模块的 NDK 版本以匹配 27.1.12297006 // 4. 在 prebuild 时修改 root build.gradle 强制指定所有模块的 NDK 版本以匹配 27.1.12297006
config = withDangerousMod(config, [ config = withDangerousMod(config, [
'android', 'android',
async (modConfig) => { async (modConfig) => {

View File

@ -39,6 +39,8 @@ export function isTransactionIntent(input: string): boolean {
return hasAmount && hasKeyword; return hasAmount && hasKeyword;
} }
import { logger } from '../utils/logger';
/** /**
* *
*/ */
@ -47,11 +49,14 @@ export async function processChatMessage(
provider: AiProvider, provider: AiProvider,
conversation?: ChatConversation, conversation?: ChatConversation,
): Promise<ChatResponse> { ): Promise<ChatResponse> {
logger.info('aiChat', `收到 AI 助手对话请求 [文本: "${input}"]`);
try { try {
// 意图判定 // 意图判定
if (isTransactionIntent(input)) { if (isTransactionIntent(input)) {
logger.info('aiChat', '检测到记账意图,触发自然语言识别流水线');
const result = await processNaturalLanguage(input, provider); const result = await processNaturalLanguage(input, provider);
if (result.type === 'draft') { if (result.type === 'draft') {
logger.info('aiChat', `AI 识别生成交易草稿: [时间: ${result.draft.time}, 金额: ${result.draft.amount} ${result.draft.currency}, 叙述: "${result.draft.narration}"]`);
return { type: 'bill_card', billCards: [result.draft] }; return { type: 'bill_card', billCards: [result.draft] };
} }
// 非 draft 但有意图 → 返回 AI 文本 // 非 draft 但有意图 → 返回 AI 文本
@ -59,10 +64,13 @@ export async function processChatMessage(
} }
// 自由对话 // 自由对话
logger.info('aiChat', '未命中记账模式,发起 AI 自由问答');
const messages = buildChatContext(input, conversation); const messages = buildChatContext(input, conversation);
const response = await provider.chat(messages); const response = await provider.chat(messages);
logger.info('aiChat', 'AI 自由问答回复成功');
return { type: 'text', text: removeThink(response) }; return { type: 'text', text: removeThink(response) };
} catch (e) { } catch (e) {
logger.error('aiChat', 'AI 对话助手响应失败', e);
return { type: 'error', error: e instanceof Error ? e.message : String(e) }; return { type: 'error', error: e instanceof Error ? e.message : String(e) };
} }
} }

View File

@ -50,6 +50,8 @@ export function calculateMonthlyStats(
}; };
} }
import { logger } from '../utils/logger';
/** /**
* AI * AI
*/ */
@ -59,20 +61,27 @@ export async function generateMonthlySummary(
month: number, month: number,
provider: AiProvider, provider: AiProvider,
): Promise<MonthlySummaryResult> { ): Promise<MonthlySummaryResult> {
const stats = calculateMonthlyStats(transactions, year, month); logger.info('aiSummary', `开始生成 AI 月度财务总结 [${year}${month}月]`);
try {
const stats = calculateMonthlyStats(transactions, year, month);
const messages = buildMonthlySummaryPrompt({ const messages = buildMonthlySummaryPrompt({
totalExpense: stats.totalExpense, totalExpense: stats.totalExpense,
totalIncome: stats.totalIncome, totalIncome: stats.totalIncome,
transactionCount: stats.transactionCount, transactionCount: stats.transactionCount,
topCategories: stats.topCategories, topCategories: stats.topCategories,
}); });
const response = await provider.chat(messages); const response = await provider.chat(messages);
return { logger.info('aiSummary', `AI 月度财务总结生成成功 [${year}${month}月]`);
stats, return {
summary: removeThink(response), stats,
}; summary: removeThink(response),
};
} catch (e) {
logger.error('aiSummary', `生成 AI 月度总结失败 [${year}${month}月]`, e);
throw e;
}
} }
/** 无 AI 的纯文本总结(降级,不调 AI。 */ /** 无 AI 的纯文本总结(降级,不调 AI。 */

View File

@ -141,6 +141,13 @@ export default function HomeScreen() {
<TodoStrip /> <TodoStrip />
{/* 月度趋势 */}
{monthlyData.length > 0 && (
<Card title={t('home.monthlyTrend')}>
<TrendLine transactions={transactions} />
</Card>
)}
{/* 最近 5 条交易 */} {/* 最近 5 条交易 */}
{recentTxs.length > 0 && ( {recentTxs.length > 0 && (
<Card title={t('home.recentTransactions')}> <Card title={t('home.recentTransactions')}>
@ -152,13 +159,6 @@ export default function HomeScreen() {
</Pressable> </Pressable>
</Card> </Card>
)} )}
{/* 月度趋势 */}
{monthlyData.length > 0 && (
<Card title={t('home.monthlyTrend')}>
<TrendLine transactions={transactions} />
</Card>
)}
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );

View File

@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, type Href } from 'expo-router'; import { useRouter, type Href } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Touchable } from '../../components/Touchable';
export default function SettingsScreen() { export default function SettingsScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
@ -13,15 +14,14 @@ export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const renderNavLink = (icon: keyof typeof Ionicons.glyphMap, label: string, path: Href) => ( const renderNavLink = (icon: keyof typeof Ionicons.glyphMap, label: string, path: Href) => (
<Pressable <Touchable
onPress={() => router.push(path)} onPress={() => router.push(path)}
accessibilityRole="link" accessibilityRole="link"
accessibilityLabel={label} accessibilityLabel={label}
style={({ pressed }) => [ style={[
styles.navRow, styles.navRow,
{ {
borderBottomColor: theme.colors.divider, borderBottomColor: theme.colors.divider,
opacity: pressed ? 0.6 : 1,
}, },
]} ]}
> >
@ -30,7 +30,7 @@ export default function SettingsScreen() {
{label} {label}
</Text> </Text>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} /> <Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
</Pressable> </Touchable>
); );
return ( return (
@ -67,6 +67,7 @@ export default function SettingsScreen() {
<View style={styles.listGroup}> <View style={styles.listGroup}>
{renderNavLink('cloud-upload-outline', t('settings.syncBackup'), '/settings/sync')} {renderNavLink('cloud-upload-outline', t('settings.syncBackup'), '/settings/sync')}
{renderNavLink('pulse-outline', t('settings.diagnostics'), '/settings/diagnostics' as Href)} {renderNavLink('pulse-outline', t('settings.diagnostics'), '/settings/diagnostics' as Href)}
{renderNavLink('document-text-outline', t('settings.appLogs'), '/settings/logs' as Href)}
</View> </View>
</Card> </Card>
@ -74,7 +75,7 @@ export default function SettingsScreen() {
<Card title={t('settings.groupPreferences')}> <Card title={t('settings.groupPreferences')}>
<View style={styles.listGroup}> <View style={styles.listGroup}>
{renderNavLink('phone-portrait-outline', t('settings.preferencesEntry'), '/settings/preferences')} {renderNavLink('phone-portrait-outline', t('settings.preferencesEntry'), '/settings/preferences')}
{renderNavLink('cog-outline', t('settings.aiSettingsTitle'), '/settings/ai')} {renderNavLink('cog-outline', t('settings.llmTitle'), '/settings/ai')}
{renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)} {renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)}
</View> </View>
</Card> </Card>

View File

@ -11,6 +11,7 @@ import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore'; import { useMetadataStore } from '../../store/metadataStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { useTheme, createCommonStyles } from '../../theme'; import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
@ -85,7 +86,7 @@ export default function TransactionsScreen() {
// cost/price 不带:复制是新建交易,丢 cost 属可接受简化(含 cost 的交易可从编辑入口进高级模式) // cost/price 不带:复制是新建交易,丢 cost 属可接受简化(含 cost 的交易可从编辑入口进高级模式)
postings: tx.postings.map(p => ({ account: p.account, amount: p.amount, currency: p.currency })), postings: tx.postings.map(p => ({ account: p.account, amount: p.amount, currency: p.currency })),
}); });
router.push({ pathname: '/transaction/new', params: { draftJson } }); useNumpadUiStore.getState().open({ draftJson });
}; };
// 左滑:删除(确认后从 main.bean 移除) // 左滑:删除(确认后从 main.bean 移除)
@ -137,7 +138,7 @@ export default function TransactionsScreen() {
<SectionList <SectionList
sections={sections} sections={sections}
keyExtractor={(item) => item.id} keyExtractor={(item, index) => `${item.id}-${index}`}
renderItem={({ item: tx }) => ( renderItem={({ item: tx }) => (
<SwipeableTransactionCard <SwipeableTransactionCard
transaction={tx} transaction={tx}

View File

@ -12,6 +12,7 @@ import { useMetadataStore } from '../store/metadataStore';
import { parseDecimal } from '../domain/decimal'; import { parseDecimal } from '../domain/decimal';
import { useAutomationStore } from '../store/automationStore'; import { useAutomationStore } from '../store/automationStore';
import { FileSystemBackend } from '../services/fileSystemBackend'; import { FileSystemBackend } from '../services/fileSystemBackend';
import { useNumpadUiStore } from '../store/numpadUiStore';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { LockScreen } from '../components/LockScreen'; import { LockScreen } from '../components/LockScreen';
import { NumpadSheetHost } from '../components/NumpadSheetHost'; import { NumpadSheetHost } from '../components/NumpadSheetHost';
@ -25,11 +26,31 @@ import { parseNotification } from '../services/notification';
import { parseSms } from '../services/sms'; import { parseSms } from '../services/sms';
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts, loadProcessedTxKeys, pendingDrafts } from '../services/automationPipeline'; import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts, loadProcessedTxKeys, pendingDrafts } from '../services/automationPipeline';
import { getAccessibilityBridge } from '../services/accessibilityBridge'; import { getAccessibilityBridge } from '../services/accessibilityBridge';
import { pushFloatingUiConfig } from '../services/floatingUiConfig';
import { ensureOcrModels } from '../services/modelDownloader';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { expoLogBackend } from '../services/logBackend';
let lastSignature = ''; let lastSignature = '';
let lastProcessedTime = 0; let lastProcessedTime = 0;
/**
* UI P6 FloatingUiConfig
* / / colors + labels
* ThemeProvider null
*/
function FloatingUiConfigSyncer() {
const { theme } = useTheme();
const locale = useSettingsStore(s => s.locale);
const t = useT();
useEffect(() => {
// useT() 每次渲染返回新 bind故依赖用 locale 而非 t
// locale 变化时 useT 内部已将 i18n.locale 指向新语言
void pushFloatingUiConfig(theme, t);
}, [theme, locale]);
return null;
}
/** 原生悬浮窗跳转 App 事件类型 */ /** 原生悬浮窗跳转 App 事件类型 */
interface NativeOpenAppEvent { interface NativeOpenAppEvent {
draftId?: string; draftId?: string;
@ -41,6 +62,15 @@ interface NativeOpenAppEvent {
direction?: string; direction?: string;
merchant?: string; merchant?: string;
narration?: string; narration?: string;
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 用内置)。 */ /** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */
@ -109,9 +139,19 @@ function AppShell() {
persistenceInitRef.current = true; persistenceInitRef.current = true;
// 1. 初始化持久化并还原数据 // 1. 初始化持久化并还原数据
initPersistence().then(async () => { initPersistence().then(async () => {
// 初始化磁盘日志持久化系统
logger.initFileBackend(expoLogBackend).catch(e => {
logger.warn('layout', '磁盘日志初始化失败', e);
});
// 加载已处理的交易键(跨会话去重) // 加载已处理的交易键(跨会话去重)
await loadProcessedTxKeys(); await loadProcessedTxKeys();
// P8确保 OCR 模型文件可用(首次启动从 APK assets 拷贝到 filesDir
ensureOcrModels().catch(e => {
logger.warn('layout', 'OCR 模型初始化失败(非关键路径,截图 OCR 将降级)', e);
});
const mainPath = FileSystem.documentDirectory + 'main.bean'; const mainPath = FileSystem.documentDirectory + 'main.bean';
// 架构说明main.bean 作为主账本文件,包含所有 Beancount 指令open/close/option/include 等), // 架构说明main.bean 作为主账本文件,包含所有 Beancount 指令open/close/option/include 等),
@ -133,6 +173,14 @@ function AppShell() {
const completed = useSettingsStore.getState().onboardingCompleted; const completed = useSettingsStore.getState().onboardingCompleted;
const locked = useSettingsStore.getState().appLockEnabled; const locked = useSettingsStore.getState().appLockEnabled;
const floatingEnabled = useSettingsStore.getState().floatingBallEnabled;
// 开启/初始化时同步悬浮球开关至原生无障碍服务
const bridge = getAccessibilityBridge();
if (bridge) {
bridge.setFloatingBallEnabled(floatingEnabled).catch(() => {});
}
if (!completed) { if (!completed) {
setPhase('onboarding'); setPhase('onboarding');
} else if (locked) { } else if (locked) {
@ -154,7 +202,7 @@ function AppShell() {
if (phase === 'ready') { if (phase === 'ready') {
const cleanup = setupDeepLinking((action) => { const cleanup = setupDeepLinking((action) => {
if (action.type === 'add-transaction') { if (action.type === 'add-transaction') {
router.push('/transaction/new'); useNumpadUiStore.getState().open();
} else if (action.type === 'open-tab') { } else if (action.type === 'open-tab') {
if (action.tab === 'home') router.push('/(tabs)'); if (action.tab === 'home') router.push('/(tabs)');
else if (action.tab === 'transactions') router.push('/(tabs)/transactions'); else if (action.tab === 'transactions') router.push('/(tabs)/transactions');
@ -164,7 +212,7 @@ function AppShell() {
} else if (action.type === 'import-csv') { } else if (action.type === 'import-csv') {
router.push('/import'); router.push('/import');
} else if (action.type === 'ocr-camera' || action.type === 'ocr-image') { } else if (action.type === 'ocr-camera' || action.type === 'ocr-image') {
router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } }); useNumpadUiStore.getState().open({ autoOcr: true });
} else if (action.type === 'voice-input') { } else if (action.type === 'voice-input') {
router.push('/(tabs)/settings'); router.push('/(tabs)/settings');
} }
@ -182,13 +230,14 @@ function AppShell() {
const subscriptions = [ const subscriptions = [
DeviceEventEmitter.addListener('billingNotification', (event) => { DeviceEventEmitter.addListener('billingNotification', (event) => {
try { try {
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}, 内容: ${event.text}`); const safeText = sanitizeLogText(event.text || '');
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}, 内容: ${safeText}`);
const bill = parseNotification(event); const bill = parseNotification(event);
if (bill) { if (bill) {
logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${event.text}`); logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${safeText}`);
handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`); handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`);
} else { } else {
logger.debug('layout', `通知未匹配为账单: [${event.title}] ${event.text}`); logger.debug('layout', `通知未匹配为账单: [${event.title}] ${safeText}`);
} }
} catch (e) { } catch (e) {
logger.error('layout', '通知解析失败', e); logger.error('layout', '通知解析失败', e);
@ -196,7 +245,8 @@ function AppShell() {
}), }),
DeviceEventEmitter.addListener('billingSms', (event) => { DeviceEventEmitter.addListener('billingSms', (event) => {
try { try {
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}, 内容: ${event.body}`); const safeBody = sanitizeLogText(event.body || '');
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}, 内容: ${safeBody}`);
const smsEvent = { const smsEvent = {
sender: event.sender || event.address || '', sender: event.sender || event.address || '',
body: event.body, body: event.body,
@ -204,73 +254,74 @@ function AppShell() {
}; };
const bill = parseSms(smsEvent); const bill = parseSms(smsEvent);
if (bill) { if (bill) {
logger.debug('layout', `短信解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始短信: ${event.body}`); logger.debug('layout', `短信解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始短信: ${safeBody}`);
handleIncomingBillEvent('sms', bill, event.body); handleIncomingBillEvent('sms', bill, event.body);
} else { } else {
logger.debug('layout', `短信未匹配为账单: ${event.body}`); logger.debug('layout', `短信未匹配为账单: ${safeBody}`);
} }
} catch (e) { } catch (e) {
logger.error('layout', '短信解析失败', e); logger.error('layout', '短信解析失败', e);
} }
}), }),
DeviceEventEmitter.addListener('billingScreenshot', (event) => { DeviceEventEmitter.addListener('billingScreenshot', async (event) => {
logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
processScreenshotEvent(event).catch((e: unknown) => {
logger.error('layout', '截图 OCR 处理失败', e);
});
}),
DeviceEventEmitter.addListener('billingOpenApp', (res: NativeOpenAppEvent) => {
try { try {
logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
await processScreenshotEvent(event);
} catch (e) {
logger.error('layout', `截图 OCR 处理失败 (包名: ${event.packageName}, 是否有Base64: ${Boolean(event.imageBase64)})`, e instanceof Error ? { message: e.message, stack: e.stack } : String(e));
}
}),
DeviceEventEmitter.addListener('billingOpenApp', async (event) => {
try {
const res = event as NativeOpenAppEvent;
logger.info('layout', `收到原生悬浮窗跳转 App 请求: ${JSON.stringify(res)}`); logger.info('layout', `收到原生悬浮窗跳转 App 请求: ${JSON.stringify(res)}`);
if (res.draftId) { if (res.confirmed) {
try { // 已由原生悬浮窗确认入账,前端仅记录日志或提示
pendingDrafts.delete(res.draftId); return;
} catch (err) {
// 忽略
}
} }
const amt = res.amount || '0'; if (!res.amount && !res.merchant) {
if (!res.account || !res.category || !amt || amt === '0') {
logger.warn('layout', 'billingOpenApp 数据不完整,跳过', JSON.stringify(res)); logger.warn('layout', 'billingOpenApp 数据不完整,跳过', JSON.stringify(res));
return; return;
} }
try { const amt = res.amount ?? '0';
const parsed = parseDecimal(amt); const validAmount = parseDecimal(amt);
if (parsed.coefficient <= 0n) { if (!validAmount) {
if (amt.length > 0) {
logger.warn('layout', 'billingOpenApp 金额格式错误', amt);
} else {
logger.warn('layout', 'billingOpenApp 金额无效', amt); logger.warn('layout', 'billingOpenApp 金额无效', amt);
return;
} }
} catch {
logger.warn('layout', 'billingOpenApp 金额格式错误', amt);
return; return;
} }
const currency = 'CNY'; const direction = res.direction ?? 'expense';
const dir = (res.direction || 'expense') as 'income' | 'expense' | 'transfer'; const categoryAccount = res.category ?? 'Expenses:未分类';
const postings = buildPostings(dir, res.account, res.category, amt, currency); 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 }
];
const draft = { const draft = {
date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0], date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0],
payee: res.merchant || undefined, payee: res.merchant || undefined,
narration: res.narration || '', narration: res.narration || '',
postings postings
}; };
router.push({ useNumpadUiStore.getState().open({ draftJson: JSON.stringify(draft) });
pathname: '/transaction/new',
params: { draftJson: JSON.stringify(draft) }
});
} catch (e) { } catch (e) {
logger.error('layout', '处理悬浮窗跳转 App 失败', e); logger.error('layout', '处理悬浮窗跳转 App 失败', e);
} }
}), }),
DeviceEventEmitter.addListener('billingPageRemembered', (event) => { DeviceEventEmitter.addListener('billingPageRemembered', (event) => {
logger.info('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`); logger.debug('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`);
logger.info('layout', `[无障碍调试] 记住该页面时捕获的所有文本内容: ${JSON.stringify(event.texts)}`); logger.debug('layout', `[无障碍调试] 记住该页面时捕获的所有文本内容: ${JSON.stringify(event.texts)}`);
}), }),
DeviceEventEmitter.addListener('billingDebugNodes', async (event) => { DeviceEventEmitter.addListener('billingDebugNodes', async (event) => {
const { package: pkg, activity, texts, isManual } = event; const { package: pkg, activity, texts, isManual } = event;
const sigKey = `${pkg}|${activity}`; const sigKey = `${pkg}|${activity}`;
logger.info('layout', `[无障碍监听] 页面特征信号: ${sigKey}`); logger.debug('layout', `[无障碍监听] 页面特征信号: ${sigKey}`);
logger.info('layout', `[无障碍监听] 页面提取到的文本内容: ${JSON.stringify(texts)}`); logger.debug('layout', `[无障碍监听] 页面提取到的文本内容: ${JSON.stringify(texts?.slice(0, 5))}`);
try { try {
const bridge = getAccessibilityBridge(); const bridge = getAccessibilityBridge();
@ -279,7 +330,7 @@ function AppShell() {
const signatures = await bridge.getPageSignatures(); const signatures = await bridge.getPageSignatures();
const isWhitelisted = signatures.some(s => s.signature === sigKey); const isWhitelisted = signatures.some(s => s.signature === sigKey);
if (!isWhitelisted) { if (!isWhitelisted) {
logger.info('layout', `[无障碍监听] 页面 ${sigKey} 未在自动记账白名单中,拒绝弹窗`); logger.debug('layout', `[无障碍监听] 页面 ${sigKey} 未在自动记账白名单中,拒绝弹窗`);
return; return;
} }
} }
@ -298,11 +349,12 @@ function AppShell() {
if (isDetail) { if (isDetail) {
const success = await parseAndProcessAccessibilityTexts(texts, pkg); const success = await parseAndProcessAccessibilityTexts(texts, pkg);
if (!success) { if (!success) {
logger.info('layout', '[无障碍] 微信详情页直接文本解析未成功,降级触发 OCR 识别'); const textSnippet = texts.slice(0, 5).join(' | ');
logger.info('layout', `[无障碍] 微信详情页直接文本解析未成功 (截获文本前5句: [${textSnippet}]),降级触发 OCR 识别`);
bridge?.triggerManualOcr(); bridge?.triggerManualOcr();
} }
} else { } else {
logger.info('layout', '[无障碍] 微信非详情页面,不触发记账与截图'); logger.debug('layout', '[无障碍] 微信非详情页面,不触发记账与截图');
} }
} else if (pkg === 'com.eg.android.AlipayGphone') { } else if (pkg === 'com.eg.android.AlipayGphone') {
// 支付宝:识别是否是详情页 // 支付宝:识别是否是详情页
@ -310,11 +362,12 @@ function AppShell() {
if (isDetail) { if (isDetail) {
const success = await parseAndProcessAccessibilityTexts(texts, pkg); const success = await parseAndProcessAccessibilityTexts(texts, pkg);
if (!success) { if (!success) {
logger.info('layout', '[无障碍] 支付宝详情页直接文本解析未成功,降级触发 OCR 识别'); const textSnippet = texts.slice(0, 5).join(' | ');
logger.info('layout', `[无障碍] 支付宝详情页直接文本解析未成功 (截获文本前5句: [${textSnippet}]),降级触发 OCR 识别`);
bridge?.triggerManualOcr(); bridge?.triggerManualOcr();
} }
} else { } else {
logger.info('layout', '[无障碍] 支付宝非详情页面,不触发记账与截图'); logger.debug('layout', '[无障碍] 支付宝非详情页面,不触发记账与截图');
} }
} else { } else {
// 其他白名单应用如各大银行、QQ、TIM等直接通过截图 + OCR 识别 // 其他白名单应用如各大银行、QQ、TIM等直接通过截图 + OCR 识别
@ -377,7 +430,6 @@ function AppShell() {
<StatusBar style={isDark ? 'light' : 'dark'} /> <StatusBar style={isDark ? 'light' : 'dark'} />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}> <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}>
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="transaction/new" options={{ headerShown: false }} />
<Stack.Screen name="transaction/[id]" options={{ headerShown: false }} /> <Stack.Screen name="transaction/[id]" options={{ headerShown: false }} />
<Stack.Screen name="category/index" options={{ headerShown: false }} /> <Stack.Screen name="category/index" options={{ headerShown: false }} />
<Stack.Screen name="tag/index" options={{ headerShown: false }} /> <Stack.Screen name="tag/index" options={{ headerShown: false }} />
@ -413,6 +465,7 @@ export default function RootLayout() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider> <ThemeProvider>
<FloatingUiConfigSyncer />
<AppShell /> <AppShell />
</ThemeProvider> </ThemeProvider>
</GestureHandlerRootView> </GestureHandlerRootView>

View File

@ -12,14 +12,16 @@
* onboarding * onboarding
*/ */
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { AppState, Platform, PermissionsAndroid, Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { useSettingsStore, type Locale, type ThemeMode } from '../store/settingsStore'; import { useSettingsStore, type Locale, type ThemeMode } from '../store/settingsStore';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { getAccessibilityBridge } from '../services/accessibilityBridge';
import { Card } from '../components/Card';
export interface OnboardingStep { export interface OnboardingStep {
key: string; key: string;
@ -52,6 +54,68 @@ export function OnboardingScreen({ onComplete }: OnboardingProps) {
const current = steps[step]; const current = steps[step];
const isLast = step === steps.length - 1; const isLast = step === steps.length - 1;
// ── 权限状态管理P7 Task 2 ──
const [permStates, setPermStates] = useState<Record<string, boolean | null>>({
accessibility: null, notification: null, sms: null, storage: null, overlay: null,
});
const permsChecked = !Object.values(permStates).some(v => v === null);
const allGranted = permsChecked && Object.values(permStates).every(Boolean);
useEffect(() => {
if (Platform.OS !== 'android') return;
const checkAllPermissions = async () => {
if (current.key !== 'permissions') return;
const bridge = getAccessibilityBridge();
let a = false, n = false, o = false, s = false, st = false;
if (bridge) {
try { a = await bridge.isServiceRunning(); } catch { /* false */ }
try { n = await bridge.isNotificationListenerEnabled(); } catch { /* false */ }
try { o = await bridge.canDrawOverlays(); } catch { /* false */ }
}
try { s = await PermissionsAndroid.check('android.permission.RECEIVE_SMS') as boolean; } catch { /* false */ }
const storagePerm = parseInt(String(Platform.Version), 10) >= 33
? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
try { st = await PermissionsAndroid.check(storagePerm) as boolean; } catch { /* false */ }
setPermStates({ accessibility: a, notification: n, overlay: o, sms: s, storage: st });
};
checkAllPermissions();
// 从系统设置返回后重新检查
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') checkAllPermissions();
});
return () => sub.remove();
}, [current.key]);
const handlePermAction = async (key: string) => {
const storagePerm = parseInt(String(Platform.Version), 10) >= 33
? 'android.permission.READ_MEDIA_IMAGES'
: 'android.permission.READ_EXTERNAL_STORAGE';
switch (key) {
case 'accessibility':
await Linking.sendIntent('android.settings.ACCESSIBILITY_SETTINGS');
break;
case 'notification':
await Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS');
break;
case 'sms': {
const result = await PermissionsAndroid.request('android.permission.RECEIVE_SMS');
setPermStates(p => ({ ...p, sms: result === PermissionsAndroid.RESULTS.GRANTED }));
break;
}
case 'storage': {
const result = await PermissionsAndroid.request(storagePerm);
setPermStates(p => ({ ...p, storage: result === PermissionsAndroid.RESULTS.GRANTED }));
break;
}
case 'overlay':
await Linking.sendIntent('android.settings.action.MANAGE_OVERLAY_PERMISSION');
break;
}
};
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<ScrollView contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={styles.content}>
@ -97,6 +161,55 @@ export function OnboardingScreen({ onComplete }: OnboardingProps) {
</View> </View>
)} )}
{current.key === 'permissions' && Platform.OS === 'android' && (
<View style={{ width: '100%', marginTop: 24 }}>
<Card>
{[
{ key: 'accessibility', icon: 'accessibility-outline' as const, label: t('onboarding.permAccessibility') },
{ key: 'notification', icon: 'notifications-outline' as const, label: t('onboarding.permNotification') },
{ key: 'sms', icon: 'chatbubble-outline' as const, label: t('onboarding.permSms') },
{ key: 'storage', icon: 'images-outline' as const, label: t('onboarding.permStorage') },
{ key: 'overlay', icon: 'tablet-landscape-outline' as const, label: t('onboarding.permOverlay') },
].map(item => {
const status = permStates[item.key];
const granted = status === true;
const statusColor = granted ? theme.colors.success : theme.colors.fgSecondary;
const statusText = status === null ? '...' : granted ? t('onboarding.permGranted') : t('onboarding.permNotGranted');
return (
<View key={item.key} style={styles.permRow}>
<View style={styles.permLeft}>
<Ionicons name={item.icon} size={20} color={granted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, marginLeft: 12 }]}>
{item.label}
</Text>
</View>
<View style={styles.permRight}>
<Text style={[theme.typography.caption, { color: statusColor, marginRight: 8 }]}>
{statusText}
</Text>
{!granted && !allGranted && (
<Button
label={item.key === 'sms' || item.key === 'storage' ? t('onboarding.permRequest') : t('onboarding.permOpenSettings')}
onPress={() => handlePermAction(item.key)}
variant="secondary"
/>
)}
</View>
</View>
);
})}
</Card>
{allGranted && (
<View style={styles.allGrantedWrap}>
<Ionicons name="checkmark-circle" size={48} color={theme.colors.success} />
<Text style={[theme.typography.body, { color: theme.colors.success, marginTop: 8 }]}>
{t('onboarding.permAllDone')}
</Text>
</View>
)}
</View>
)}
{current.key === 'security' && ( {current.key === 'security' && (
<Pressable <Pressable
onPress={() => setAppLockEnabled(!appLockEnabled)} onPress={() => setAppLockEnabled(!appLockEnabled)}
@ -146,4 +259,9 @@ const styles = StyleSheet.create({
dots: { flexDirection: 'row', justifyContent: 'center', gap: 6, marginBottom: 12 }, dots: { flexDirection: 'row', justifyContent: 'center', gap: 6, marginBottom: 12 },
dot: { width: 8, height: 8, borderRadius: 4 }, dot: { width: 8, height: 8, borderRadius: 4 },
actions: { flexDirection: 'row', gap: 12, justifyContent: 'flex-end' }, actions: { flexDirection: 'row', gap: 12, justifyContent: 'flex-end' },
// 权限清单P7 Task 2
permRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
permLeft: { flexDirection: 'row', alignItems: 'center', flex: 1 },
permRight: { flexDirection: 'row', alignItems: 'center' },
allGrantedWrap: { alignItems: 'center', marginTop: 24 },
}); });

View File

@ -6,6 +6,7 @@ import { useTheme } from '../../theme';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { AccountCreateModal } from '../../components/AccountCreateModal';
import { FormModal, type FormField } from '../../components/FormModal'; import { FormModal, type FormField } from '../../components/FormModal';
import { ScreenHeader } from '../../components/ScreenHeader'; import { ScreenHeader } from '../../components/ScreenHeader';
import { toDateString } from '../../domain/decimal'; import { toDateString } from '../../domain/decimal';
@ -133,18 +134,29 @@ export default function AccountScreen() {
{ key: 'income', label: t('account.tabIncome') }, { 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[] = [ const fields: FormField[] = [
{ {
key: 'type', key: 'type',
label: t('account.fieldType'), label: t('account.fieldType'),
placeholder: 'Assets / Liabilities / Expenses / Income', type: 'dropdown',
options: rootTypeOptions,
defaultValue: tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income', defaultValue: tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income',
flex: 1,
}, },
{ {
key: 'name', key: 'name',
label: t('account.fieldName'), label: t('account.fieldName'),
placeholder: t('account.fieldNamePlaceholder'), placeholder: t('account.fieldNamePlaceholder'),
defaultValue: '', defaultValue: '',
flex: 1.5,
}, },
{ {
key: 'currency', key: 'currency',
@ -198,6 +210,8 @@ export default function AccountScreen() {
{filteredAccounts.map(account => { {filteredAccounts.map(account => {
const shortName = account.slice(prefix.length); const shortName = account.slice(prefix.length);
const rootType = account.split(':')[0] as 'Assets' | 'Liabilities' | 'Expenses' | 'Income' | 'Equity';
const localizedRoot = t(`account.rootLabels.${rootType}`);
const balance = balances.get(account) ?? '0.00'; const balance = balances.get(account) ?? '0.00';
const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY'; const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY';
@ -208,7 +222,7 @@ export default function AccountScreen() {
{t('account.fullName')} {t('account.fullName')}
</Text> </Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{account} {account} {localizedRoot ? `(${localizedRoot})` : ''}
</Text> </Text>
</View> </View>
<View style={styles.infoRow}> <View style={styles.infoRow}>
@ -257,10 +271,9 @@ export default function AccountScreen() {
</ScrollView> </ScrollView>
{/* 新开户对话框 */} {/* 新开户对话框 */}
<FormModal <AccountCreateModal
visible={isAdding} visible={isAdding}
title={t('account.addModalTitle')} defaultType={tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income'}
fields={fields}
onConfirm={handleAddAccount} onConfirm={handleAddAccount}
onCancel={() => setIsAdding(false)} onCancel={() => setIsAdding(false)}
/> />

View File

@ -17,6 +17,7 @@ import { useRouter } from 'expo-router';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai'; import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai';
import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant'; import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant';
import { ScreenHeader } from '../../components/ScreenHeader'; import { ScreenHeader } from '../../components/ScreenHeader';
@ -123,7 +124,7 @@ export default function AIChatScreen() {
return ( return (
<Pressable <Pressable
key={i} key={i}
onPress={() => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } })} onPress={() => useNumpadUiStore.getState().open({ autoOcr: true })}
style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent }]} style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent }]}
> >
<Text style={[theme.typography.bodySmall, { color: dirColor, fontWeight: '700' }]}> <Text style={[theme.typography.bodySmall, { color: dirColor, fontWeight: '700' }]}>

View File

@ -15,51 +15,55 @@
* - * -
* - App * - App
*/ */
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View, Switch } from 'react-native'; import { ActivityIndicator, Alert, AppState, Linking, NativeModules, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { createCommonStyles } from '../../theme/commonStyles';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useAutomationStore } from '../../store/automationStore'; import { useRouter } from 'expo-router';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/Button';
import { ScreenHeader } from '../../components/ScreenHeader'; import { ScreenHeader } from '../../components/ScreenHeader';
import type { AutomationSource } from '../../store/automationStore';
import { import {
getAccessibilityBridge, getAccessibilityBridge,
getPackageLabel, getPackageLabel,
type PageSignature, type PageSignature,
} from '../../services/accessibilityBridge'; } from '../../services/accessibilityBridge';
import { ensureOcrModels, downloadOcrModels } from '../../services/modelDownloader';
const SOURCE_LABELS: Record<AutomationSource, string> = {
notification: 'automation.sourceNotification',
sms: 'automation.sourceSms',
screenshot: 'automation.sourceScreenshot',
ocr: 'automation.sourceOcr',
manual: 'automation.sourceManual',
};
export default function AutomationScreen() { export default function AutomationScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT(); const t = useT();
const router = useRouter();
const detected = useAutomationStore(s => s.detected);
const drafts = useAutomationStore(s => s.drafts);
const stats = useAutomationStore(s => s.stats);
const processAll = useAutomationStore(s => s.processAll);
const confirmDraft = useAutomationStore(s => s.confirmDraft);
const rejectDraft = useAutomationStore(s => s.rejectDraft);
const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction);
const floatingBallEnabled = useSettingsStore(s => s.floatingBallEnabled); const floatingBallEnabled = useSettingsStore(s => s.floatingBallEnabled);
const setFloatingBallEnabled = useSettingsStore(s => s.setFloatingBallEnabled); const setFloatingBallEnabled = useSettingsStore(s => s.setFloatingBallEnabled);
// 自动记账层级
const layer1RuleEnabled = useSettingsStore(s => s.layer1RuleEnabled);
const setLayer1RuleEnabled = useSettingsStore(s => s.setLayer1RuleEnabled);
const layer2OcrEnabled = useSettingsStore(s => s.layer2OcrEnabled);
const setLayer2OcrEnabled = useSettingsStore(s => s.setLayer2OcrEnabled);
const layer3AiEnabled = useSettingsStore(s => s.layer3AiEnabled);
const setLayer3AiEnabled = useSettingsStore(s => s.setLayer3AiEnabled);
const ocrModelVersion = useSettingsStore(s => s.ocrModelVersion);
const 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);
const setTransferRecognitionEnabled = useSettingsStore(s => s.setTransferRecognitionEnabled);
// 无障碍服务状态 // 无障碍服务状态
const [serviceRunning, setServiceRunning] = useState(false); const [serviceRunning, setServiceRunning] = useState(false);
const [pageSignatures, setPageSignatures] = useState<PageSignature[]>([]); const [pageSignatures, setPageSignatures] = useState<PageSignature[]>([]);
@ -67,7 +71,74 @@ export default function AutomationScreen() {
const [screenshotActive, setScreenshotActive] = useState(false); const [screenshotActive, setScreenshotActive] = useState(false);
const [topApp, setTopApp] = useState<{ package: string; activity: string } | null>(null); const [topApp, setTopApp] = useState<{ package: string; activity: string } | null>(null);
const sourceEntries = useMemo(() => Object.entries(stats) as [AutomationSource, number][], [stats]); // 权限状态
const [notifEnabled, setNotifEnabled] = useState<boolean | null>(null);
const [smsGranted, setSmsGranted] = useState<boolean | null>(null);
const [storageGranted, setStorageGranted] = useState<boolean | null>(null);
const [overlayGranted, setOverlayGranted] = useState<boolean | null>(null);
// OCR 模型安装/下载状态
const [modelStatus, setModelStatus] = useState<'idle' | 'installing' | 'verifying' | 'done' | 'error'>('idle');
const [modelProgress, setModelProgress] = useState(0);
const handleInstallModel = async () => {
setModelStatus('installing');
try {
await ensureOcrModels((p) => setModelProgress(p));
setModelStatus('done');
} catch (e) {
setModelStatus('error');
Alert.alert(t('common.error'), String(e));
}
};
const handleRedownloadModel = async () => {
setModelStatus('installing');
setModelProgress(0);
try {
await downloadOcrModels(undefined, (p) => setModelProgress(p));
setModelStatus('done');
} catch (e) {
setModelStatus('error');
Alert.alert(t('automation.ocrModelDownloadFail'), String(e));
}
};
const handleL2Toggle = (val: boolean) => {
if (val && !ocrModelVersion) {
Alert.alert(t('automation.ocrModelTitle'), t('automation.ocrModelNotInstalled'), [
{ text: t('common.cancel'), onPress: () => setLayer2OcrEnabled(false) },
{ text: t('automation.ocrModelInstall'), onPress: handleInstallModel },
]);
return;
}
setLayer2OcrEnabled(val);
};
const scrollRef = useRef<ScrollView>(null);
const handleL3Toggle = (val: boolean) => {
if (val && (!aiEnabled || !aiApiKey)) {
Alert.alert(t('automation.layer3Ai'), t('automation.layer3AiDisabled'), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.confirm'), onPress: () => router.push('/settings/ai') },
]);
return;
}
setLayer3AiEnabled(val);
};
const 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 refreshAccessibilityState = useCallback(async () => {
@ -103,27 +174,56 @@ export default function AutomationScreen() {
}; };
}, [refreshAccessibilityState]); }, [refreshAccessibilityState]);
const handleProcess = async () => { // 检查权限状态(挂载 + 从系统设置返回前台时重新检查)
if (!ledger) return; useEffect(() => {
const rules = useMetadataStore.getState().rules; if (Platform.OS !== 'android') return;
const categories = useMetadataStore.getState().categories;
const history = ledger.transactions; const checkPermissions = () => {
try { const bridge = getAccessibilityBridge();
await processAll(ledger, rules, categories, history); if (bridge && typeof bridge.isNotificationListenerEnabled === 'function') {
} catch (e) { bridge.isNotificationListenerEnabled().then(setNotifEnabled).catch(() => setNotifEnabled(false));
Alert.alert(String(e)); }
} if (bridge && typeof bridge.canDrawOverlays === 'function') {
bridge.canDrawOverlays().then(setOverlayGranted).catch(() => setOverlayGranted(false));
}
const smsPerm = 'android.permission.RECEIVE_SMS';
PermissionsAndroid.check(smsPerm).then(async (granted) => {
if (granted) { setSmsGranted(true); return; }
try {
const result = await PermissionsAndroid.request(smsPerm);
setSmsGranted(result === PermissionsAndroid.RESULTS.GRANTED);
} catch { setSmsGranted(false); }
}).catch(() => setSmsGranted(false));
const storagePerm = Number(Platform.Version) >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
PermissionsAndroid.check(storagePerm).then(g => setStorageGranted(g as boolean)).catch(() => setStorageGranted(false));
};
checkPermissions();
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') checkPermissions();
});
return () => sub.remove();
}, []);
const handleOpenNotificationSettings = () => {
if (Platform.OS !== 'android') return;
Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS').catch(() => {});
}; };
const handleConfirm = async (index: number) => { const handleRequestSms = async () => {
const draft = confirmDraft(index); const result = await PermissionsAndroid.request('android.permission.RECEIVE_SMS');
if (!draft) return; setSmsGranted(result === PermissionsAndroid.RESULTS.GRANTED);
try { };
await addTransaction(draft.draft);
Alert.alert(t('automation.confirmed')); const handleOpenOverlaySettings = () => {
} catch (e) { Linking.sendIntent('android.settings.action.MANAGE_OVERLAY_PERMISSION').catch(() => {});
Alert.alert(String(e)); };
}
const handleRequestStorage = async () => {
const perm = Number(Platform.Version) >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
const result = await PermissionsAndroid.request(perm);
setStorageGranted(result === PermissionsAndroid.RESULTS.GRANTED);
}; };
const handleScreenshotToggle = () => { const handleScreenshotToggle = () => {
@ -251,37 +351,8 @@ export default function AutomationScreen() {
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader title={t('automation.title')} /> <ScreenHeader title={t('automation.title')} />
<FlatList <ScrollView ref={scrollRef} contentContainerStyle={styles.content}>
data={drafts}
keyExtractor={(item, index) => item.draft.sourceEventIds?.[0] ?? `${item.draft.date}-${index}`}
renderItem={({ item, index }) => (
<Card title={`${item.draft.date} · ${item.draft.narration}`}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{item.draft.postings.map(p => `${p.account} ${p.amount} ${p.currency ?? ''}`).join('\n')}
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 8 }}>
<Button label={t('automation.confirmDraft')} onPress={() => handleConfirm(index)} />
<Button label={t('automation.rejectDraft')} onPress={() => rejectDraft(index)} variant="secondary" />
</View>
</Card>
)}
ListHeaderComponent={
<View style={{ gap: 12, marginBottom: 12 }}> <View style={{ gap: 12, marginBottom: 12 }}>
{/* 通道统计 */}
<Card title={t('automation.channelStats')}>
<View style={styles.statsRow}>
{sourceEntries.map(([source, count]) => (
<View key={source} style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t(SOURCE_LABELS[source])}
</Text>
<Text style={[theme.typography.h3, { color: count > 0 ? theme.colors.accent : theme.colors.fgSecondary }]}>
{count}
</Text>
</View>
))}
</View>
</Card>
{/* 无障碍服务状态与控制 */} {/* 无障碍服务状态与控制 */}
{Platform.OS === 'android' && ( {Platform.OS === 'android' && (
@ -397,39 +468,186 @@ export default function AutomationScreen() {
</Card> </Card>
)} )}
{/* 检测到的事件 */} {/* --- Card A: 自动记账层级 --- */}
<Card title={t('automation.detectedEvents', { count: detected.length })}> <Card title={t('automation.autoBookkeeping')}>
{detected.length === 0 ? ( {/* L1 规则匹配 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <View style={[styles.layerRow, { marginBottom: 8 }]}>
{t('automation.noEvents')} <Ionicons name="flash-outline" size={20} color={layer1RuleEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('automation.layer1Rule')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.layer1RuleDesc')}
</Text>
</View>
<Switch
value={layer1RuleEnabled}
onValueChange={setLayer1RuleEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* L2 OCR 识别 */}
<View style={[styles.layerRow, { marginBottom: 8 }]}>
<Ionicons name="scan-outline" size={20} color={layer2OcrEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('automation.layer2Ocr')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.layer2OcrDesc')}
</Text>
</View>
<Switch
value={layer2OcrEnabled}
onValueChange={handleL2Toggle}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* L3 AI 视觉 */}
<View style={styles.layerRow}>
<Ionicons name="sparkles-outline" size={20} color={layer3AiEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('automation.layer3Ai')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.layer3AiDesc')}
</Text>
</View>
<Switch
value={layer3AiEnabled}
onValueChange={handleL3Toggle}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* --- Card B: OCR 模型 --- */}
<Card title={t('automation.ocrModelTitle')}>
{/* 状态行 */}
<View style={styles.layerRow}>
<Ionicons
name="hardware-chip-outline"
size={20}
color={ocrModelVersion ? theme.colors.success : theme.colors.fgSecondary}
/>
<Text style={[theme.typography.body, { flex: 1, marginLeft: 10, color: ocrModelVersion ? theme.colors.success : theme.colors.fgSecondary }]}>
{ocrModelVersion
? t('automation.ocrModelInstalled', { version: ocrModelVersion })
: t('automation.ocrModelNotInstalled')}
</Text> </Text>
) : ( </View>
<View style={{ gap: 4 }}>
{detected.map(ev => ( {/* 下载进度条 */}
<Text key={ev.id} style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}> {modelStatus === 'installing' && (
[{t(SOURCE_LABELS[ev.source])}] {ev.event.counterparty} · {ev.event.amount} {ev.event.currency} <View style={{ marginTop: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<ActivityIndicator size="small" color={theme.colors.accent} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, flex: 1 }]}>
{modelProgress > 0
? t('automation.ocrModelDownloading', { progress: String(modelProgress) })
: t('automation.ocrModelCopying')}
</Text> </Text>
))}
<View style={{ marginTop: 8 }}>
<Button label={t('automation.process')} onPress={handleProcess} />
</View> </View>
{modelProgress > 0 && (
<View style={[styles.progressBar, { backgroundColor: theme.colors.progressBg, marginTop: 6 }]}>
<View style={[styles.progressFill, { backgroundColor: theme.colors.accent, width: `${modelProgress}%` }]} />
</View>
)}
</View>
)}
{/* 操作按钮行 */}
{modelStatus !== 'installing' && (
<View style={[styles.layerRow, { marginTop: 8 }]}>
{!ocrModelVersion || modelStatus === 'error' ? (
<Button label={t('automation.ocrModelDownload')} onPress={handleInstallModel} variant="secondary" />
) : (
<Button label={t('automation.ocrModelRedownload')} onPress={handleRedownloadModel} variant="secondary" />
)}
</View> </View>
)} )}
</Card> </Card>
{/* 草稿标题 */} {/* --- Card C: 管道设置 --- */}
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}> <Card title={t('settings.algorithmConfig')}>
{t('automation.drafts', { count: drafts.length })} <View style={[styles.switchRow, { marginBottom: 8 }]}>
</Text> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.dedupLabel')}
</Text>
<Switch
value={dedupEnabled}
onValueChange={setDedupEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.transferLabel')}
</Text>
<Switch
value={transferRecognitionEnabled}
onValueChange={setTransferRecognitionEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* 其他权限 */}
{Platform.OS === 'android' && (
<Card title={t('automation.otherPermissions')}>
{/* 通知监听 */}
<View style={styles.permRow}>
<Ionicons name="notifications-outline" size={20} color={notifEnabled ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.notificationTitle')}</Text>
<Text style={{ color: notifEnabled ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{notifEnabled ? t('automation.notificationEnabled') : t('automation.notificationDisabled')}
</Text>
{!notifEnabled && <Button label={t('automation.notificationOpenSettings')} onPress={handleOpenNotificationSettings} variant="secondary" />}
</View>
{/* 短信 */}
<View style={[styles.permRow, { marginTop: 8 }]}>
<Ionicons name="chatbubble-outline" size={20} color={smsGranted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.smsPermissionTitle')}</Text>
<Text style={{ color: smsGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{t('automation.smsPermGranted')}
</Text>
{!smsGranted && <Button label={t('automation.smsPermRequest')} onPress={handleRequestSms} variant="secondary" />}
</View>
{/* 存储 */}
<View style={[styles.permRow, { marginTop: 8 }]}>
<Ionicons name="images-outline" size={20} color={storageGranted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.storagePermTitle')}</Text>
<Text style={{ color: storageGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{t('automation.storagePermGranted')}
</Text>
{!storageGranted && <Button label={t('automation.storagePermRequest')} onPress={handleRequestStorage} variant="secondary" />}
</View>
{/* 悬浮窗 */}
<View style={[styles.permRow, { marginTop: 8 }]}>
<Ionicons name="tablet-landscape-outline" size={20} color={overlayGranted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.overlayPermTitle')}</Text>
<Text style={{ color: overlayGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{overlayGranted ? t('automation.overlayPermGranted') : t('automation.overlayPermNotGranted')}
</Text>
{!overlayGranted && <Button label={t('automation.overlayPermOpenSettings')} onPress={handleOpenOverlaySettings} variant="secondary" />}
</View>
</Card>
)}
</View> </View>
} </ScrollView>
ListEmptyComponent={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('automation.noDrafts')}
</Text>
}
contentContainerStyle={styles.content}
/>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -442,6 +660,10 @@ const styles = StyleSheet.create({
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
statusDot: { width: 8, height: 8, borderRadius: 4 }, statusDot: { width: 8, height: 8, borderRadius: 4 },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
layerRow: { flexDirection: 'row', alignItems: 'center' },
chip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 }, chip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 },
progressBar: { height: 4, borderRadius: 2, overflow: 'hidden' },
progressFill: { height: '100%', borderRadius: 2 },
sigRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 4 }, sigRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 4 },
permRow: { flexDirection: 'row', alignItems: 'center' },
}); });

View File

@ -6,12 +6,13 @@
* P5 ManagementScreen headerContent / chips * P5 ManagementScreen headerContent / chips
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme, createCommonStyles } from '../../theme'; import { useTheme, createCommonStyles } from '../../theme';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { ManagementScreen } from '../../components/ManagementScreen'; import { ManagementScreen } from '../../components/ManagementScreen';
import { Touchable } from '../../components/Touchable';
import type { Category } from '../../domain/categories'; import type { Category } from '../../domain/categories';
export default function CategoryScreen() { export default function CategoryScreen() {
@ -36,7 +37,7 @@ export default function CategoryScreen() {
headerContent={ headerContent={
<View style={styles.chipRow}> <View style={styles.chipRow}>
{(['expense', 'income'] as const).map(tp => ( {(['expense', 'income'] as const).map(tp => (
<Pressable <Touchable
key={tp} key={tp}
onPress={() => setCatType(tp)} onPress={() => setCatType(tp)}
style={[commonStyles.chip, catType === tp && commonStyles.chipActive]} style={[commonStyles.chip, catType === tp && commonStyles.chipActive]}
@ -44,25 +45,19 @@ export default function CategoryScreen() {
<Text style={[commonStyles.chipText, catType === tp && commonStyles.chipTextActive]}> <Text style={[commonStyles.chipText, catType === tp && commonStyles.chipTextActive]}>
{tp === 'expense' ? t('category.expense') : t('category.income')} {tp === 'expense' ? t('category.expense') : t('category.income')}
</Text> </Text>
</Pressable> </Touchable>
))} ))}
</View> </View>
} }
renderItem={(cat, { openEdit, confirmDelete }) => ( renderItem={(cat, { openEdit, confirmDelete }) => (
<Pressable <Card title={cat.name} onPress={openEdit} onLongPress={confirmDelete}>
onPress={openEdit} <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
onLongPress={confirmDelete} {cat.keywords.length > 0 && (
style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]} <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
> {t('category.keywordsLabel')}: {cat.keywords.join(', ')}
<Card title={cat.name}> </Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text> )}
{cat.keywords.length > 0 && ( </Card>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{t('category.keywordsLabel')}: {cat.keywords.join(', ')}
</Text>
)}
</Card>
</Pressable>
)} )}
formTitle={editing => (editing formTitle={editing => (editing
? t('category.editTitle') ? t('category.editTitle')

View File

@ -5,14 +5,16 @@
* linkedAccount Beancount Liabilities * linkedAccount Beancount Liabilities
* P5 ManagementScreen renderItem * P5 ManagementScreen renderItem
*/ */
import React, { useMemo } from 'react'; import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { ManagementScreen } from '../../components/ManagementScreen'; import { ManagementScreen } from '../../components/ManagementScreen';
import { AccountCreateModal } from '../../components/AccountCreateModal';
import { Touchable } from '../../components/Touchable';
import type { CreditCard } from '../../domain/creditCards'; import type { CreditCard } from '../../domain/creditCards';
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards'; import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards';
@ -26,8 +28,25 @@ export default function CreditCardScreen() {
const removeCreditCard = useMetadataStore(s => s.removeCreditCard); const removeCreditCard = useMetadataStore(s => s.removeCreditCard);
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
const transactions = ledger?.transactions ?? []; const transactions = ledger?.transactions ?? [];
const [isQuickAddingAccount, setIsQuickAddingAccount] = useState(false);
/** 过滤出所有以 Liabilities 开头的信用负债账户。 */
const liabilityAccounts = useMemo(() => {
if (!ledger?.accounts) return [];
const accountNames = Array.from(ledger.accounts.keys());
return accountNames.filter((name: string) => name.startsWith('Liabilities'));
}, [ledger?.accounts]);
const liabilityOptions = useMemo(() => {
return liabilityAccounts.map((name: string) => ({
label: name.replace(/^Liabilities:/, ''),
value: name,
}));
}, [liabilityAccounts]);
/** 预计算所有账户余额 Map避免在渲染循环中重复遍历。 */ /** 预计算所有账户余额 Map避免在渲染循环中重复遍历。 */
const accountBalances = useMemo(() => { const accountBalances = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@ -44,21 +63,29 @@ export default function CreditCardScreen() {
const getAccountBalance = (account: string): string => accountBalances.get(account) ?? '0.00'; const getAccountBalance = (account: string): string => accountBalances.get(account) ?? '0.00';
const handleQuickAddAccount = (values: Record<string, string>) => {
const type = values.type || 'Liabilities';
const name = values.name?.trim() || 'CreditCard';
const fullAccountName = `${type}:${name}`;
autoOpenAccounts([fullAccountName]);
setIsQuickAddingAccount(false);
};
return ( return (
<ManagementScreen<CreditCard> <>
title={t('creditCard.title')} <ManagementScreen<CreditCard>
items={creditCards} title={t('creditCard.title')}
keyExtractor={card => card.id} items={creditCards}
addLabel={t('creditCard.add')} keyExtractor={card => card.id}
emptyText={t('creditCard.empty')} addLabel={t('creditCard.add')}
renderItem={(card, { openEdit, confirmDelete }) => { emptyText={t('creditCard.empty')}
const period = calculateBillingPeriod(card, new Date()); renderItem={(card, { openEdit, confirmDelete }) => {
const statementAmount = calculateStatementAmount(transactions, card, period); const period = calculateBillingPeriod(card, new Date());
const currentBalance = getAccountBalance(card.linkedAccount); const statementAmount = calculateStatementAmount(transactions, card, period);
const availableCredit = calculateAvailableCredit(card, currentBalance); const currentBalance = getAccountBalance(card.linkedAccount);
return ( const availableCredit = calculateAvailableCredit(card, currentBalance);
<Pressable onPress={openEdit} onLongPress={confirmDelete}> return (
<Card title={card.name}> <Card title={card.name} onPress={openEdit} onLongPress={confirmDelete}>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
@ -97,51 +124,112 @@ export default function CreditCardScreen() {
</View> </View>
</View> </View>
</Card> </Card>
</Pressable> );
); }}
}} formTitle={editing => (editing ? t('creditCard.editTitle') : t('creditCard.add'))}
formTitle={editing => (editing ? t('creditCard.editTitle') : t('creditCard.add'))} onValuesChange={(changedKey: string, newValue: string, currentValues: Record<string, string>) => {
formFields={editing => [ if (changedKey === 'linkedAccount' && newValue) {
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: editing?.name }, const shortName = newValue.includes(':') ? newValue.slice(newValue.lastIndexOf(':') + 1) : newValue;
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: 'CMB', defaultValue: editing?.bankName }, const updates: Record<string, string> = {};
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: editing?.lastFour, keyboardType: 'numeric' }, if (shortName) {
{ key: 'billingDay', label: t('creditCard.fieldBillingDay'), placeholder: '5', defaultValue: editing ? String(editing.billingDay) : '', keyboardType: 'numeric' }, updates.name = shortName;
{ key: 'paymentDay', label: t('creditCard.fieldPaymentDay'), placeholder: '25', defaultValue: editing ? String(editing.paymentDay) : '', keyboardType: 'numeric' }, if (shortName.includes('招商') || shortName.toUpperCase().includes('CMB')) updates.bankName = '招商银行';
{ key: 'creditLimit', label: t('creditCard.fieldLimit'), placeholder: '10000', defaultValue: editing?.creditLimit, keyboardType: 'decimal-pad' }, else if (shortName.includes('支付宝') || shortName.includes('花呗')) updates.bankName = '支付宝';
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: editing?.currency }, else if (shortName.includes('微信') || shortName.includes('微粒贷')) updates.bankName = '微信';
{ key: 'linkedAccount', label: t('creditCard.fieldLinkedAccount'), placeholder: 'Liabilities:CreditCard:CMB', defaultValue: editing?.linkedAccount }, else if (shortName.includes('建设') || shortName.toUpperCase().includes('CCB')) updates.bankName = '建设银行';
]} else if (shortName.includes('工商') || shortName.toUpperCase().includes('ICBC')) updates.bankName = '工商银行';
onSubmit={(values, editing) => { else if (shortName.includes('中国银行') || shortName.toUpperCase().includes('BOC')) updates.bankName = '中国银行';
const card: Omit<CreditCard, 'id'> = { else if (shortName.includes('农业') || shortName.toUpperCase().includes('ABC')) updates.bankName = '农业银行';
name: values.name?.trim() || t('creditCard.unnamed'), else if (shortName.includes('交通') || shortName.toUpperCase().includes('BOCOM')) updates.bankName = '交通银行';
lastFour: values.lastFour?.trim() || '0000', else if (!currentValues.bankName) updates.bankName = shortName;
bankName: values.bankName?.trim() || 'UNKNOWN', }
billingDay: parseInt(values.billingDay, 10) || 1, return updates;
paymentDay: parseInt(values.paymentDay, 10) || 1, }
creditLimit: values.creditLimit?.trim() || '0', }}
currency: values.currency?.trim() || 'CNY', formFields={editing => [
linkedAccount: values.linkedAccount?.trim() || 'Liabilities:CreditCard', // Row 1: linkedAccount (1.5) + name (1.0)
}; liabilityOptions.length > 0
if (editing) { ? {
updateCreditCard(editing.id, card); key: 'linkedAccount',
} else { label: t('creditCard.fieldLinkedAccount'),
addCreditCard({ ...card, id: generateId('cc') }); type: 'dropdown',
options: liabilityOptions,
placeholder: '请选择关联信用账户',
defaultValue: editing?.linkedAccount ?? '',
flex: 1.5,
row: 1,
}
: {
key: 'linkedAccount',
label: t('creditCard.fieldLinkedAccount'),
placeholder: 'Liabilities:CreditCard:CMB',
defaultValue: editing?.linkedAccount ?? '',
flex: 1.5,
row: 1,
},
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: editing?.name, flex: 1.0, row: 1 },
// Row 2: bankName (1.0) + lastFour (1.0) + currency (1.0)
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: '招商银行', defaultValue: editing?.bankName, flex: 1.0, row: 2 },
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: editing?.lastFour, keyboardType: 'numeric', flex: 1.0, row: 2 },
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: editing?.currency ?? 'CNY', flex: 1.0, row: 2 },
// Row 3: billingDay (1.0) + paymentDay (1.0) + creditLimit (1.2)
{ key: 'billingDay', label: t('creditCard.fieldBillingDay'), placeholder: '5', defaultValue: editing ? String(editing.billingDay) : '5', keyboardType: 'numeric', flex: 1.0, row: 3 },
{ key: 'paymentDay', label: t('creditCard.fieldPaymentDay'), placeholder: '25', defaultValue: editing ? String(editing.paymentDay) : '25', keyboardType: 'numeric', flex: 1.0, row: 3 },
{ key: 'creditLimit', label: t('creditCard.fieldLimit'), placeholder: '10000', defaultValue: editing?.creditLimit ?? '10000', keyboardType: 'decimal-pad', flex: 1.2, row: 3 },
]}
formExtra={() => (
<Touchable
onPress={() => setIsQuickAddingAccount(true)}
style={styles.quickAddBtn}
>
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontWeight: '600' }]}>
</Text>
</Touchable>
)}
onSubmit={(values, editing) => {
const card: Omit<CreditCard, 'id'> = {
name: values.name?.trim() || t('creditCard.unnamed'),
lastFour: values.lastFour?.trim() || '0000',
bankName: values.bankName?.trim() || 'UNKNOWN',
billingDay: parseInt(values.billingDay, 10) || 1,
paymentDay: parseInt(values.paymentDay, 10) || 1,
creditLimit: values.creditLimit?.trim() || '0',
currency: values.currency?.trim() || 'CNY',
linkedAccount: values.linkedAccount?.trim() || 'Liabilities:CreditCard',
};
if (editing) {
updateCreditCard(editing.id, card);
} else {
addCreditCard({ ...card, id: generateId('cc') });
}
return true;
}}
onDelete={card => removeCreditCard(card.id)}
deleteConfirmText={card => t('creditCard.deleteConfirm', { name: card.name })}
deleteConfirmTitle={t('creditCard.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
} }
return true; />
}}
onDelete={card => removeCreditCard(card.id)} {/* 快捷新建负债账户弹窗 */}
deleteConfirmText={card => t('creditCard.deleteConfirm', { name: card.name })} <AccountCreateModal
deleteConfirmTitle={t('creditCard.deleteTitle')} visible={isQuickAddingAccount}
footer={ defaultType="Liabilities"
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}> onConfirm={handleQuickAddAccount}
{t('common.clickEditLongDelete')} onCancel={() => setIsQuickAddingAccount(false)}
</Text> />
} </>
/>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 }, infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 }, billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
quickAddBtn: { paddingVertical: 6, alignItems: 'center', marginTop: 4 },
}); });

View File

@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { DuplicateDetail } from '../../domain/dedup';
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native'; import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@ -143,7 +144,7 @@ export default function ImportScreen() {
// 6. 自动执行 Pipeline用户无需手动查找运行按钮 // 6. 自动执行 Pipeline用户无需手动查找运行按钮
if (ledger) { if (ledger) {
setStatus(t('importFlow.pipelineRunning')); setStatus(t('importFlow.pipelineRunning'));
processEvents(ledger, []).then(result => { processEvents(ledger, ledger.transactions || []).then(result => {
setManualDuplicates(result.duplicates); setManualDuplicates(result.duplicates);
setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length })); setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length }));
}).catch(e => { }).catch(e => {
@ -158,7 +159,7 @@ export default function ImportScreen() {
const runPipeline = () => { const runPipeline = () => {
if (!ledger) return; if (!ledger) return;
processEvents(ledger, []).then(result => { processEvents(ledger, ledger.transactions || []).then(result => {
setManualDuplicates(result.duplicates); setManualDuplicates(result.duplicates);
setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount })); setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount }));
}).catch(e => setStatus(String(e))); }).catch(e => setStatus(String(e)));
@ -269,11 +270,59 @@ export default function ImportScreen() {
const rules = useMetadataStore.getState().rules; const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories; const categories = useMetadataStore.getState().categories;
const classification = classifyWithCategories(event, rules, categories, ledger); const classification = classifyWithCategories(event, rules, categories, ledger);
await addTransaction(classification.draft); await addTransaction(classification.draft, undefined, true);
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id)); setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
setStatus(t('importFlow.forcedImport')); setStatus(t('importFlow.forcedImport'));
Alert.alert('记账成功', `已成功将「${event.counterparty || event.memo || '交易'}」单独记入账本`);
} catch (err) { } catch (err) {
setStatus(String(err)); setStatus(String(err));
Alert.alert('记账失败', String(err));
}
};
const handleLinkDuplicate = async (event: ImportedEvent) => {
if (!ledger) return;
try {
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const classification = classifyWithCategories(event, rules, categories, ledger);
// 寻找对应的历史匹配交易
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === event.id);
const matchedTx = detail?.matchedWith?.rawTransaction;
let linkTag = matchedTx?.links?.[0] || 'lnk-' + Math.random().toString(36).slice(2, 8);
// 为新草稿添加链接
const newDraft = {
...classification.draft,
links: Array.from(new Set([...(classification.draft.links || []), linkTag])),
};
await addTransaction(newDraft, undefined, true);
// 如果有历史匹配交易且它还没有此 linkTag同步写回历史交易 raw
if (matchedTx && !matchedTx.links.includes(linkTag)) {
const currentRaw = matchedTx.raw;
const firstLineEnd = currentRaw.indexOf('\n');
const header = firstLineEnd !== -1 ? currentRaw.slice(0, firstLineEnd) : currentRaw;
const rest = firstLineEnd !== -1 ? currentRaw.slice(firstLineEnd) : '';
const updatedHeader = `${header} ^${linkTag}`;
const updatedRaw = `${updatedHeader}${rest}`;
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
if (mobileBean.includes(currentRaw)) {
const newContent = mobileBean.replace(currentRaw, updatedRaw);
await replaceMobileBean(newContent);
}
}
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
setStatus(`已成功关联交易 (标签 ^${linkTag})`);
Alert.alert('关联成功', `已成功将「${event.counterparty || event.memo || '交易'}」与历史账单关联(关联标签 ^${linkTag}`);
} catch (err) {
setStatus(String(err));
Alert.alert('关联失败', String(err));
} }
}; };
@ -344,23 +393,45 @@ export default function ImportScreen() {
{showDuplicates && ( {showDuplicates && (
<View style={styles.duplicatesList}> <View style={styles.duplicatesList}>
{manualDuplicates.map((item) => { {manualDuplicates.map((item) => {
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === item.id);
const matchedTx = detail?.matchedWith?.rawTransaction;
const reason = detail?.reason || t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') });
const result = { const result = {
isDuplicate: true, isDuplicate: true,
confidence: 'medium' as const, confidence: (detail?.confidence ?? 'medium') as any,
reason: t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') }) reason,
}; };
return ( return (
<Card key={item.id} title={`${item.occurredAt} · ${item.counterparty}`}> <Card
key={item.id}
title={`${item.occurredAt} · ${item.counterparty || '未知来源'}`}
onPress={() => handleLinkDuplicate(item)}
>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{item.memo || t('importFlow.noDesc')} {item.memo || t('importFlow.noDesc')}
</Text> </Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })} {t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })}
</Text> </Text>
<DedupBanner
result={result} {matchedTx && (
onAccept={() => handleAcceptDuplicate(item)} <View style={{ backgroundColor: `${theme.colors.accent}12`, padding: 8, borderRadius: 8, marginVertical: 4 }}>
onReject={() => handleRejectDuplicate(item)} <Text style={[theme.typography.caption, { color: theme.colors.accent, fontWeight: '700' }]}>
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, marginTop: 2 }]}>
{matchedTx.date} {matchedTx.payee ? matchedTx.payee + ' - ' : ''}{matchedTx.narration} ({matchedTx.postings[0]?.amount} {matchedTx.postings[0]?.currency})
</Text>
</View>
)}
<DedupBanner
result={result}
onAccept={() => handleAcceptDuplicate(item)}
onReject={() => handleRejectDuplicate(item)}
onLink={() => handleLinkDuplicate(item)}
/> />
</Card> </Card>
); );

View File

@ -1,146 +1,156 @@
/**
* LLM / AI P8
*
*
* - AI openai / gemini / deepseek
* - API Key
* - Base URL
* -
* - AI
*/
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Switch, Alert } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, TextInput, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { createCommonStyles } from '../../theme/commonStyles';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import { FormModal, type FormField } from '../../components/FormModal';
import { ScreenHeader } from '../../components/ScreenHeader'; import { ScreenHeader } from '../../components/ScreenHeader';
export default function AISettingsScreen() { const PROVIDERS: { key: string; label: string }[] = [
const { theme } = useTheme(); { key: 'openai', label: 'OpenAI' },
const t = useT(); { key: 'gemini', label: 'Gemini' },
{ key: 'deepseek', label: 'DeepSeek' },
];
// Settings State const PROVIDER_DEFAULT_URLS: Record<string, string> = {
const dedupEnabled = useSettingsStore(s => s.dedupEnabled); openai: 'https://api.openai.com/v1',
const setDedupEnabled = useSettingsStore(s => s.setDedupEnabled); gemini: 'https://generativelanguage.googleapis.com/v1beta',
const transferRecognitionEnabled = useSettingsStore(s => s.transferRecognitionEnabled); deepseek: 'https://api.deepseek.com/v1',
const setTransferRecognitionEnabled = useSettingsStore(s => s.setTransferRecognitionEnabled); };
const PROVIDER_DEFAULT_MODELS: Record<string, string> = {
openai: 'gpt-4o-mini',
gemini: 'gemini-2.0-flash',
deepseek: 'deepseek-chat',
};
export default function AiSettingsScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT();
const aiEnabled = useSettingsStore(s => s.aiEnabled); const aiEnabled = useSettingsStore(s => s.aiEnabled);
const aiProviderId = useSettingsStore(s => s.aiProviderId); const aiProviderId = useSettingsStore(s => s.aiProviderId);
const aiApiKey = useSettingsStore(s => s.aiApiKey) || ''; const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || 'https://api.openai.com/v1'; const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || '';
const aiModel = useSettingsStore(s => s.aiModel) || 'gpt-4o-mini'; const aiModel = useSettingsStore(s => s.aiModel) || '';
const updateAiConfig = useSettingsStore(s => s.updateAiConfig); const updateAiConfig = useSettingsStore(s => s.updateAiConfig);
const [aiModalVisible, setAiModalVisible] = useState(false); const [localKey, setLocalKey] = useState(aiApiKey);
const [localUrl, setLocalUrl] = useState(aiBaseUrl);
const [localModel, setLocalModel] = useState(aiModel);
const aiFields: FormField[] = [ const inputStyle = {
{ key: 'apiKey', label: t('settings.aiFieldApiKey'), placeholder: 'sk-xxxxxx', defaultValue: aiApiKey }, backgroundColor: theme.colors.bgTertiary,
{ key: 'baseUrl', label: t('settings.aiFieldBaseUrl'), placeholder: 'https://api.openai.com/v1', defaultValue: aiBaseUrl }, color: theme.colors.fgPrimary,
{ key: 'model', label: t('settings.aiFieldModel'), placeholder: 'gpt-4o-mini', defaultValue: aiModel }, borderRadius: theme.radii.sm,
]; padding: 12,
fontSize: 14,
const saveAI = (values: Record<string, string>) => { marginTop: 6,
updateAiConfig({
aiApiKey: values.apiKey,
aiBaseUrl: values.baseUrl,
aiModel: values.model,
});
setAiModalVisible(false);
}; };
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader title={t('settings.aiSettingsTitle')} /> <ScreenHeader title={t('settings.llmTitle')} />
<ScrollView contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={styles.content}>
{/* 自动记账与算法开关 */} {/* AI 总开关 */}
<Card title={t('settings.algorithmConfig')}> <Card>
<View style={styles.switchRow}> <View style={styles.row}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.dedupLabel')}
</Text>
<Switch
value={dedupEnabled}
onValueChange={setDedupEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
<View style={[styles.switchRow, { marginTop: 12 }]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.transferLabel')}
</Text>
<Switch
value={transferRecognitionEnabled}
onValueChange={setTransferRecognitionEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* AI 智能助理配置 */}
<Card title={t('settings.aiAssistantTitle')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.enableAiHint')} {t('settings.enableAiHint')}
</Text> </Text>
<Switch <Switch
value={aiEnabled} value={aiEnabled}
onValueChange={(val) => updateAiConfig({ aiEnabled: val })} onValueChange={val => updateAiConfig({ aiEnabled: val })}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }} trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse} thumbColor={theme.colors.fgInverse}
/> />
</View> </View>
{aiEnabled && ( </Card>
<View style={{ marginTop: 12, gap: 8 }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> {/* 服务商选择 */}
{t('settings.aiProvider')} <Card title={t('automation.aiProviderLabel')}>
</Text> <View style={styles.chipRow}>
<View style={styles.optionRow}> {PROVIDERS.map(p => (
{['openai', 'gemini', 'deepseek'].map((provider) => { <Pressable
const active = aiProviderId === provider; key={p.key}
return ( onPress={() => updateAiConfig({ aiProviderId: p.key as 'openai' | 'gemini' | 'deepseek' })}
<Pressable style={[
key={provider} commonStyles.chip,
onPress={() => updateAiConfig({ aiProviderId: provider as any })} aiProviderId === p.key && commonStyles.chipActive,
style={[ ]}
styles.option, >
{ <Text style={[commonStyles.chipText, aiProviderId === p.key && commonStyles.chipTextActive]}>
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary, {p.label}
borderColor: active ? theme.colors.accent : theme.colors.border, </Text>
}, </Pressable>
]} ))}
> </View>
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontWeight: active ? '700' : '400' }}> </Card>
{provider.toUpperCase()}
</Text> {/* API Key */}
</Pressable> <Card title={t('settings.aiFieldApiKey')}>
); <TextInput
})} style={inputStyle}
</View> value={localKey}
<View style={{ marginTop: 4 }}> onChangeText={setLocalKey}
<Button label={t('settings.aiConfigBtn')} onPress={() => setAiModalVisible(true)} variant="secondary" /> onBlur={() => updateAiConfig({ aiApiKey: localKey })}
</View> placeholder="sk-..."
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}> placeholderTextColor={theme.colors.fgSecondary}
{t('settings.aiCurrentModel', { model: aiModel, url: aiBaseUrl })} secureTextEntry
</Text> autoCapitalize="none"
</View> autoCorrect={false}
)} />
</Card>
{/* Base URL */}
<Card title={t('settings.aiFieldBaseUrl')}>
<TextInput
style={inputStyle}
value={localUrl}
onChangeText={setLocalUrl}
onBlur={() => updateAiConfig({ aiBaseUrl: localUrl })}
placeholder={PROVIDER_DEFAULT_URLS[aiProviderId] || PROVIDER_DEFAULT_URLS.openai}
placeholderTextColor={theme.colors.fgSecondary}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</Card>
{/* 模型 */}
<Card title={t('settings.aiFieldModel')}>
<TextInput
style={inputStyle}
value={localModel}
onChangeText={setLocalModel}
onBlur={() => updateAiConfig({ aiModel: localModel })}
placeholder={PROVIDER_DEFAULT_MODELS[aiProviderId] || PROVIDER_DEFAULT_MODELS.openai}
placeholderTextColor={theme.colors.fgSecondary}
autoCapitalize="none"
autoCorrect={false}
/>
</Card> </Card>
</ScrollView> </ScrollView>
{/* AI 配置模态框 */}
<FormModal
visible={aiModalVisible}
title={t('settings.aiConfigTitle')}
fields={aiFields}
onConfirm={saveAI}
onCancel={() => setAiModalVisible(false)}
/>
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
content: { padding: 16, gap: 16 }, content: { padding: 16, paddingBottom: 64, gap: 12 },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
optionRow: { flexDirection: 'row', gap: 8 }, chipRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' },
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 },
}); });

448
src/app/settings/logs.tsx Normal file
View File

@ -0,0 +1,448 @@
/**
* -> ->
*
*
* 1. +
* 2. 4 DEBUG/INFO/WARN/ERROR/Tag
* 3. JSON data Stack Trace
* 4. /
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
FlatList,
Modal,
Pressable,
ScrollView,
Share,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { ScreenHeader } from '../../components/ScreenHeader';
import { useT } from '../../i18n';
import { useTheme } from '../../theme';
import { LogEntry, LogFileInfo, LogLevel, LEVEL_LABELS, logger } from '../../utils/logger';
export default function LogsScreen() {
const { theme } = useTheme();
const t = useT();
const [selectedFileDate, setSelectedFileDate] = useState<string | null>(null); // null 表示“今天(实时)”
const [logFiles, setLogFiles] = useState<LogFileInfo[]>([]);
const [fileLogs, setFileLogs] = useState<LogEntry[]>([]);
const [liveLogs, setLiveLogs] = useState<LogEntry[]>([]);
const [levelFilter, setLevelFilter] = useState<LogLevel | 'ALL'>('ALL');
const [dateModalOpen, setDateModalOpen] = useState(false);
const selectedFile = useMemo(() => {
return logFiles.find(f => f.date === selectedFileDate);
}, [logFiles, selectedFileDate]);
const [searchQuery, setSearchQuery] = useState<string>('');
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
// 刷新日志文件列表
const refreshFileList = useCallback(async () => {
const files = await logger.getLogFiles();
setLogFiles(files);
}, []);
// 刷新实时内存日志与历史日志
const refreshLogs = useCallback(async () => {
// 强制刷新日志写入队列
await logger.flushQueue();
await refreshFileList();
if (selectedFileDate === null) {
const todayLogs = await logger.getTodayLogs();
setLiveLogs(todayLogs);
} else {
const loaded = await logger.loadLogsFromFile(selectedFileDate);
setFileLogs(loaded);
}
}, [selectedFileDate, refreshFileList]);
useEffect(() => {
void refreshLogs();
}, [refreshLogs]);
// 当前激活显示的原始日志列表
const rawLogs = selectedFileDate === null ? liveLogs : fileLogs;
// 过滤后的日志列表(按时间倒序排列:最新的日志在前面)
const filteredLogs = useMemo(() => {
const list = rawLogs.filter(e => {
// 级别过滤
if (levelFilter !== 'ALL' && e.level !== levelFilter) {
return false;
}
// 搜索过滤
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase();
const msgMatch = e.message.toLowerCase().includes(q);
const tagMatch = e.tag.toLowerCase().includes(q);
const dataMatch = e.data ? JSON.stringify(e.data).toLowerCase().includes(q) : false;
if (!msgMatch && !tagMatch && !dataMatch) {
return false;
}
}
return true;
});
// 倒序排列(最新在前)
return list.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}, [rawLogs, levelFilter, searchQuery]);
// 统计各级别数量
const counts = useMemo(() => {
const res = { ALL: rawLogs.length, [LogLevel.DEBUG]: 0, [LogLevel.INFO]: 0, [LogLevel.WARN]: 0, [LogLevel.ERROR]: 0 };
for (const item of rawLogs) {
if (res[item.level] !== undefined) {
res[item.level]++;
}
}
return res;
}, [rawLogs]);
// 导出日志文件/文本
const handleExport = async () => {
try {
const exportText = logger.exportAsFormattedText(filteredLogs);
if (!exportText || filteredLogs.length === 0) {
Alert.alert(t('common.error'), t('logs.empty'));
return;
}
if (await Sharing.isAvailableAsync()) {
const fileDateStr = selectedFileDate ?? new Date().toISOString().slice(0, 10);
const tempPath = `${FileSystem.cacheDirectory}app-log-${fileDateStr}-${Date.now()}.txt`;
await FileSystem.writeAsStringAsync(tempPath, exportText);
await Sharing.shareAsync(tempPath, {
mimeType: 'text/plain',
dialogTitle: t('logs.export'),
});
} else {
await Share.share({
message: exportText,
title: t('logs.title'),
});
}
} catch (e) {
Alert.alert(t('error.exportFail'), e instanceof Error ? e.message : String(e));
}
};
// 清空所有日志
const handleClear = () => {
Alert.alert(
t('logs.clearConfirmTitle'),
t('logs.clearConfirmDesc'),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('logs.clear'),
style: 'destructive',
onPress: async () => {
await logger.clearAllLogs();
setSelectedFileDate(null);
await refreshLogs();
Alert.alert(t('common.confirm'), t('logs.clearSuccess'));
},
},
],
);
};
// 获取级别对应的色彩
const getLevelStyle = (level: LogLevel) => {
switch (level) {
case LogLevel.DEBUG:
return { bg: theme.colors.bgTertiary, fg: theme.colors.fgSecondary };
case LogLevel.INFO:
return { bg: theme.colors.accent + '22', fg: theme.colors.accent };
case LogLevel.WARN:
return { bg: '#E6A23C22', fg: '#E6A23C' };
case LogLevel.ERROR:
return { bg: theme.colors.error + '22', fg: theme.colors.error };
}
};
// 格式化时间戳 (HH:mm:ss.SSS)
const formatTime = (isoString: string) => {
try {
const d = new Date(isoString);
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
const s = String(d.getSeconds()).padStart(2, '0');
const ms = String(d.getMilliseconds()).padStart(3, '0');
return `${h}:${m}:${s}.${ms}`;
} catch {
return isoString.slice(11, 23);
}
};
const renderHeaderRight = (
<View style={styles.headerRight}>
<Pressable onPress={handleExport} hitSlop={8} style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="share-outline" size={22} color={theme.colors.accent} />
</Pressable>
<Pressable onPress={handleClear} hitSlop={8} style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="trash-outline" size={22} color={theme.colors.error} />
</Pressable>
</View>
);
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader title={t('logs.title')} right={renderHeaderRight} />
{/* 日志日期 Dropdown 触发按钮 */}
<View style={[styles.fileSelectorBar, { borderBottomColor: theme.colors.divider }]}>
<Pressable
onPress={() => setDateModalOpen(true)}
style={[
styles.dropdownTriggerBtn,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
},
]}
>
<Ionicons name="calendar-outline" size={16} color={theme.colors.accent} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '600', flex: 1, marginLeft: 8 }]}>
{selectedFileDate === null
? t('logs.todayLive')
: `${selectedFileDate} (${Math.round((selectedFile?.size ?? 0) / 1024)}KB)`}
</Text>
<Ionicons name="chevron-down" size={16} color={theme.colors.fgSecondary} />
</Pressable>
</View>
{/* 日志日期 Dropdown 弹出菜单 Modal */}
<Modal
visible={dateModalOpen}
transparent
animationType="fade"
onRequestClose={() => setDateModalOpen(false)}
>
<Pressable
style={[styles.modalOverlay, { backgroundColor: theme.colors.overlay }]}
onPress={() => setDateModalOpen(false)}
>
<Pressable
style={[
styles.dropdownMenu,
theme.shadows.lg,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
},
]}
onPress={e => e.stopPropagation()}
>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, fontWeight: '600' }]}>
</Text>
<ScrollView style={{ maxHeight: 300 }}>
{/* 今天(实时) */}
<Pressable
onPress={() => {
setSelectedFileDate(null);
setDateModalOpen(false);
}}
style={[
styles.menuItem,
{
backgroundColor: selectedFileDate === null ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
borderColor: selectedFileDate === null ? theme.colors.accent : theme.colors.border,
},
]}
>
<Text style={[theme.typography.bodySmall, { color: selectedFileDate === null ? theme.colors.accent : theme.colors.fgPrimary, fontWeight: '600' }]}>
{t('logs.todayLive')}
</Text>
{selectedFileDate === null && (
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} />
)}
</Pressable>
{/* 历史日志文件 */}
{logFiles.map(file => {
const isSelected = selectedFileDate === file.date;
return (
<Pressable
key={file.name}
onPress={() => {
setSelectedFileDate(file.date);
setDateModalOpen(false);
}}
style={[
styles.menuItem,
{
backgroundColor: isSelected ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
},
]}
>
<Text style={[theme.typography.bodySmall, { color: isSelected ? theme.colors.accent : theme.colors.fgPrimary, fontWeight: '600' }]}>
{file.date} ({Math.round(file.size / 1024)}KB)
</Text>
{isSelected && (
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} />
)}
</Pressable>
);
})}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
{/* 搜索框 */}
<View style={styles.searchContainer}>
<View style={[styles.searchBox, { backgroundColor: theme.colors.bgSecondary, borderColor: theme.colors.border }]}>
<Ionicons name="search-outline" size={16} color={theme.colors.fgSecondary} />
<TextInput
style={[theme.typography.bodySmall, styles.searchInput, { color: theme.colors.fgPrimary }]}
placeholder={t('logs.searchPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
value={searchQuery}
onChangeText={setSearchQuery}
/>
{searchQuery ? (
<Pressable onPress={() => setSearchQuery('')} hitSlop={6}>
<Ionicons name="close-circle" size={18} color={theme.colors.fgSecondary} />
</Pressable>
) : null}
</View>
</View>
{/* 级别 Chip 筛选 */}
<View style={styles.levelFilterRow}>
{(['ALL', LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR] as const).map(lvl => {
const isSelected = levelFilter === lvl;
const label = lvl === 'ALL' ? t('logs.filterLevelAll', { count: counts.ALL }) : `${LEVEL_LABELS[lvl]} (${counts[lvl]})`;
return (
<Pressable
key={String(lvl)}
onPress={() => setLevelFilter(lvl)}
style={[
styles.levelChip,
{
backgroundColor: isSelected ? theme.colors.accent : theme.colors.bgTertiary,
},
]}
>
<Text
style={[
theme.typography.caption,
{ color: isSelected ? theme.colors.fgInverse : theme.colors.fgSecondary, fontWeight: isSelected ? '700' : '400' },
]}
>
{label}
</Text>
</Pressable>
);
})}
</View>
{/* 日志列表 */}
<FlatList
data={filteredLogs}
keyExtractor={(_, index) => String(index)}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="document-text-outline" size={48} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary, marginTop: 12 }]}>
{t('logs.empty')}
</Text>
</View>
}
renderItem={({ item, index }) => {
const style = getLevelStyle(item.level);
const isExpanded = expandedIndex === index;
const hasData = item.data !== undefined && item.data !== null;
return (
<Pressable
onPress={() => setExpandedIndex(isExpanded ? null : index)}
style={[
styles.logCard,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
},
]}
>
<View style={styles.cardHeader}>
<View style={[styles.levelBadge, { backgroundColor: style.bg }]}>
<Text style={[theme.typography.caption, { color: style.fg, fontWeight: '700', fontSize: 10 }]}>
{LEVEL_LABELS[item.level]}
</Text>
</View>
<Text style={[theme.typography.caption, styles.tagText, { color: theme.colors.accent }]}>
[{item.tag}]
</Text>
<Text style={[theme.typography.caption, styles.timeText, { color: theme.colors.fgSecondary }]}>
{formatTime(item.timestamp)}
</Text>
</View>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, marginTop: 6, lineHeight: 18 }]}>
{item.message}
</Text>
{hasData && (
<View style={styles.dataFooter}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{isExpanded ? t('logs.collapseData') : t('logs.expandData')}
</Text>
<Ionicons name={isExpanded ? 'chevron-up' : 'chevron-down'} size={14} color={theme.colors.fgSecondary} />
</View>
)}
{isExpanded && hasData && (
<View style={[styles.dataBox, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, fontFamily: 'monospace' }]}>
{typeof item.data === 'object' ? JSON.stringify(item.data, null, 2) : String(item.data)}
</Text>
</View>
)}
</Pressable>
);
}}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 16, marginRight: 8 },
fileSelectorBar: { paddingHorizontal: 16, paddingVertical: 8, borderBottomWidth: 1 },
dropdownTriggerBtn: { flexDirection: 'row', alignItems: 'center', borderRadius: 8, borderWidth: 1, paddingHorizontal: 12, height: 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 },
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 },
logCard: { borderRadius: 8, borderWidth: 1, padding: 12 },
cardHeader: { flexDirection: 'row', alignItems: 'center', gap: 8 },
levelBadge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
tagText: { fontWeight: '600' },
timeText: { marginLeft: 'auto' },
dataFooter: { flexDirection: 'row', alignItems: 'center', marginTop: 8, gap: 4 },
dataBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
emptyContainer: { alignItems: 'center', justifyContent: 'center', paddingTop: 60 },
});

View File

@ -15,10 +15,12 @@ import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../../theme'; import { useTheme, createCommonStyles } from '../../theme';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { hash } from '../../domain/ledger'; import { hash } from '../../domain/ledger';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { TransactionCard } from '../../components/TransactionCard'; import { TransactionCard } from '../../components/TransactionCard';
import { ScreenHeader } from '../../components/ScreenHeader'; import { ScreenHeader } from '../../components/ScreenHeader';
import { logger } from '../../utils/logger';
export default function TransactionDetailScreen() { export default function TransactionDetailScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
@ -49,7 +51,7 @@ export default function TransactionDetailScreen() {
// 筛选可以用来关联的其他账单 // 筛选可以用来关联的其他账单
const linkableTransactions = useMemo(() => { const linkableTransactions = useMemo(() => {
if (!ledger) return []; if (!ledger) return [];
return ledger.transactions const list = ledger.transactions
.filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l))) .filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l)))
.filter(t => { .filter(t => {
if (!searchQuery) return true; if (!searchQuery) return true;
@ -60,17 +62,19 @@ export default function TransactionDetailScreen() {
t.postings.some(p => p.account.toLowerCase().includes(q)) t.postings.some(p => p.account.toLowerCase().includes(q))
); );
}) })
.reverse() // 解决截断 Bug将顺序反转优先展示最新的交易 .sort((a, b) => b.date.localeCompare(a.date)); // 显式按交易日期【由新到旧 (最新 ➔ 最旧)】严格倒序排列
.slice(0, 30);
// 有搜索条件时展示最多 100 条匹配结果,无搜索词时默认展示最新 50 条
return searchQuery ? list.slice(0, 100) : list.slice(0, 50);
}, [ledger, id, tx?.links, searchQuery]); }, [ledger, id, tx?.links, searchQuery]);
const handleLinkTransaction = async (targetTx: typeof tx) => { const handleLinkTransaction = async (targetTx: typeof tx) => {
if (!tx || !targetTx) return; if (!tx || !targetTx) return;
let linkTag = tx.links[0] || targetTx.links[0] || '';
try { try {
const { mobileBean, replaceMobileBean } = useLedgerStore.getState(); const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
// 取出已有链接标签,若没有则生成一个随机哈希标签 // 取出已有链接标签,若没有则生成一个随机哈希标签
let linkTag = tx.links[0] || targetTx.links[0];
if (!linkTag) { if (!linkTag) {
linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8); linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8);
} }
@ -101,7 +105,30 @@ export default function TransactionDetailScreen() {
newContent = newContent.replaceAll(tx.raw, newCurrentRaw); newContent = newContent.replaceAll(tx.raw, newCurrentRaw);
newContent = newContent.replaceAll(targetTx.raw, newTargetRaw); newContent = newContent.replaceAll(targetTx.raw, newTargetRaw);
const formatTxSummary = (t: typeof tx) => {
if (!t) return '';
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
};
await replaceMobileBean(newContent); await replaceMobileBean(newContent);
logger.info('ledgerStore', `关联交易成功: 标签 ^${linkTag}\n【交易 1】: ${formatTxSummary(tx)}\n【交易 2】: ${formatTxSummary(targetTx)}`, {
linkTag,
transaction1: {
id: tx.id,
date: tx.date,
payee: tx.payee,
narration: tx.narration,
postings: tx.postings,
},
transaction2: {
id: targetTx.id,
date: targetTx.date,
payee: targetTx.payee,
narration: targetTx.narration,
postings: targetTx.postings,
},
});
setLinkModalVisible(false); setLinkModalVisible(false);
// 解决 race condition直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏 // 解决 race condition直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏
@ -111,6 +138,7 @@ export default function TransactionDetailScreen() {
} }
Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag })); Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag }));
} catch (e) { } catch (e) {
logger.error('ledgerStore', `关联交易失败 (Tag: ^${linkTag})`, e);
Alert.alert(t('transaction.linkFail'), String(e)); Alert.alert(t('transaction.linkFail'), String(e));
} }
}; };
@ -139,7 +167,25 @@ export default function TransactionDetailScreen() {
} }
} }
const formatTxSummary = (t: typeof tx) => {
if (!t) return '';
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
};
await replaceMobileBean(newContent); await replaceMobileBean(newContent);
const unlinkedListStr = companionTxs.map(c => formatTxSummary(c)).join('\n ');
logger.info('ledgerStore', `解除交易关联成功: 标签 ^${linkTag} (同步解绑 ${companionTxs.length} 笔相关交易)\n 解绑交易列表:\n ${unlinkedListStr}`, {
linkTag,
unlinkedCount: companionTxs.length,
unlinkedTransactions: companionTxs.map(c => ({
id: c.id,
date: c.date,
payee: c.payee,
narration: c.narration,
postings: c.postings,
})),
});
// 解决 race condition直接使用 hash() 算法计算新 ID // 解决 race condition直接使用 hash() 算法计算新 ID
const newId = hash(`main.bean:${newCurrentRaw}`); const newId = hash(`main.bean:${newCurrentRaw}`);
@ -148,6 +194,7 @@ export default function TransactionDetailScreen() {
} }
Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag })); Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag }));
} catch (e) { } catch (e) {
logger.error('ledgerStore', `解除交易关联失败 (Tag: ^${linkTag})`, e);
Alert.alert(t('transaction.unlinkFail'), String(e)); Alert.alert(t('transaction.unlinkFail'), String(e));
} }
}; };
@ -195,39 +242,111 @@ export default function TransactionDetailScreen() {
</View> </View>
</Card> </Card>
{/* Postings */} {/* 资金流向 */}
<Card title={`${t('transaction.postings')} (${tx.postings.length})`}> <Card title={`${t('transaction.postings')} (${tx.postings.length})`}>
{tx.postings.map((p, i) => ( <View style={{ gap: 8 }}>
<View key={i} style={[styles.postingRow, { borderTopColor: theme.colors.divider, borderTopWidth: i > 0 ? 1 : 0 }]}> {tx.postings.map((p, i) => {
<View style={{ flex: 1 }}> const parts = p.account.split(':');
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}> const rootType = parts[0];
{p.account} const accountShortName = parts.slice(1).join(':') || p.account;
</Text>
{p.metadata && Object.entries(p.metadata).map(([k, v]) => ( let categoryTag = t('account.title');
<Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> let iconName: keyof typeof Ionicons.glyphMap = 'swap-horizontal-outline';
{k}: {v} let tagBg = `${theme.colors.accent}15`;
</Text> let tagColor = theme.colors.accent;
))}
</View> if (rootType === 'Assets') {
<View style={{ alignItems: 'flex-end' }}> categoryTag = t('account.rootTypes.Assets');
{p.amount && ( iconName = 'wallet-outline';
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}> tagBg = `${theme.colors.financial.income}15`;
{p.amount} {p.currency ?? ''} tagColor = theme.colors.financial.income;
</Text> } else if (rootType === 'Expenses') {
)} categoryTag = t('account.rootTypes.Expenses');
{p.cost && ( iconName = 'cart-outline';
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> tagBg = `${theme.colors.financial.expense}15`;
{p.cost} tagColor = theme.colors.financial.expense;
</Text> } else if (rootType === 'Income') {
)} categoryTag = t('account.rootTypes.Income');
{p.price && ( iconName = 'cash-outline';
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> tagBg = `${theme.colors.financial.income}15`;
@ {p.price} tagColor = theme.colors.financial.income;
</Text> } else if (rootType === 'Liabilities') {
)} categoryTag = t('account.rootTypes.Liabilities');
</View> iconName = 'card-outline';
</View> tagBg = `${theme.colors.warning}15`;
))} tagColor = theme.colors.warning;
} else if (rootType === 'Equity') {
categoryTag = t('account.rootTypes.Equity');
iconName = 'options-outline';
}
const amountVal = parseFloat(p.amount ?? '0');
const isNegative = amountVal < 0;
return (
<View
key={i}
style={[
styles.flowCardItem,
{
backgroundColor: theme.colors.bgTertiary,
borderRadius: theme.radii.lg,
},
]}
>
<View style={[styles.flowIconBox, { backgroundColor: tagBg }]}>
<Ionicons name={iconName} size={18} color={tagColor} />
</View>
<View style={{ flex: 1, marginLeft: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View style={[styles.miniTypeTag, { backgroundColor: `${theme.colors.fgSecondary}15` }]}>
<Text style={{ fontSize: 10, color: theme.colors.fgSecondary, fontWeight: '600' }}>
{categoryTag}
</Text>
</View>
</View>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700', marginTop: 2 }]}>
{accountShortName}
</Text>
{p.metadata && Object.entries(p.metadata).map(([k, v]) => (
<Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{k}: {v}
</Text>
))}
</View>
<View style={{ alignItems: 'flex-end', justifyContent: 'center' }}>
{p.amount && (
<Text
style={[
theme.typography.body,
{
color: isNegative ? theme.colors.financial.expense : theme.colors.fgPrimary,
fontWeight: '700',
},
]}
>
{p.amount} {p.currency ?? ''}
</Text>
)}
{p.cost && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
: {p.cost}
</Text>
)}
{p.price && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
: @ {p.price}
</Text>
)}
</View>
</View>
);
})}
</View>
</Card> </Card>
{/* 标签 */} {/* 标签 */}
@ -292,9 +411,13 @@ export default function TransactionDetailScreen() {
</Card> </Card>
)} )}
{/* 元数据 */} {/* 元数据 / 附加信息 */}
{tx.metadata && Object.keys(tx.metadata).length > 0 && ( {tx.metadata && Object.keys(tx.metadata).length > 0 && (
<Card title={t('transaction.metadata')}> <Card
title={`${t('transaction.metadata')} (${Object.keys(tx.metadata).length})`}
collapsible
defaultCollapsed
>
{Object.entries(tx.metadata).map(([k, v]) => ( {Object.entries(tx.metadata).map(([k, v]) => (
<View key={k} style={styles.metaRow}> <View key={k} style={styles.metaRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{k}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{k}</Text>
@ -305,7 +428,11 @@ export default function TransactionDetailScreen() {
)} )}
{/* 原始 .bean */} {/* 原始 .bean */}
<Card title={t('transaction.rawBean')}> <Card
title={t('transaction.rawBean')}
collapsible
defaultCollapsed
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'], lineHeight: 20 }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'], lineHeight: 20 }]}>
{tx.raw} {tx.raw}
</Text> </Text>
@ -318,7 +445,7 @@ export default function TransactionDetailScreen() {
{isEditable && ( {isEditable && (
<View style={styles.editActions}> <View style={styles.editActions}>
<Pressable <Pressable
onPress={() => router.push({ pathname: '/transaction/new', params: { editId: id } })} onPress={() => useNumpadUiStore.getState().open({ editId: id })}
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]} style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
> >
<Ionicons name="create-outline" size={16} color={theme.colors.accent} /> <Ionicons name="create-outline" size={16} color={theme.colors.accent} />
@ -421,4 +548,7 @@ const styles = StyleSheet.create({
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 }, metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' }, editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 }, editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 },
flowCardItem: { flexDirection: 'row', alignItems: 'center', padding: 10 },
flowIconBox: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
miniTypeTag: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
}); });

View File

@ -1,29 +0,0 @@
/**
* NumpadSheet 宿P3
* editId/ 稿draftJson/ OCRmode=ocr
*/
import React from 'react';
import { StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams } from 'expo-router';
import { useTheme } from '../../theme';
import { NumpadSheet } from '../../components/NumpadSheet';
export default function NewTransactionScreen() {
const { theme } = useTheme();
const { mode, editId, draftJson } = useLocalSearchParams<{ mode?: string; editId?: string; draftJson?: string }>();
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['bottom', 'left', 'right']}>
<NumpadSheet
presentation="page"
editId={editId}
draftJson={draftJson}
autoOcr={mode === 'ocr'}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
});

View File

@ -0,0 +1,60 @@
import React, { useMemo } from 'react';
import { useT } from '../i18n';
import { FormModal, type FormField } from './FormModal';
export interface AccountCreateModalProps {
visible: boolean;
defaultType?: string;
onConfirm: (values: Record<string, string>) => void;
onCancel: () => void;
}
/**
*
* 3 Dropdown (1.2) + TextInput (2.0) + TextInput (0.9)
*/
export function AccountCreateModal({ visible, defaultType = 'Assets', onConfirm, onCancel }: AccountCreateModalProps) {
const t = useT();
const fields: FormField[] = useMemo(() => [
// Row 1: 账户类型 Dropdown (flex: 1) + 币种 TextInput (flex: 1)
{
key: 'type',
label: t('account.fieldType'),
type: 'dropdown',
options: [
{ label: t('account.rootTypes.Assets'), value: 'Assets' },
{ label: t('account.rootTypes.Liabilities'), value: 'Liabilities' },
{ label: t('account.rootTypes.Expenses'), value: 'Expenses' },
{ label: t('account.rootTypes.Income'), value: 'Income' },
{ label: t('account.rootTypes.Equity'), value: 'Equity' },
],
defaultValue: defaultType,
flex: 1,
},
{
key: 'currency',
label: t('account.fieldCurrency'),
placeholder: 'CNY',
defaultValue: 'CNY',
flex: 1,
},
// Row 2: 账户名称 TextInput (全宽大输入框)
{
key: 'name',
label: t('account.fieldName'),
placeholder: t('account.fieldNamePlaceholder'),
defaultValue: '',
},
], [t, defaultType]);
return (
<FormModal
visible={visible}
title={t('account.addModalTitle')}
fields={fields}
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
}

View File

@ -1,6 +1,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { useT } from '../i18n';
import { calculateAccountTotalBalance, type AccountNode } from '../domain/accountTree'; import { calculateAccountTotalBalance, type AccountNode } from '../domain/accountTree';
interface AccountTreeProps { interface AccountTreeProps {
@ -11,7 +12,6 @@ interface AccountTreeProps {
/** 账户树展示(主题化,缩进显示层级 + 余额)。 */ /** 账户树展示(主题化,缩进显示层级 + 余额)。 */
export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) { export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
const { theme } = useTheme();
return ( return (
<View style={{ gap: 4 }}> <View style={{ gap: 4 }}>
{nodes.map(node => ( {nodes.map(node => (
@ -23,13 +23,23 @@ export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
const AccountTreeNode = React.memo(function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) { const AccountTreeNode = React.memo(function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT();
const total = useMemo(() => calculateAccountTotalBalance(node), [node]); const total = useMemo(() => calculateAccountTotalBalance(node), [node]);
const isLeaf = node.children.length === 0; const isLeaf = node.children.length === 0;
let displayName = node.name;
if (depth === 0) {
const localized = t(`account.rootTypes.${node.name as 'Assets' | 'Liabilities' | 'Income' | 'Expenses' | 'Equity'}`);
if (localized && !localized.startsWith('account.')) {
displayName = localized;
}
}
return ( return (
<View> <View>
<View style={[styles.row, { paddingLeft: depth * 16 }]}> <View style={[styles.row, { paddingLeft: depth * 16 }]}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: depth === 0 ? '700' : '400' }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: depth === 0 ? '700' : '400' }]}>
{isLeaf ? '• ' : ''}{node.name} {isLeaf ? '• ' : ''}{displayName}
</Text> </Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{total} {total}

View File

@ -64,7 +64,7 @@ export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps)
{state.routes.slice(0, 2).map(renderTab)} {state.routes.slice(0, 2).map(renderTab)}
<View style={styles.plusSlot}> <View style={styles.plusSlot}>
<Pressable <Pressable
onPress={() => { if (numpadGlobalEntry) openNumpad(); else router.navigate('/transaction/new'); }} onPress={() => openNumpad()}
style={[styles.plus, { style={[styles.plus, {
backgroundColor: theme.colors.accent, backgroundColor: theme.colors.accent,
borderColor: theme.colors.bgPrimary, borderColor: theme.colors.bgPrimary,

View File

@ -1,18 +1,25 @@
import React, { useCallback, useRef } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import { Animated, Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native'; 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 { interface CardProps {
title?: string; title?: string;
children: React.ReactNode; children: React.ReactNode;
onPress?: () => void; onPress?: () => void;
onLongPress?: () => void;
style?: ViewStyle; style?: ViewStyle;
/** 是否开启折叠/展开功能。 */
collapsible?: boolean;
/** 默认是否呈收起状态(仅当 collapsible 为 true 时生效)。 */
defaultCollapsed?: boolean;
} }
/** 卡片容器(主题化,并有触控微交互)。 */ /** 卡片容器(主题化,并有触控微交互与可选折叠展开功能)。 */
export function Card({ title, children, onPress, style }: CardProps) { export function Card({ title, children, onPress, onLongPress, style, collapsible, defaultCollapsed }: CardProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const scale = useRef(new Animated.Value(1)).current; const scale = useRef(new Animated.Value(1)).current;
const [isCollapsed, setIsCollapsed] = useState(collapsible ? (defaultCollapsed ?? false) : false);
const handlePressIn = useCallback(() => { const handlePressIn = useCallback(() => {
Animated.spring(scale, { Animated.spring(scale, {
@ -41,10 +48,41 @@ export function Card({ title, children, onPress, style }: CardProps) {
...theme.shadows.sm, ...theme.shadows.sm,
}; };
if (onPress) { const isInteractive = Boolean(onPress || onLongPress);
if (collapsible) {
return (
<View style={[styles.card, cardStyle, style]}>
<Pressable
onPress={() => setIsCollapsed(prev => !prev)}
style={styles.headerRow}
hitSlop={4}
>
<Text
style={[
styles.title,
{ color: theme.colors.fgPrimary, flex: 1, marginBottom: 0 },
theme.typography.h3,
]}
>
{title}
</Text>
<Ionicons
name={isCollapsed ? 'chevron-down' : 'chevron-up'}
size={18}
color={theme.colors.fgSecondary}
/>
</Pressable>
{!isCollapsed && <View style={styles.collapsibleContent}>{children}</View>}
</View>
);
}
if (isInteractive) {
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
onLongPress={onLongPress}
onPressIn={handlePressIn} onPressIn={handlePressIn}
onPressOut={handlePressOut} onPressOut={handlePressOut}
style={style} style={style}
@ -100,4 +138,6 @@ export function Card({ title, children, onPress, style }: CardProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { gap: 8 }, card: { gap: 8 },
title: { marginBottom: 4 }, title: { marginBottom: 4 },
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 2 },
collapsibleContent: { marginTop: 4, gap: 8 },
}); });

View File

@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { getCategoryColor } from '../theme/palette'; import { getCategoryColor } from '../theme/palette';
import type { Category } from '../domain/categories'; import type { Category } from '../domain/categories';
import { CategoryIcon } from './CategoryIcon'; import { CategoryIcon } from './CategoryIcon';
import { Touchable } from './Touchable';
interface CategoryPickerProps { interface CategoryPickerProps {
categories: Category[]; categories: Category[];
@ -22,15 +23,14 @@ export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPic
const color = getCategoryColor(cat.id); const color = getCategoryColor(cat.id);
return ( return (
<Pressable <Touchable
key={cat.id} key={cat.id}
onPress={() => onSelect(cat)} onPress={() => onSelect(cat)}
style={({ pressed }) => [ style={[
styles.item, styles.item,
{ {
backgroundColor: active ? color + '15' : theme.colors.bgTertiary, backgroundColor: active ? color + '15' : theme.colors.bgTertiary,
borderColor: active ? color : theme.colors.border, borderColor: active ? color : theme.colors.border,
opacity: pressed ? 0.75 : 1,
} }
]} ]}
> >
@ -47,7 +47,7 @@ export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPic
> >
{cat.name} {cat.name}
</Text> </Text>
</Pressable> </Touchable>
); );
})} })}
</View> </View>

View File

@ -7,12 +7,13 @@ import type { DedupResult } from '../domain/dedup';
interface DedupBannerProps { interface DedupBannerProps {
result: DedupResult; result: DedupResult;
onAccept?: () => void; // 接受(仍要记账) onAccept?: () => void; // 强行单独导入
onReject?: () => void; // 拒绝(不记) onReject?: () => void; // 忽略跳过
onLink?: () => void; // 关联/合并已有交易
} }
/** 去重提示横幅(主题化)。展示去重原因 + 操作按钮。 */ /** 去重提示横幅(主题化)。展示去重原因 + 操作按钮。 */
export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) { export function DedupBanner({ result, onAccept, onReject, onLink }: DedupBannerProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
if (!result.isDuplicate) return null; if (!result.isDuplicate) return null;
@ -43,6 +44,11 @@ export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) {
<Pressable onPress={onReject} style={[styles.btn, { borderColor: theme.colors.border }]}> <Pressable onPress={onReject} style={[styles.btn, { borderColor: theme.colors.border }]}>
<Text style={{ color: theme.colors.fgSecondary }}>{t('importFlow.skip')}</Text> <Text style={{ color: theme.colors.fgSecondary }}>{t('importFlow.skip')}</Text>
</Pressable> </Pressable>
{onLink && (
<Pressable onPress={onLink} style={[styles.btn, { borderColor: theme.colors.accent, backgroundColor: `${theme.colors.accent}15` }]}>
<Text style={{ color: theme.colors.accent, fontWeight: '700' }}></Text>
</Pressable>
)}
<Pressable onPress={onAccept} style={[styles.btn, { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent }]}> <Pressable onPress={onAccept} style={[styles.btn, { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent }]}>
<Text style={{ color: theme.colors.fgInverse }}>{t('importFlow.forceImport')}</Text> <Text style={{ color: theme.colors.fgInverse }}>{t('importFlow.forceImport')}</Text>
</Pressable> </Pressable>
@ -54,6 +60,6 @@ export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
banner: { borderLeftWidth: 3, borderRadius: 6, padding: 12, gap: 6, marginVertical: 8 }, banner: { borderLeftWidth: 3, borderRadius: 6, padding: 12, gap: 6, marginVertical: 8 },
header: { flexDirection: 'row', alignItems: 'center', gap: 6 }, header: { flexDirection: 'row', alignItems: 'center', gap: 6 },
actions: { flexDirection: 'row', gap: 8, marginTop: 4 }, actions: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 4 },
btn: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 6, borderWidth: 1 }, btn: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 12, borderRadius: 6, borderWidth: 1 },
}); });

View File

@ -1,4 +1,4 @@
/** /**
* plan.md CRUD * plan.md CRUD
* *
* , + / * , + /
@ -24,6 +24,13 @@ import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../theme'; import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { Button } from './Button'; import { Button } from './Button';
import { Touchable } from './Touchable';
export interface FormOption {
label: string; // 中文名称
value: string; // 英文前缀 (如 Assets)
subLabel?: string;
}
export interface FormField { export interface FormField {
/** 字段 key,对应 values 对象的属性名。 */ /** 字段 key,对应 values 对象的属性名。 */
@ -40,6 +47,14 @@ export interface FormField {
multiline?: boolean; multiline?: boolean;
/** 密码输入(遮蔽文本)。 */ /** 密码输入(遮蔽文本)。 */
secureTextEntry?: boolean; secureTextEntry?: boolean;
/** 控件类型:'text' (默认) | 'select' 单选卡片组 | 'dropdown' 下拉选择框 */
type?: 'text' | 'select' | 'dropdown';
/** select / dropdown 类型的可选项。 */
options?: FormOption[];
/** 在同一行内布局时的占比弹性系数。 */
flex?: number;
/** 行号标识,相同 row 的字段组合在同一行中。 */
row?: number;
} }
interface FormModalProps { interface FormModalProps {
@ -50,15 +65,30 @@ interface FormModalProps {
onCancel: () => void; onCancel: () => void;
/** 确认按钮文本(默认「确定」)。 */ /** 确认按钮文本(默认「确定」)。 */
confirmLabel?: string; confirmLabel?: string;
/** 字段值变更时的联动回调,返回新值覆盖。 */
onValuesChange?: (changedKey: string, newValue: string, prevValues: Record<string, string>) => Record<string, string> | void;
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */ /** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
children?: React.ReactNode; children?: React.ReactNode;
} }
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel, children }: FormModalProps) { export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel, onValuesChange, children }: FormModalProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]); const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const [values, setValues] = useState<Record<string, string>>({}); 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 初始化 // 每次 visible 变为 true 时,用 fields 的 defaultValue 初始化
useEffect(() => { useEffect(() => {
@ -68,46 +98,206 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
init[f.key] = f.defaultValue ?? ''; init[f.key] = f.defaultValue ?? '';
} }
setValues(init); setValues(init);
setActiveDropdownKey(null);
setFocusedKey(null);
} }
}, [visible]); // eslint-disable-line react-hooks/exhaustive-deps }, [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 ( return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}> <Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onCancel}> <Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onCancel}>
<Pressable <Pressable
style={[styles.sheet, { style={[
backgroundColor: theme.colors.bgSecondary, styles.sheet,
borderRadius: theme.radii.xl, theme.shadows.lg,
borderWidth: StyleSheet.hairlineWidth, {
borderColor: theme.colors.border, backgroundColor: theme.colors.bgSecondary,
}]} borderColor: theme.colors.border,
},
]}
onPress={(e) => e.stopPropagation()} onPress={(e) => e.stopPropagation()}
> >
<View style={[styles.header, { borderBottomColor: theme.colors.divider }]}> {/* 顶部 Drag Handle */}
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{title}</Text> <View style={[styles.dragHandle, { backgroundColor: theme.colors.divider }]} />
<Pressable onPress={onCancel} hitSlop={8}>
<Ionicons name="close" size={20} color={theme.colors.fgSecondary} /> <View style={styles.header}>
</Pressable> <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> </View>
<ScrollView style={styles.body}> <ScrollView style={styles.body} showsVerticalScrollIndicator={false}>
{fields.map(f => ( {fieldElements}
<View key={f.key} style={styles.field}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4 }]}>
{f.label}
</Text>
<TextInput
value={values[f.key] ?? ''}
onChangeText={(text) => setValues(prev => ({ ...prev, [f.key]: text }))}
placeholder={f.placeholder}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType={f.keyboardType ?? 'default'}
multiline={f.multiline}
secureTextEntry={f.secureTextEntry}
style={commonStyles.input}
/>
</View>
))}
</ScrollView> </ScrollView>
{children} {children}
@ -118,6 +308,64 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
</View> </View>
</Pressable> </Pressable>
</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> </Modal>
); );
} }
@ -130,26 +378,113 @@ const styles = StyleSheet.create({
sheet: { sheet: {
maxHeight: '80%', maxHeight: '80%',
padding: 20, padding: 20,
paddingTop: 12,
paddingBottom: 36, paddingBottom: 36,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
},
dragHandle: {
width: 36,
height: 4,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 16,
opacity: 0.8,
}, },
header: { header: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingBottom: 12, marginBottom: 16,
borderBottomWidth: 1, },
marginBottom: 12, closeCircleBtn: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
}, },
body: { body: {
maxHeight: 400, maxHeight: 400,
}, },
field: { field: {
marginBottom: 12, 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: { actions: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 10,
marginTop: 8, 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,
}, },
}); });

View File

@ -1,11 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View, type ViewStyle } from 'react-native'; import { Alert, ScrollView, StyleSheet, Text, View, type ViewStyle } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { ScreenHeader } from './ScreenHeader'; import { ScreenHeader } from './ScreenHeader';
import { FormModal, type FormField } from './FormModal'; import { FormModal, type FormField } from './FormModal';
import { Touchable } from './Touchable';
/** /**
* P2 + + + + FormModal * P2 + + + + FormModal
@ -27,6 +28,8 @@ interface ManagementScreenProps<T> {
deleteConfirmText: (item: T) => string; deleteConfirmText: (item: T) => string;
/** 删除确认弹窗标题(默认通用「删除」)。 */ /** 删除确认弹窗标题(默认通用「删除」)。 */
deleteConfirmTitle?: string; deleteConfirmTitle?: string;
/** 字段值变更时的联动回调,返回新值覆盖。 */
onValuesChange?: (changedKey: string, newValue: string, prevValues: Record<string, string>) => Record<string, string> | void;
/** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */ /** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */
formExtra?: (editing: T | null) => React.ReactNode; formExtra?: (editing: T | null) => React.ReactNode;
/** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */ /** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */
@ -71,9 +74,9 @@ export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
<ScreenHeader <ScreenHeader
title={props.title} title={props.title}
right={ right={
<Pressable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}> <Touchable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}>
<Ionicons name="add" size={26} color={theme.colors.accent} /> <Ionicons name="add" size={26} color={theme.colors.accent} />
</Pressable> </Touchable>
} }
/> />
<ScrollView contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={styles.content}>
@ -97,6 +100,7 @@ export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
visible={modalOpen} visible={modalOpen}
title={props.formTitle(editing)} title={props.formTitle(editing)}
fields={props.formFields(editing)} fields={props.formFields(editing)}
onValuesChange={props.onValuesChange}
onConfirm={handleSubmit} onConfirm={handleSubmit}
onCancel={closeModal} onCancel={closeModal}
> >

View File

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { Touchable } from './Touchable';
/** 键值:数字/小数点/加减走 numpadExpressiontoday/done 由宿主处理。 */ /** 键值:数字/小数点/加减走 numpadExpressiontoday/done 由宿主处理。 */
export type KeyboardKey = string; export type KeyboardKey = string;
@ -21,49 +22,97 @@ const ROWS: KeyboardKey[][] = [
['.', '0', 'backspace', 'done'], ['.', '0', 'backspace', 'done'],
]; ];
/** 内嵌数字键盘4×4金额优先/ 连续计算,「今天」快速设日期。 */ /** 内嵌数字键盘4×4金额优先/ 连续计算,「今天」快速设日期。带有按压微缩放与变色触控反馈。 */
export function NumpadKeyboard({ onKey, todayLabel, doneLabel, backspaceLabel }: NumpadKeyboardProps) { export function NumpadKeyboard({ onKey, todayLabel, doneLabel, backspaceLabel }: NumpadKeyboardProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const renderKey = (key: KeyboardKey) => { const renderKey = (key: KeyboardKey) => {
if (key === 'done') { if (key === 'done') {
return ( return (
<Pressable <Touchable
key={key} key={key}
onPress={() => onKey(key)} onPress={() => onKey(key)}
style={[styles.key, styles.doneKey, { backgroundColor: theme.colors.accent, borderRadius: theme.radii.lg }]} disableRipple
style={({ pressed }) => [
styles.key,
styles.doneKey,
{
backgroundColor: pressed ? theme.colors.accentDark : theme.colors.accent,
borderRadius: theme.radii.lg,
},
]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={doneLabel} accessibilityLabel={doneLabel}
> >
<Text style={[styles.doneText, { color: theme.colors.fgInverse }]}>{doneLabel}</Text> <Text style={[styles.doneText, { color: theme.colors.fgInverse }]}>{doneLabel}</Text>
</Pressable> </Touchable>
); );
} }
if (key === 'backspace') { if (key === 'backspace') {
return ( return (
<Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={backspaceLabel}> <Touchable
key={key}
onPress={() => onKey(key)}
disableRipple
style={({ pressed }) => [
styles.key,
pressed && { backgroundColor: theme.colors.bgTertiary },
]}
accessibilityRole="button"
accessibilityLabel={backspaceLabel}
>
<Ionicons name="backspace-outline" size={22} color={theme.colors.fgPrimary} /> <Ionicons name="backspace-outline" size={22} color={theme.colors.fgPrimary} />
</Pressable> </Touchable>
); );
} }
if (key === 'today') { if (key === 'today') {
return ( return (
<Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={todayLabel}> <Touchable
key={key}
onPress={() => onKey(key)}
disableRipple
style={({ pressed }) => [
styles.key,
pressed && { backgroundColor: theme.colors.bgTertiary },
]}
accessibilityRole="button"
accessibilityLabel={todayLabel}
>
<Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{todayLabel}</Text> <Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{todayLabel}</Text>
</Pressable> </Touchable>
); );
} }
if (key === '+' || key === '-') { if (key === '+' || key === '-') {
return ( return (
<Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={key}> <Touchable
key={key}
onPress={() => onKey(key)}
disableRipple
style={({ pressed }) => [
styles.key,
pressed && { backgroundColor: theme.colors.bgTertiary },
]}
accessibilityRole="button"
accessibilityLabel={key}
>
<Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{key === '+' ? '' : ''}</Text> <Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{key === '+' ? '' : ''}</Text>
</Pressable> </Touchable>
); );
} }
return ( return (
<Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={key}> <Touchable
key={key}
onPress={() => onKey(key)}
disableRipple
style={({ pressed }) => [
styles.key,
pressed && { backgroundColor: theme.colors.bgTertiary },
]}
accessibilityRole="button"
accessibilityLabel={key}
>
<Text style={[styles.digitText, { color: theme.colors.fgPrimary }]}>{key}</Text> <Text style={[styles.digitText, { color: theme.colors.fgPrimary }]}>{key}</Text>
</Pressable> </Touchable>
); );
}; };
@ -81,7 +130,7 @@ export function NumpadKeyboard({ onKey, todayLabel, doneLabel, backspaceLabel }:
const styles = StyleSheet.create({ const styles = StyleSheet.create({
grid: { gap: 2 }, grid: { gap: 2 },
row: { flexDirection: 'row' }, row: { flexDirection: 'row' },
key: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 14 }, key: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 14, borderRadius: 8 },
digitText: { fontSize: 22, fontWeight: '600', fontVariant: ['tabular-nums'] }, digitText: { fontSize: 22, fontWeight: '600', fontVariant: ['tabular-nums'] },
opText: { fontSize: 16, fontWeight: '600' }, opText: { fontSize: 16, fontWeight: '600' },
doneKey: { margin: 4 }, doneKey: { margin: 4 },

View File

@ -21,11 +21,13 @@ import { useLedgerStore } from '../store/ledgerStore';
import { useMetadataStore } from '../store/metadataStore'; import { useMetadataStore } from '../store/metadataStore';
import { useSettingsStore } from '../store/settingsStore'; import { useSettingsStore } from '../store/settingsStore';
import { TAG_COLORS } from '../theme/palette'; import { TAG_COLORS } from '../theme/palette';
import { AccountCreateModal } from './AccountCreateModal';
import { BottomSheet } from './BottomSheet'; import { BottomSheet } from './BottomSheet';
import { Button } from './Button'; import { Button } from './Button';
import { CategoryIcon } from './CategoryIcon'; import { CategoryIcon } from './CategoryIcon';
import { CategoryPicker } from './CategoryPicker'; import { CategoryPicker } from './CategoryPicker';
import { DatePickerField } from './DatePickerField'; import { DatePickerField } from './DatePickerField';
import { FormModal, type FormField } from './FormModal';
import { NumpadKeyboard } from './NumpadKeyboard'; import { NumpadKeyboard } from './NumpadKeyboard';
import { PostingEditor } from './PostingEditor'; import { PostingEditor } from './PostingEditor';
import { TagPicker } from './TagPicker'; import { TagPicker } from './TagPicker';
@ -39,11 +41,12 @@ import { matchCategory } from '../domain/categories';
import type { Category } from '../domain/categories'; import type { Category } from '../domain/categories';
import type { Tag } from '../domain/tags'; import type { Tag } from '../domain/tags';
import type { Posting } from '../domain/types'; import type { Posting } from '../domain/types';
import { logger } from '../utils/logger';
type Direction = 'expense' | 'income' | 'transfer'; type Direction = 'expense' | 'income' | 'transfer';
interface NumpadSheetProps { interface NumpadSheetProps {
presentation: 'page' | 'modal'; presentation?: 'modal' | 'page';
editId?: string; editId?: string;
draftJson?: string; draftJson?: string;
autoOcr?: boolean; autoOcr?: boolean;
@ -52,7 +55,7 @@ interface NumpadSheetProps {
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
} }
export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose, onDirtyChange }: NumpadSheetProps) { export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr, onClose, onDirtyChange }: NumpadSheetProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]); const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT(); const t = useT();
@ -61,6 +64,7 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const editTransaction = useLedgerStore(s => s.editTransaction); const editTransaction = useLedgerStore(s => s.editTransaction);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
const categories = useMetadataStore(s => s.categories); const categories = useMetadataStore(s => s.categories);
const tags = useMetadataStore(s => s.tags); const tags = useMetadataStore(s => s.tags);
const lastUsedAccount = useSettingsStore(s => s.lastUsedAccount); const lastUsedAccount = useSettingsStore(s => s.lastUsedAccount);
@ -85,6 +89,42 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
const [isSubmitted, setIsSubmitted] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false);
const [accountPicker, setAccountPicker] = useState<'source' | 'target' | null>(null); const [accountPicker, setAccountPicker] = useState<'source' | 'target' | null>(null);
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false); const [categoryPickerOpen, setCategoryPickerOpen] = useState(false);
const [isQuickAddingAccount, setIsQuickAddingAccount] = useState(false);
const handleQuickAddAccount = async (values: Record<string, string>) => {
const typeVal = values.type?.trim();
const nameVal = values.name?.trim();
if (!typeVal || !nameVal) {
Alert.alert(t('account.addFail'), t('account.addFailEmpty'));
return;
}
const rawAccount = `${typeVal}:${nameVal}`;
let sanitized = rawAccount.replace(//g, ':');
sanitized = sanitized
.split(':')
.map((seg, idx) => {
if (idx === 0) return seg;
let cleaned = seg.replace(/[\(\)\[\]]/g, '-');
cleaned = cleaned.replace(/-+/g, '-');
cleaned = cleaned.replace(/^-|-$/g, '');
cleaned = cleaned.replace(/[^\w\u4e00-\u9fa5-]/g, '');
return cleaned;
})
.join(':');
try {
await autoOpenAccounts([sanitized]);
setIsQuickAddingAccount(false);
if (accountPicker === 'source') {
setSourceAccount(sanitized);
} else if (accountPicker === 'target') {
setTargetAccount(sanitized);
}
setAccountPicker(null);
} catch (e) {
Alert.alert(t('account.openFail'), e instanceof Error ? e.message : String(e));
}
};
// 编辑模式保留字段 // 编辑模式保留字段
const [oldRaw, setOldRaw] = useState(''); const [oldRaw, setOldRaw] = useState('');
@ -130,6 +170,8 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
return [...(pinned ? [pinned] : []), ...rest].slice(0, 3); return [...(pinned ? [pinned] : []), ...rest].slice(0, 3);
}, [availableCategories, lastUsedCategoryId]); }, [availableCategories, lastUsedCategoryId]);
const parsedOriginalDraftRef = useRef<{ payee?: string; categoryAccount?: string; sourceAccount?: string; amount?: string; direction?: string } | null>(null);
// ===== 预填draftJson草稿 ===== // ===== 预填draftJson草稿 =====
useEffect(() => { useEffect(() => {
if (!draftJson) return; if (!draftJson) return;
@ -150,6 +192,13 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
if (matched) setSelectedCategory(matched); if (matched) setSelectedCategory(matched);
} }
} }
parsedOriginalDraftRef.current = {
payee: (draft.payee || '').trim(),
categoryAccount: legs?.targetAccount || '',
sourceAccount: legs?.sourceAccount || '',
amount: legs?.amount || '',
direction: legs?.direction || 'expense',
};
if (Array.isArray(draft.postings) && draft.postings.length > 0) { if (Array.isArray(draft.postings) && draft.postings.length > 0) {
setPostings(draft.postings.map((p: any) => ({ setPostings(draft.postings.map((p: any) => ({
account: p.account, amount: p.amount, currency: p.currency, account: p.account, amount: p.amount, currency: p.currency,
@ -324,8 +373,7 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
}; };
const closeSoon = () => { const closeSoon = () => {
if (presentation === 'modal') onClose?.(); onClose?.();
else setTimeout(() => router.back(), 600);
}; };
// modal 工具行关闭按钮:脏数据时先确认(提交成功走 closeSoon 直关isSubmitted 已置位不会误拦) // modal 工具行关闭按钮:脏数据时先确认(提交成功走 closeSoon 直关isSubmitted 已置位不会误拦)
@ -416,6 +464,29 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
setIsSubmitted(true); setIsSubmitted(true);
closeSoon(); closeSoon();
} else { } else {
if (parsedOriginalDraftRef.current) {
const orig = parsedOriginalDraftRef.current;
const userPayee = payee.trim();
const userCategory = categoryAccount;
const userSource = sourceAccount;
const userAmount = amt;
const userDirection = direction;
const isCategoryChanged = userCategory !== orig.categoryAccount;
const isPayeeChanged = userPayee !== orig.payee;
const isAmountChanged = userAmount !== orig.amount;
const isSourceChanged = userSource !== orig.sourceAccount;
const isDirectionChanged = userDirection !== orig.direction;
if (isCategoryChanged || isPayeeChanged || isAmountChanged || isSourceChanged || isDirectionChanged) {
logger.info('userCorrection', `[用户修正草稿记账] 预填推荐 vs 用户手动修正:`, {
original: { payee: orig.payee, category: orig.categoryAccount, sourceAccount: orig.sourceAccount, amount: orig.amount, direction: orig.direction },
corrected: { payee: userPayee, category: userCategory, sourceAccount: userSource, amount: userAmount, direction: userDirection },
changes: { categoryChanged: isCategoryChanged, payeeChanged: isPayeeChanged, amountChanged: isAmountChanged, sourceChanged: isSourceChanged, directionChanged: isDirectionChanged },
});
}
}
await buildAndSaveTransaction({ await buildAndSaveTransaction({
date, date,
amount: amt, amount: amt,
@ -698,6 +769,15 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
{/* 全部账户 BottomSheet */} {/* 全部账户 BottomSheet */}
<BottomSheet visible={accountPicker !== null} onClose={() => setAccountPicker(null)} title={t('numpad.allAccounts')}> <BottomSheet visible={accountPicker !== null} onClose={() => setAccountPicker(null)} title={t('numpad.allAccounts')}>
<ScrollView style={styles.accountList}> <ScrollView style={styles.accountList}>
<Pressable
onPress={() => setIsQuickAddingAccount(true)}
style={[styles.accountListItem, { borderBottomColor: theme.colors.divider, flexDirection: 'row', alignItems: 'center' }]}
>
<Ionicons name="add-circle-outline" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700', marginLeft: 6 }]}>
{t('account.addNew')}
</Text>
</Pressable>
{assetAccounts.map(acct => ( {assetAccounts.map(acct => (
<Pressable <Pressable
key={acct} key={acct}
@ -714,6 +794,14 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
</ScrollView> </ScrollView>
</BottomSheet> </BottomSheet>
{/* 快捷新建账户 AccountCreateModal (全应用 100% 统一复用) */}
<AccountCreateModal
visible={isQuickAddingAccount}
defaultType="Assets"
onConfirm={handleQuickAddAccount}
onCancel={() => setIsQuickAddingAccount(false)}
/>
{/* 全部分类 BottomSheet */} {/* 全部分类 BottomSheet */}
<BottomSheet visible={categoryPickerOpen} onClose={() => setCategoryPickerOpen(false)} title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}> <BottomSheet visible={categoryPickerOpen} onClose={() => setCategoryPickerOpen(false)} title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
<ScrollView style={styles.accountList}> <ScrollView style={styles.accountList}>

View File

@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { Touchable } from './Touchable';
interface ScreenHeaderProps { interface ScreenHeaderProps {
title: string; title: string;
@ -20,14 +21,14 @@ export function ScreenHeader({ title, right, onBack, backLabel = '返回' }: Scr
const router = useRouter(); const router = useRouter();
return ( return (
<View style={styles.row}> <View style={styles.row}>
<Pressable <Touchable
onPress={onBack ?? (() => router.back())} onPress={onBack ?? (() => router.back())}
hitSlop={8} hitSlop={8}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={backLabel} accessibilityLabel={backLabel}
> >
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} /> <Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
</Pressable> </Touchable>
<Text style={[theme.typography.h2, styles.title, { color: theme.colors.fgPrimary }]} numberOfLines={1}> <Text style={[theme.typography.h2, styles.title, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
{title} {title}
</Text> </Text>

View File

@ -0,0 +1,44 @@
import React from 'react';
import { Platform, Pressable, type PressableProps, type StyleProp, type ViewStyle } from 'react-native';
import { useTheme } from '../theme';
export interface TouchableProps extends Omit<PressableProps, 'style'> {
/** 按下时的透明度 (不传则使用主题 theme.opacities.pressed 0.7)。 */
activeOpacity?: number;
/** 是否禁用 Android 水波纹 (默认 false, 自动开启 android_ripple)。 */
disableRipple?: boolean;
/** 支持传统 ViewStyle 数组/对象,或者 Pressable 的动态 style 函数。 */
style?: StyleProp<ViewStyle> | ((state: { pressed: boolean }) => StyleProp<ViewStyle>);
}
/**
* Pressable
* - iOS: 优雅的 opacity ( tokens )
* - Android: 自动配置 android_ripple
*/
export function Touchable({ activeOpacity, disableRipple = false, style, children, android_ripple, ...props }: TouchableProps) {
const { theme } = useTheme();
const defaultOpacity = activeOpacity ?? theme.opacities.pressed;
const defaultRipple = !disableRipple && Platform.OS === 'android' ? {
color: theme.colors.border,
foreground: true,
...android_ripple,
} : android_ripple;
return (
<Pressable
android_ripple={defaultRipple}
{...props}
style={(state) => {
const resolvedStyle = typeof style === 'function' ? style(state) : style;
return [
resolvedStyle,
state.pressed && { opacity: defaultOpacity },
];
}}
>
{children}
</Pressable>
);
}

View File

@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { CategoryIcon } from './CategoryIcon'; import { CategoryIcon } from './CategoryIcon';
import { Touchable } from './Touchable';
import type { Transaction } from '../domain/types'; import type { Transaction } from '../domain/types';
interface TransactionCardProps { interface TransactionCardProps {
@ -36,11 +37,11 @@ export const TransactionCard = React.memo(function TransactionCard({ transaction
].filter(Boolean).join(' · '); ].filter(Boolean).join(' · ');
return ( return (
<Pressable <Touchable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`${transaction.narration || transaction.payee || ''} ${amountText} ${transaction.date.slice(0, 10)}`} accessibilityLabel={`${transaction.narration || transaction.payee || ''} ${amountText} ${transaction.date.slice(0, 10)}`}
onPress={() => onPress?.(transaction)} onPress={() => onPress?.(transaction)}
style={({ pressed }) => [ style={[
styles.card, styles.card,
{ {
backgroundColor: theme.colors.bgSecondary, backgroundColor: theme.colors.bgSecondary,
@ -48,7 +49,6 @@ export const TransactionCard = React.memo(function TransactionCard({ transaction
padding: theme.spacing.md, padding: theme.spacing.md,
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border, borderColor: theme.colors.border,
opacity: pressed ? 0.8 : 1,
}, },
]} ]}
> >
@ -75,7 +75,7 @@ export const TransactionCard = React.memo(function TransactionCard({ transaction
))} ))}
</View> </View>
)} )}
</Pressable> </Touchable>
); );
}); });

View File

@ -10,12 +10,16 @@ import { detectDirection } from './constants';
import { negateDecimal } from './decimal'; import { negateDecimal } from './decimal';
import type { ImportedEvent } from './types'; import type { ImportedEvent } from './types';
import { splitCsvLine, pickFirst } from './csvUtils'; import { splitCsvLine, pickFirst } from './csvUtils';
import { logger } from '../utils/logger';
/** 通用 CSV 行解析(引号感知,支持含逗号字段)。 */ /** 通用 CSV 行解析(引号感知,支持含逗号字段)。 */
function parseCsvRows(content: string): Record<string, string>[] { function parseCsvRows(content: string): Record<string, string>[] {
const lines = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n').filter(l => l.trim()); const lines = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n').filter(l => l.trim());
const headerLine = lines.find(l => /交易|金额|时间|日期|余额|商户|对方/i.test(l)); const headerLine = lines.find(l => /交易|金额|时间|日期|余额|商户|对方/i.test(l));
if (!headerLine) throw new Error('未找到账单表头'); if (!headerLine) {
logger.error('import', 'CSV 文件解析失败:未找到包含交易/金额/日期的表头行');
throw new Error('未找到账单表头');
}
const headers = splitCsvLine(headerLine).map(h => h.trim().replace(/^"|"$/g, '')); const headers = splitCsvLine(headerLine).map(h => h.trim().replace(/^"|"$/g, ''));
return lines.slice(lines.indexOf(headerLine) + 1).map(line => { return lines.slice(lines.indexOf(headerLine) + 1).map(line => {
const values = splitCsvLine(line).map(v => v.trim().replace(/^"|"$/g, '')); const values = splitCsvLine(line).map(v => v.trim().replace(/^"|"$/g, ''));

View File

@ -1,9 +1,10 @@
import { classifyWithCategories, inferPostingAmounts } from './rules'; import { classifyWithCategories, inferPostingAmounts } from './rules';
import { batchDedup, dedupAgainstHistory, type DedupConfig } from './dedup'; import { batchDedup, dedupAgainstHistory, type DedupConfig, type DuplicateDetail } from './dedup';
import { recognizeTransfers, type TransferConfig, type TransferPair } from './transferRecognizer'; import { recognizeTransfers, type TransferConfig, type TransferPair } from './transferRecognizer';
import { compareDecimals } from './decimal'; import { compareDecimals } from './decimal';
import type { Category } from './categories'; import type { Category } from './categories';
import type { ImportedEvent, LedgerIndex, Rule, Transaction, TransactionDraft } from './types'; import type { ImportedEvent, LedgerIndex, Rule, Transaction, TransactionDraft } from './types';
import { logger } from '../utils/logger';
/** /**
* AutoAccounting BillService.analyze + deduplicationMutex * AutoAccounting BillService.analyze + deduplicationMutex
@ -44,6 +45,8 @@ export interface PipelineResult {
drafts: ProcessedDraft[]; drafts: ProcessedDraft[];
/** 被去重丢弃的事件。 */ /** 被去重丢弃的事件。 */
duplicates: ImportedEvent[]; duplicates: ImportedEvent[];
/** 详细的去重匹配信息。 */
duplicateDetails?: DuplicateDetail[];
/** 转账识别统计。 */ /** 转账识别统计。 */
transferCount: number; transferCount: number;
} }
@ -98,25 +101,45 @@ export class BillPipeline {
return { drafts: [], duplicates: [], transferCount: 0 }; return { drafts: [], duplicates: [], transferCount: 0 };
} }
logger.info('billPipeline', `开始批次处理账单事件 (事件总数: ${events.length})`);
// 1. 转账识别(先于去重) // 1. 转账识别(先于去重)
const { transfers, remaining: afterTransfer } = recognizeTransfers( const { transfers, remaining: afterTransfer } = recognizeTransfers(
events, events,
ctx.transferConfig, ctx.transferConfig,
); );
for (const t of transfers) {
logger.info('billPipeline', `[转账识别] 命中转账对 (置信度: ${t.confidence}): "${t.draft.narration}", 金额: ${t.draft.postings[1]?.amount}, 原因: ${t.reason}`);
}
// 2. 多通道去重(剩余事件之间) // 2. 多通道去重(剩余事件之间)
const { accepted: afterBatchDedup, duplicates: batchDups } = batchDedup( const { accepted: afterBatchDedup, duplicates: batchDups, duplicateDetails: batchDetails } = batchDedup(
afterTransfer, afterTransfer,
ctx.dedupConfig, ctx.dedupConfig,
); );
for (const detail of batchDetails) {
const d = detail.event;
const m = detail.matchedWith;
const matchText = m ? ` -> 匹配到已有事件 (时间: ${m.time}, 金额: ${m.amount}, 对方: "${m.counterparty || '未知'}")` : '';
logger.info('billPipeline', `[同批次去重] 过滤重复事件 [ID: ${d.id}, 时间: ${d.occurredAt}, 金额: ${d.amount} ${d.currency}, 对方: "${d.counterparty || '未知'}"]${matchText} | 原因: ${detail.reason}`);
}
// 3. 与历史交易去重(避免重复入账) // 3. 与历史交易去重(避免重复入账)
const { accepted: afterHistoryDedup, duplicates: historyDups } = dedupAgainstHistory( const { accepted: afterHistoryDedup, duplicates: historyDups, duplicateDetails: historyDetails } = dedupAgainstHistory(
afterBatchDedup, afterBatchDedup,
ctx.history, ctx.history,
ctx.dedupConfig, ctx.dedupConfig,
); );
for (const detail of historyDetails) {
const d = detail.event;
const m = detail.matchedWith;
const matchText = m ? ` -> 匹配到历史账本 [时间: ${m.time}, 金额: ${m.amount}, 对方: "${m.counterparty || '未知'}"]` : '';
logger.info('billPipeline', `[历史交易去重] 过滤重复事件 [ID: ${d.id}, 时间: ${d.occurredAt}, 金额: ${d.amount} ${d.currency}, 对方: "${d.counterparty || '未知'}"]${matchText} | 原因: ${detail.reason}`);
}
// 4. 分类(转账已是完整草稿;非转账事件走 classifyWithCategories // 4. 分类(转账已是完整草稿;非转账事件走 classifyWithCategories
const drafts: ProcessedDraft[] = []; const drafts: ProcessedDraft[] = [];
const transferDups: ImportedEvent[] = []; const transferDups: ImportedEvent[] = [];
@ -131,6 +154,7 @@ export class BillPipeline {
}); });
} else { } else {
// 转账已在历史中,将两个源事件标记为重复 // 转账已在历史中,将两个源事件标记为重复
logger.info('billPipeline', `[转账历史去重] 转账草稿 "${transfer.draft.narration}" 已存在于历史账本中,标记丢弃`);
const leftEvt = events.find(e => e.id === transfer.leftEventId); const leftEvt = events.find(e => e.id === transfer.leftEventId);
const rightEvt = events.find(e => e.id === transfer.rightEventId); const rightEvt = events.find(e => e.id === transfer.rightEventId);
if (leftEvt) transferDups.push(leftEvt); if (leftEvt) transferDups.push(leftEvt);
@ -171,9 +195,13 @@ export class BillPipeline {
}); });
} }
const totalDups = batchDups.length + historyDups.length + transferDups.length;
logger.info('billPipeline', `批次处理完成: 生成 ${drafts.length} 笔草稿 (转账: ${transfers.length}), 过滤 ${totalDups} 笔重复事件`);
return { return {
drafts, drafts,
duplicates: [...batchDups, ...historyDups, ...transferDups], duplicates: [...batchDups, ...historyDups, ...transferDups],
duplicateDetails: [...batchDetails, ...historyDetails],
transferCount: transfers.length, transferCount: transfers.length,
}; };
} }

View File

@ -34,6 +34,8 @@ export interface DedupResult {
reason?: string; reason?: string;
/** 重复的来源事件/交易 id。 */ /** 重复的来源事件/交易 id。 */
duplicateOf?: string; duplicateOf?: string;
/** 匹配到的对比项信息。 */
matchedItem?: DedupComparable;
} }
/** 去重比较所需的归一化形状(从 ImportedEvent 或 Transaction 映射)。 */ /** 去重比较所需的归一化形状(从 ImportedEvent 或 Transaction 映射)。 */
@ -41,6 +43,8 @@ export interface DedupComparable {
time: string; time: string;
amount: string; amount: string;
counterparty?: string; counterparty?: string;
transactionId?: string;
rawTransaction?: Transaction;
} }
/** 把 ImportedEvent 归一化为去重比较形状。 */ /** 把 ImportedEvent 归一化为去重比较形状。 */
@ -103,24 +107,24 @@ export function checkDuplicate(
if (!hasTimeEvent || !hasTimeEx) { if (!hasTimeEvent || !hasTimeEx) {
// 至少一方是日期粒度(例如历史账本数据) // 至少一方是日期粒度(例如历史账本数据)
if (isSameDay && compareDecimals(eventAmountAbs, exAmountAbs) === 0) { if (isSameDay && compareDecimals(eventAmountAbs, exAmountAbs) === 0) {
const sameCounterparty = event.counterparty && exCounterparty && const normEv = (event.counterparty || '').trim();
event.counterparty === exCounterparty; const normEx = (exCounterparty || '').trim();
const sameCounterparty = (normEv && normEx && (normEv === normEx || normEv.includes(normEx) || normEx.includes(normEv))) || (!normEv && !normEx);
if (sameCounterparty) { if (sameCounterparty) {
return { return {
isDuplicate: true, isDuplicate: true,
confidence: 'medium', confidence: 'medium',
reason: `同天发生、金额相同、对手方一致(${exTimeStr}`, reason: `同天发生、金额相同、对手方匹配(${exTimeStr}`,
}; matchedItem: normalize(item),
}
// 双方都无对手方信息 → 低置信度重复
// 仅一方有对手方 → 不判定重复(保留条,避免误杀)
if (!event.counterparty && !exCounterparty) {
return {
isDuplicate: true,
confidence: 'low',
reason: `同天发生、金额相同(${exTimeStr}`,
}; };
} }
// 同天 + 同金额 → 判定为低置信度重复,避免二次导入相同账单
return {
isDuplicate: true,
confidence: 'low',
reason: `同天发生、金额相同(${exTimeStr}`,
matchedItem: normalize(item),
};
} }
continue; continue;
} }
@ -139,6 +143,7 @@ export function checkDuplicate(
isDuplicate: true, isDuplicate: true,
confidence: 'high', confidence: 'high',
reason: `时间相同、金额一致(${exTimeStr}`, reason: `时间相同、金额一致(${exTimeStr}`,
matchedItem: normalize(item),
}; };
} }
@ -152,6 +157,7 @@ export function checkDuplicate(
isDuplicate: true, isDuplicate: true,
confidence: 'medium', confidence: 'medium',
reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同、对手方一致`, reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同、对手方一致`,
matchedItem: normalize(item),
}; };
} }
// 无对手方信息或不一致 → 低置信度重复 // 无对手方信息或不一致 → 低置信度重复
@ -159,6 +165,7 @@ export function checkDuplicate(
isDuplicate: true, isDuplicate: true,
confidence: 'low', confidence: 'low',
reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同`, reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同`,
matchedItem: normalize(item),
}; };
} }
} }
@ -182,16 +189,24 @@ function dateWindowDays(timeWindowMinutes: number): number {
return Math.ceil(timeWindowMinutes / (24 * 60)); return Math.ceil(timeWindowMinutes / (24 * 60));
} }
export interface DuplicateDetail {
event: ImportedEvent;
matchedWith?: DedupComparable;
reason?: string;
confidence?: DedupConfidence;
}
/** /**
* *
* *
*/ */
export function batchDedup( export function batchDedup(
events: ImportedEvent[], events: ImportedEvent[],
config: DedupConfig = DEFAULT_DEDUP_CONFIG, config: DedupConfig = DEFAULT_DEDUP_CONFIG,
): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } { ): { accepted: ImportedEvent[]; duplicates: ImportedEvent[]; duplicateDetails: DuplicateDetail[] } {
const accepted: ImportedEvent[] = []; const accepted: ImportedEvent[] = [];
const duplicates: ImportedEvent[] = []; const duplicates: ImportedEvent[] = [];
const duplicateDetails: DuplicateDetail[] = [];
const acceptedByDate = new Map<string, ImportedEvent[]>(); const acceptedByDate = new Map<string, ImportedEvent[]>();
const windowDays = dateWindowDays(config.timeWindowMinutes); const windowDays = dateWindowDays(config.timeWindowMinutes);
@ -210,6 +225,12 @@ export function batchDedup(
const result = checkDuplicate(event, candidates, config); const result = checkDuplicate(event, candidates, config);
if (result.isDuplicate) { if (result.isDuplicate) {
duplicates.push(event); duplicates.push(event);
duplicateDetails.push({
event,
matchedWith: result.matchedItem,
reason: result.reason,
confidence: result.confidence,
});
} else { } else {
accepted.push(event); accepted.push(event);
const list = acceptedByDate.get(eventDate) ?? []; const list = acceptedByDate.get(eventDate) ?? [];
@ -218,7 +239,7 @@ export function batchDedup(
} }
} }
return { accepted, duplicates }; return { accepted, duplicates, duplicateDetails };
} }
/** /**
@ -229,12 +250,23 @@ export function dedupAgainstHistory(
events: ImportedEvent[], events: ImportedEvent[],
history: Transaction[], history: Transaction[],
config: DedupConfig = DEFAULT_DEDUP_CONFIG, config: DedupConfig = DEFAULT_DEDUP_CONFIG,
): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } { ): { accepted: ImportedEvent[]; duplicates: ImportedEvent[]; duplicateDetails: DuplicateDetail[] } {
const accepted: ImportedEvent[] = []; const accepted: ImportedEvent[] = [];
const duplicates: ImportedEvent[] = []; const duplicates: ImportedEvent[] = [];
const duplicateDetails: DuplicateDetail[] = [];
const windowDays = dateWindowDays(config.timeWindowMinutes); const windowDays = dateWindowDays(config.timeWindowMinutes);
// Group history by date YYYY-MM-DD // 1. 提取历史已保存交易中的 import_id精确判重集合
const historyImportIds = new Set<string>();
for (const t of history) {
if (t.metadata?.import_id) {
for (const id of t.metadata.import_id.split(',')) {
historyImportIds.add(id.trim());
}
}
}
// 2. Group history by date YYYY-MM-DD
const historyByDate = new Map<string, DedupComparable[]>(); const historyByDate = new Map<string, DedupComparable[]>();
for (const t of history) { for (const t of history) {
const dateStr = t.date.slice(0, 10); const dateStr = t.date.slice(0, 10);
@ -244,7 +276,9 @@ export function dedupAgainstHistory(
const comparable: DedupComparable = { const comparable: DedupComparable = {
time: dateStr, time: dateStr,
amount: (sourcePosting?.amount ?? '0').replace(/^-/, ''), amount: (sourcePosting?.amount ?? '0').replace(/^-/, ''),
counterparty: t.payee ?? '', counterparty: (t.payee || t.narration || '').trim(),
transactionId: t.id,
rawTransaction: t,
}; };
const list = historyByDate.get(dateStr) ?? []; const list = historyByDate.get(dateStr) ?? [];
list.push(comparable); list.push(comparable);
@ -252,6 +286,16 @@ export function dedupAgainstHistory(
} }
for (const event of events) { for (const event of events) {
// 优先O(1) 精确匹配已有的 import_id
if (event.id && historyImportIds.has(event.id)) {
duplicates.push(event);
duplicateDetails.push({
event,
reason: '匹配到历史交易 import_id',
});
continue;
}
const eventDate = event.occurredAt.slice(0, 10); const eventDate = event.occurredAt.slice(0, 10);
const [y, m, d] = eventDate.split('-').map(Number); const [y, m, d] = eventDate.split('-').map(Number);
@ -266,10 +310,16 @@ export function dedupAgainstHistory(
const result = checkDuplicate(event, candidates, config); const result = checkDuplicate(event, candidates, config);
if (result.isDuplicate) { if (result.isDuplicate) {
duplicates.push(event); duplicates.push(event);
duplicateDetails.push({
event,
matchedWith: result.matchedItem,
reason: result.reason,
confidence: result.confidence,
});
} else { } else {
accepted.push(event); accepted.push(event);
} }
} }
return { accepted, duplicates }; return { accepted, duplicates, duplicateDetails };
} }

View File

@ -1,4 +1,4 @@
import { addDecimals } from './decimal'; import { addDecimals } from './decimal';
import type { Account, BalanceAssertion, Diagnostic, LedgerFile, LedgerIndex, Option, Posting, Transaction, TransactionDraft, ValidationResult } from './types'; import type { Account, BalanceAssertion, Diagnostic, LedgerFile, LedgerIndex, Option, Posting, Transaction, TransactionDraft, ValidationResult } from './types';
/** /**
@ -192,7 +192,7 @@ export function parseLedger(files: LedgerFile[], options?: ParseLedgerOptions):
} }
const raw = rawLines.join('\n'); const raw = rawLines.join('\n');
transactions.push({ transactions.push({
id: hash(`${file.path}:${raw}`), id: hash(`${file.path}:${cursor}:${raw}`),
date: start[1], flag: start[2], date: start[1], flag: start[2],
payee: start[4] ? start[3] : undefined, payee: start[4] ? start[3] : undefined,
narration: start[4] ?? start[3] ?? '', narration: start[4] ?? start[3] ?? '',

View File

@ -2,6 +2,7 @@ import { commitMobileTransaction } from './mobile';
import { hash, serializeTransaction } from './ledger'; import { hash, serializeTransaction } from './ledger';
import type { LedgerIndex, TransactionDraft } from './types'; import type { LedgerIndex, TransactionDraft } from './types';
import type { MobileCommit } from './mobile'; import type { MobileCommit } from './mobile';
import { logger } from '../utils/logger';
/** /**
* main.bean * main.bean
@ -88,9 +89,13 @@ export class MobileBeanStore {
if (this._initPromise) return this._initPromise; if (this._initPromise) return this._initPromise;
this._initPromise = (async () => { this._initPromise = (async () => {
this._recovered = await this.backend.recoverIfNeeded(); this._recovered = await this.backend.recoverIfNeeded();
if (this._recovered) {
logger.warn('mobileStore', '启动检查:从写入中崩溃残余 (.tmp) 中恢复并清理了临时文件');
}
this.current = await this.backend.read(); this.current = await this.backend.read();
this.initialized = true; this.initialized = true;
})().catch(err => { })().catch(err => {
logger.error('mobileStore', '初始化存储失败', err);
// 失败后清除 promise允许重试 // 失败后清除 promise允许重试
this._initPromise = null; this._initPromise = null;
throw err; throw err;
@ -126,6 +131,7 @@ export class MobileBeanStore {
await this.backend.writeAtomic(this.current); await this.backend.writeAtomic(this.current);
} catch (e) { } catch (e) {
// 写入失败时回滚内存状态,防止内存与磁盘不一致 // 写入失败时回滚内存状态,防止内存与磁盘不一致
logger.error('mobileStore', `追加交易失败,内存已回滚. 摘要: "${draft.narration}", 金额: ${draft.postings[0]?.amount}`, e);
this.current = prev; this.current = prev;
throw e; throw e;
} }
@ -150,6 +156,7 @@ export class MobileBeanStore {
try { try {
await this.backend.writeAtomic(this.current); await this.backend.writeAtomic(this.current);
} catch (e) { } catch (e) {
logger.error('mobileStore', '替换 main.bean 内容失败,内存已回滚', e);
this.current = prev; this.current = prev;
throw e; throw e;
} }
@ -167,6 +174,7 @@ export class MobileBeanStore {
try { try {
await this.backend.writeAtomic(this.current); await this.backend.writeAtomic(this.current);
} catch (e) { } catch (e) {
logger.error('mobileStore', '原子修改 main.bean 失败,内存已回滚', e);
this.current = prev; this.current = prev;
throw e; throw e;
} }

View File

@ -1,4 +1,4 @@
/** /**
* OCR plan.md3.1 +3.9 JS * OCR plan.md3.1 +3.9 JS
* *
* Layer 1: 规则匹配 * Layer 1: 规则匹配
@ -12,6 +12,7 @@ import { DEDUP_CACHE_MAX, IMAGE_HASH_TRUNCATE_LEN } from './constants';
import { matchOcrRule, parseOcrBill, checkIsDetailPage } from './ocr'; import { matchOcrRule, parseOcrBill, checkIsDetailPage } from './ocr';
import { hash } from './ledger'; import { hash } from './ledger';
import type { ImportedEvent } from './types'; import type { ImportedEvent } from './types';
import { logger } from '../utils/logger';
/** OCR 引擎抽象(生产用原生 PP-OCRv5 Module测试用 mock。 */ /** OCR 引擎抽象(生产用原生 PP-OCRv5 Module测试用 mock。 */
export interface OcrEngine { export interface OcrEngine {
@ -38,8 +39,12 @@ export interface OcrProcessResult {
} }
export interface OcrProcessorConfig { export interface OcrProcessorConfig {
/** 是否启用 Layer 3 AI默认 false需用户启用。 */ /** 是否启用 Layer 1 规则匹配,默认 true。 */
aiVisionEnabled: boolean; layer1Enabled: boolean;
/** 是否启用 Layer 2 OCR 识别,默认 true。 */
layer2Enabled: boolean;
/** 是否启用 Layer 3 AI Vision默认 false。 */
layer3Enabled: boolean;
/** 横屏免打扰模式。 */ /** 横屏免打扰模式。 */
landscapeDnd: boolean; landscapeDnd: boolean;
} }
@ -66,7 +71,7 @@ export class OcrProcessor {
constructor( constructor(
private readonly ocrEngine: OcrEngine, private readonly ocrEngine: OcrEngine,
private readonly aiProvider?: AiVisionProvider, private readonly aiProvider?: AiVisionProvider,
private readonly config: OcrProcessorConfig = { aiVisionEnabled: false, landscapeDnd: false }, private readonly config: OcrProcessorConfig = { layer1Enabled: true, layer2Enabled: true, layer3Enabled: false, landscapeDnd: false },
) {} ) {}
/** /**
@ -92,7 +97,8 @@ export class OcrProcessor {
try { try {
const result = await this.doProcess(request.imageBase64, request.packageName, request.isLandscape); const result = await this.doProcess(request.imageBase64, request.packageName, request.isLandscape);
request.resolve(result); request.resolve(result);
} catch { } catch (e) {
logger.error('ocrProcessor', '处理队列包含未捕获异常', e);
request.resolve({ event: null, layer: 'none', skipped: 'non-bill' }); request.resolve({ event: null, layer: 'none', skipped: 'non-bill' });
} finally { } finally {
this.processing = false; this.processing = false;
@ -105,6 +111,8 @@ export class OcrProcessor {
packageName?: string, packageName?: string,
isLandscape = false, isLandscape = false,
): Promise<OcrProcessResult> { ): Promise<OcrProcessResult> {
const startTime = Date.now();
// 横屏免打扰 // 横屏免打扰
if (this.config.landscapeDnd && isLandscape) { if (this.config.landscapeDnd && isLandscape) {
return { event: null, layer: 'none', skipped: 'landscape-dnd' }; return { event: null, layer: 'none', skipped: 'landscape-dnd' };
@ -119,58 +127,91 @@ export class OcrProcessor {
} }
try { try {
// Layer 2: OCR 识别Layer 1 基于文本,需先 OCR let ocrText = '';
const ocrText = await this.ocrEngine.recognizeText(imageBase64);
// OCR 成功后才标记已处理,避免异常时永久阻断重试 // L1 和 L2 都需要 OCR 文本:如果二者都关了,跳过 OCR 调用
this.processedHashes.set(imageHash, Date.now()); if (this.config.layer1Enabled || this.config.layer2Enabled) {
// LRU 淘汰:超出阈值 20% 时批量清理最旧条目 const ocrStartTime = Date.now();
if (this.processedHashes.size > DEDUP_CACHE_MAX * 1.2) { ocrText = await this.ocrEngine.recognizeText(imageBase64);
const entries = [...this.processedHashes.entries()] const ocrDuration = Date.now() - ocrStartTime;
.sort((a, b) => a[1] - b[1]); logger.debug('ocrProcessor', `Native OCR 引擎识别完成 (耗时: ${ocrDuration}ms, 字符数: ${ocrText.length})`);
const toDelete = entries.slice(0, entries.length - DEDUP_CACHE_MAX);
for (const [key] of toDelete) { // OCR 成功后才标记已处理,避免异常时永久阻断重试
this.processedHashes.delete(key); this.processedHashes.set(imageHash, Date.now());
// LRU 淘汰:超出阈值 20% 时批量清理最旧条目
if (this.processedHashes.size > DEDUP_CACHE_MAX * 1.2) {
const entries = [...this.processedHashes.entries()]
.sort((a, b) => a[1] - b[1]);
const toDelete = entries.slice(0, entries.length - DEDUP_CACHE_MAX);
for (const [key] of toDelete) {
this.processedHashes.delete(key);
}
} }
}
if (!ocrText || !ocrText.trim()) { if (!ocrText || !ocrText.trim()) {
// 无文本,尝试 Layer 3 // 无文本,尝试 Layer 3
return await this.tryLayer3(imageBase64, undefined); if (this.config.layer3Enabled) {
return await this.tryLayer3(imageBase64, undefined);
}
return { event: null, layer: 'none', skipped: 'non-bill' };
}
} else {
// L1 和 L2 都关闭,仍标记 hash 避免重复处理
this.processedHashes.set(imageHash, Date.now());
} }
// 针对账单详情页进行特殊路由:若判断是结构化详情页,直接绕过宽松的 Layer 1 规则匹配, // Layer 1: 规则匹配(仅当启用,且非详情页以防火误提取)
// 以避免"银行卡"等规则在详情页上误提取(例如误将"消费1次"提取为"-1元")。 if (this.config.layer1Enabled && ocrText) {
const isDetailPage = checkIsDetailPage(ocrText); const isDetailPage = checkIsDetailPage(ocrText);
if (!isDetailPage) { if (!isDetailPage) {
// Layer 1: 规则匹配(基于 OCR 文本) const ruleMatch = matchOcrRule(ocrText, packageName);
const ruleMatch = matchOcrRule(ocrText, packageName); if (ruleMatch.matched && ruleMatch.event) {
if (ruleMatch.matched && ruleMatch.event) { const totalDuration = Date.now() - startTime;
return { event: ruleMatch.event, layer: 'layer1-rule', ocrText }; logger.info('ocrProcessor', `[Layer 1] 规则匹配成功 (总耗时: ${totalDuration}ms): 金额 ${ruleMatch.event.amount} ${ruleMatch.event.currency}, 对方: ${ruleMatch.event.counterparty || '未知'}`);
return { event: ruleMatch.event, layer: 'layer1-rule', ocrText };
}
} }
} }
// Layer 2: 退化解析 // Layer 2: OCR 解析(仅当启用)
const fallback = parseOcrBill(ocrText, packageName); if (this.config.layer2Enabled && ocrText) {
if (fallback) { const fallback = parseOcrBill(ocrText, packageName);
return { event: fallback, layer: 'layer2-ocr', ocrText }; if (fallback) {
const totalDuration = Date.now() - startTime;
logger.info('ocrProcessor', `[Layer 2] OCR 正则解析成功 (总耗时: ${totalDuration}ms): 金额 ${fallback.amount} ${fallback.currency}, 对方: ${fallback.counterparty || '未知'}`);
return { event: fallback, layer: 'layer2-ocr', ocrText };
}
} }
// 非账单内容billGuard尝试 Layer 3 // Layer 3: AI Vision仅当启用
return await this.tryLayer3(imageBase64, ocrText); if (this.config.layer3Enabled) {
} catch { return await this.tryLayer3(imageBase64, ocrText || undefined);
}
// 全部未命中或全部关闭
return { event: null, layer: 'none', skipped: 'non-bill' };
} catch (e) {
logger.error('ocrProcessor', 'doProcess 执行异常', e);
return { event: null, layer: 'none', skipped: 'non-bill' }; return { event: null, layer: 'none', skipped: 'non-bill' };
} }
} }
private async tryLayer3(imageBase64: string, ocrText?: string): Promise<OcrProcessResult> { private async tryLayer3(imageBase64: string, ocrText?: string): Promise<OcrProcessResult> {
if (!this.config.aiVisionEnabled || !this.aiProvider) { if (!this.config.layer3Enabled || !this.aiProvider) {
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' }; return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
} }
const startTime = Date.now();
try { try {
const event = await this.aiProvider.recognizeBill(imageBase64); const event = await this.aiProvider.recognizeBill(imageBase64);
if (event) return { event, layer: 'layer3-ai', ocrText }; const duration = Date.now() - startTime;
if (event) {
logger.info('ocrProcessor', `[Layer 3] AI Vision 识别成功 (耗时: ${duration}ms): 金额 ${event.amount} ${event.currency}, 对方: ${event.counterparty || '未知'}`);
return { event, layer: 'layer3-ai', ocrText };
}
logger.info('ocrProcessor', `[Layer 3] AI Vision 未能识别为账单 (耗时: ${duration}ms)`);
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' }; return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
} catch { } catch (e) {
logger.error('ocrProcessor', '[Layer 3] AI Vision 调用异常', e);
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' }; return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
} }
} }

View File

@ -186,6 +186,9 @@ export function classifyWithCategories(
narration, narration,
tags: rule?.tags, tags: rule?.tags,
sourceEventIds: [event.id], sourceEventIds: [event.id],
metadata: {
import_id: event.id,
},
postings: [ postings: [
{ account: sourceAccountRaw, amount: sourceAmount, currency: event.currency }, { account: sourceAccountRaw, amount: sourceAmount, currency: event.currency },
{ account: categoryAccount, amount: categoryAmount, currency: event.currency }, { account: categoryAccount, amount: categoryAmount, currency: event.currency },
@ -216,6 +219,9 @@ export function classifyTransferPair(left: ImportedEvent, right: ImportedEvent,
date: (left.occurredAt < right.occurredAt ? left.occurredAt : right.occurredAt).slice(0, 10), date: (left.occurredAt < right.occurredAt ? left.occurredAt : right.occurredAt).slice(0, 10),
narration: `转账:${from}${to}`, narration: `转账:${from}${to}`,
sourceEventIds: [left.id, right.id], sourceEventIds: [left.id, right.id],
metadata: {
import_id: `${left.id},${right.id}`,
},
postings: [ postings: [
{ account: from, amount: `-${amount}`, currency: left.currency }, { account: from, amount: `-${amount}`, currency: left.currency },
{ account: to, amount, currency: left.currency }, { account: to, amount, currency: left.currency },

View File

@ -1,4 +1,4 @@
import type { ImportedEvent, TransactionDraft } from './types'; import type { ImportedEvent, TransactionDraft } from './types';
import { resolveSourceAccount } from './rules'; import { resolveSourceAccount } from './rules';
import { parseDecimal, formatDecimal } from './decimal'; import { parseDecimal, formatDecimal } from './decimal';
@ -143,8 +143,9 @@ function findPair(
const fromAccount = resolveSourceAccount(expense); const fromAccount = resolveSourceAccount(expense);
const toAccount = resolveSourceAccount(income); const toAccount = resolveSourceAccount(income);
// ★ 核心校验:双方必须都是资产负债表科目 // ★ 核心校验:双方必须都是资产负债表科目,且不能是同一明确账户(避免已知账户自转账)
if (!isBalanceSheetAccount(fromAccount) || !isBalanceSheetAccount(toAccount)) continue; if (!isBalanceSheetAccount(fromAccount) || !isBalanceSheetAccount(toAccount)) continue;
if (fromAccount !== 'Assets:Unknown' && fromAccount === toAccount) continue;
const amount = incomeAbs; const amount = incomeAbs;

View File

@ -84,7 +84,7 @@ export default {
notFoundDesc: 'Transaction with ID %{id} could not be found. It may have been deleted or not yet loaded.', notFoundDesc: 'Transaction with ID %{id} could not be found. It may have been deleted or not yet loaded.',
summary: 'Summary', summary: 'Summary',
noSummary: '(no summary)', noSummary: '(no summary)',
postings: 'Postings', postings: 'Fund Flow',
tags: 'Tags', tags: 'Tags',
links: 'Links', links: 'Links',
metadata: 'Additional Info', metadata: 'Additional Info',
@ -132,7 +132,7 @@ export default {
unlinkFail: 'Unlink failed', unlinkFail: 'Unlink failed',
relatedTransactions: 'Related Transactions', relatedTransactions: 'Related Transactions',
readOnlyLinks: 'Read-only transaction, links cannot be edited.', readOnlyLinks: 'Read-only transaction, links cannot be edited.',
linkMobileTx: 'Link Mobile Transaction', linkMobileTx: 'Select Transaction to Link',
searchTxPlaceholder: 'Search narration or account...', searchTxPlaceholder: 'Search narration or account...',
noLinkableTx: 'No linkable mobile transactions', noLinkableTx: 'No linkable mobile transactions',
edit: 'Edit', edit: 'Edit',
@ -283,11 +283,15 @@ export default {
fieldPaymentDay: 'Payment Day (1-28)', fieldPaymentDay: 'Payment Day (1-28)',
fieldLimit: 'Credit Limit', fieldLimit: 'Credit Limit',
fieldCurrency: 'Currency', fieldCurrency: 'Currency',
fieldLinkedAccount: 'Linked Beancount Account', fieldLinkedAccount: 'Linked Credit Account',
labelBank: 'Bank', labelBank: 'Bank',
labelBillingDay: 'Billing Day', labelBillingDay: 'Billing Day',
labelPaymentDay: 'Payment Day', labelPaymentDay: 'Payment Day',
labelLimit: 'Limit', labelLimit: 'Limit',
billingPeriod: 'Current Billing Period',
dueDate: 'Payment Due Date',
statementAmount: 'Statement Amount',
availableCredit: 'Available Credit',
daySuffix: '', daySuffix: '',
unnamed: 'Untitled Card', unnamed: 'Untitled Card',
empty: 'No credit cards yet. Tap above to add one.', empty: 'No credit cards yet. Tap above to add one.',
@ -376,6 +380,16 @@ export default {
start: 'Get Started', start: 'Get Started',
enableLock: 'Enable App Lock', enableLock: 'Enable App Lock',
lockEnabled: '✓ App Lock Enabled', lockEnabled: '✓ App Lock Enabled',
permAccessibility: 'Accessibility Service',
permNotification: 'Notification Listener',
permSms: 'SMS Permission',
permStorage: 'Photos & Storage',
permOverlay: 'Display Overlay',
permGranted: 'Granted',
permNotGranted: 'Not Granted',
permOpenSettings: 'Open Settings',
permRequest: 'Request',
permAllDone: 'All Permissions Granted',
}, },
diagnostics: { diagnostics: {
title: 'Read-only Compatibility & Diagnostics', title: 'Read-only Compatibility & Diagnostics',
@ -423,7 +437,7 @@ export default {
backupSuccess: 'Backup successful: %{path}', backupSuccess: 'Backup successful: %{path}',
restoreSuccess: 'Restore successful, ledger refreshed', restoreSuccess: 'Restore successful, ledger refreshed',
maintenanceSuccess: 'Database maintenance completed', maintenanceSuccess: 'Database maintenance completed',
algorithmConfig: 'Auto-Classification Rules Settings', algorithmConfig: 'Dedup & Transfer Recognition',
selectLang: 'Select language', selectLang: 'Select language',
preferencesTitle: 'Preferences & Security', preferencesTitle: 'Preferences & Security',
entryTitle: 'Entry', entryTitle: 'Entry',
@ -432,10 +446,11 @@ export default {
recurringTitle: 'Recurring Transactions', recurringTitle: 'Recurring Transactions',
systemFeaturesTitle: 'Features & System', systemFeaturesTitle: 'Features & System',
aiSettingsTitle: 'AI & Smart Entry', aiSettingsTitle: 'AI & Smart Entry',
llmTitle: 'LLM Config',
syncSettingsTitle: 'Cloud Sync & Backup', syncSettingsTitle: 'Cloud Sync & Backup',
aboutTitle: 'About', aboutTitle: 'About',
aboutVersion: 'v0.1.0 (Hermes)', aboutVersion: 'v0.1.0 (Hermes)',
enableAiHint: 'Enable AI bill classification hints', enableAiHint: 'Enable AI Vision',
aiAssistantTitle: 'AI Bill Recognition Assistant', aiAssistantTitle: 'AI Bill Recognition Assistant',
aiProvider: 'AI Provider', aiProvider: 'AI Provider',
aiConfigBtn: 'Configure API connection', aiConfigBtn: 'Configure API connection',
@ -450,9 +465,30 @@ export default {
groupPreferences: 'Preferences', groupPreferences: 'Preferences',
diagnostics: 'Parse diagnostics', diagnostics: 'Parse diagnostics',
syncBackup: 'Sync & backup', syncBackup: 'Sync & backup',
appLogs: 'App Logs',
preferencesEntry: 'Preferences', preferencesEntry: 'Preferences',
import: 'Import bills', import: 'Import bills',
}, },
logs: {
title: 'App Log Center',
export: 'Export Logs',
copy: 'Copy Logs',
clear: 'Clear Logs',
clearConfirmTitle: 'Confirm Clear Logs',
clearConfirmDesc: 'Are you sure you want to clear all memory and device log files? This action cannot be undone.',
clearSuccess: 'Logs cleared successfully',
exportSuccess: 'Logs exported successfully',
copySuccess: 'Logs copied to clipboard',
todayLive: 'Today (Live)',
filterLevelAll: 'All (%{count})',
searchPlaceholder: 'Search tag or message...',
empty: 'No matching log entries found',
totalEntries: '%{count} log entries',
fileSize: '%{size} KB',
expandData: 'Expand extra data',
collapseData: 'Collapse extra data',
logFileDate: 'Log file (%{date})',
},
numpad: { numpad: {
from: 'From', from: 'From',
to: 'To', to: 'To',
@ -481,6 +517,20 @@ export default {
tabLiabilities: 'Liabilities', tabLiabilities: 'Liabilities',
tabExpenses: 'Expense Categories', tabExpenses: 'Expense Categories',
tabIncome: 'Income Categories', tabIncome: 'Income Categories',
rootTypes: {
Assets: 'Savings & Assets',
Liabilities: 'Credit & Debts',
Expenses: 'Expense Categories',
Income: 'Income Categories',
Equity: 'Equity & Opening Balances',
},
rootLabels: {
Assets: 'Assets',
Liabilities: 'Liabilities',
Expenses: 'Expenses',
Income: 'Income',
Equity: 'Equity',
},
addNew: 'New account', addNew: 'New account',
fullName: 'Full path', fullName: 'Full path',
currentBalance: 'Current balance', currentBalance: 'Current balance',
@ -684,6 +734,80 @@ export default {
bridgeUnavailable: 'Accessibility bridge unavailable (rebuild APK required)', bridgeUnavailable: 'Accessibility bridge unavailable (rebuild APK required)',
currentApp: 'Current App', currentApp: 'Current App',
floatingBall: 'Floating Bill Assistant', floatingBall: 'Floating Bill Assistant',
billDetectedTitle: 'New Bill Detected',
billDetectedReject: 'Discard',
billDetectedEdit: 'Edit & Save',
billDetectedConfirm: 'Confirm',
billDetectedHeader: 'Parsed Bill',
billDetectedPrompt: 'Post this transaction?',
billDetectedLineDate: 'Date',
billDetectedLineMerchant: 'Merchant',
billDetectedLineAmount: 'Amount',
billDetectedLineCategory: 'Category',
billDetectedLineAccount: 'Account',
billDetectedLineNarration: 'Narration',
billDetectedNone: 'None',
otherPermissions: 'More Permissions',
notificationTitle: 'Notification Listener',
notificationEnabled: 'Enabled',
notificationDisabled: 'Disabled',
notificationOpenSettings: 'Open Settings',
smsPermissionTitle: 'SMS Permission',
smsPermGranted: 'Granted',
smsPermRequest: 'Request',
storagePermTitle: 'Storage Permission',
storagePermGranted: 'Granted',
storagePermRequest: 'Request',
overlayPermTitle: 'Display Overlay',
overlayPermGranted: 'Granted',
overlayPermNotGranted: 'Not Granted',
overlayPermOpenSettings: 'Open Settings',
autoBookkeeping: 'Auto Bookkeeping',
layer1Rule: 'Rule Matching',
layer1RuleDesc: 'Fast regex-based bill recognition',
layer2Ocr: 'OCR Recognition',
layer2OcrDesc: 'On-device PP-OCRv6 model',
layer3Ai: 'AI Vision',
layer3AiDesc: 'Cloud multimodal LLM',
layer3AiDisabled: 'LLM provider and API key not configured. Go to LLM Config?',
ocrModelTitle: 'OCR Model',
ocrModelNotInstalled: 'Not Installed',
ocrModelInstalled: 'Installed (%{version})',
ocrModelInstall: 'Install Model',
ocrModelInstalling: 'Installing...',
ocrModelCopying: 'Copying model files...',
aiProviderLabel: 'AI Provider',
aiConfigTitle: 'AI Configuration',
ocrModelDownload: 'Download Model',
ocrModelDownloading: 'Downloading... %{progress}%',
ocrModelVerifying: 'Verifying files...',
ocrModelDownloadFail: 'Download failed, check network and retry',
ocrModelRedownload: 'Redownload',
},
floating: {
billTitle: 'Adjust Draft',
dirExpense: 'Expense',
dirIncome: 'Income',
dirTransfer: 'Transfer',
amountLabel: 'Amount',
payeeLabel: 'Payee',
narrationLabel: 'Narration',
narrationHint: 'Enter narration',
categoryExpense: 'Category',
categoryIncome: 'Income Category',
transferTarget: 'To Account',
accountExpense: 'From Account',
accountIncome: 'To Account',
accountTransfer: 'From Account',
openApp: 'Open App',
dismiss: 'Dismiss',
confirm: 'Confirm',
ballOcr: 'Scan Bill',
ballRemember: 'Remember Page',
rememberSuccess: 'Page added to recognition whitelist',
rememberFail: 'Failed to save',
pageRemembered: 'Page signature saved',
pageSignatureExists: 'Page signature already exists',
}, },
lockScreen: { lockScreen: {
locked: 'App Locked', locked: 'App Locked',

View File

@ -86,7 +86,7 @@ export default {
notFoundDesc: '找不到 ID 为 %{id} 的交易。可能已被删除或尚未加载。', notFoundDesc: '找不到 ID 为 %{id} 的交易。可能已被删除或尚未加载。',
summary: '摘要', summary: '摘要',
noSummary: '(无摘要)', noSummary: '(无摘要)',
postings: '分录', postings: '资金流向',
tags: '标签', tags: '标签',
links: '链接', links: '链接',
metadata: '附加信息', metadata: '附加信息',
@ -134,7 +134,7 @@ export default {
unlinkFail: '断开失败', unlinkFail: '断开失败',
relatedTransactions: '已关联的其他交易', relatedTransactions: '已关联的其他交易',
readOnlyLinks: '只读交易,不可编辑关联标签。', readOnlyLinks: '只读交易,不可编辑关联标签。',
linkMobileTx: '关联手机端交易', linkMobileTx: '选择要关联的交易',
searchTxPlaceholder: '搜索账单摘要或账户...', searchTxPlaceholder: '搜索账单摘要或账户...',
noLinkableTx: '没有可供关联的手机端账单', noLinkableTx: '没有可供关联的手机端账单',
edit: '编辑', edit: '编辑',
@ -285,11 +285,15 @@ export default {
fieldPaymentDay: '还款日 (1-28)', fieldPaymentDay: '还款日 (1-28)',
fieldLimit: '额度', fieldLimit: '额度',
fieldCurrency: '币种', fieldCurrency: '币种',
fieldLinkedAccount: '关联 Beancount 账户', fieldLinkedAccount: '关联信用账户',
labelBank: '银行', labelBank: '银行',
labelBillingDay: '账单日', labelBillingDay: '账单日',
labelPaymentDay: '还款日', labelPaymentDay: '还款日',
labelLimit: '额度', labelLimit: '额度',
billingPeriod: '当前账单周期',
dueDate: '到期还款日',
statementAmount: '本期应还',
availableCredit: '剩余可用额度',
daySuffix: '日', daySuffix: '日',
unnamed: '未命名信用卡', unnamed: '未命名信用卡',
empty: '暂无信用卡,点击上方添加', empty: '暂无信用卡,点击上方添加',
@ -378,6 +382,16 @@ export default {
start: '开始使用', start: '开始使用',
enableLock: '启用应用锁', enableLock: '启用应用锁',
lockEnabled: '✓ 启用应用锁', lockEnabled: '✓ 启用应用锁',
permAccessibility: '无障碍服务',
permNotification: '通知监听',
permSms: '短信读取',
permStorage: '相册/存储',
permOverlay: '悬浮窗',
permGranted: '已授权',
permNotGranted: '未授权',
permOpenSettings: '前往设置',
permRequest: '请求权限',
permAllDone: '全部已授权',
}, },
diagnostics: { diagnostics: {
title: '只读兼容与诊断', title: '只读兼容与诊断',
@ -425,7 +439,7 @@ export default {
backupSuccess: '备份成功: %{path}', backupSuccess: '备份成功: %{path}',
restoreSuccess: '数据恢复成功,账本已刷新', restoreSuccess: '数据恢复成功,账本已刷新',
maintenanceSuccess: '数据库优化维护完成', maintenanceSuccess: '数据库优化维护完成',
algorithmConfig: '自动分类匹配配置', algorithmConfig: '去重与转账识别',
selectLang: '选择系统语言', selectLang: '选择系统语言',
preferencesTitle: '偏好与安全设置', preferencesTitle: '偏好与安全设置',
entryTitle: '记账', entryTitle: '记账',
@ -434,10 +448,11 @@ export default {
recurringTitle: '周期性记账管理', recurringTitle: '周期性记账管理',
systemFeaturesTitle: '功能与系统设置', systemFeaturesTitle: '功能与系统设置',
aiSettingsTitle: '智能记账与 AI 设置', aiSettingsTitle: '智能记账与 AI 设置',
llmTitle: 'LLM 配置',
syncSettingsTitle: '云同步与本地备份', syncSettingsTitle: '云同步与本地备份',
aboutTitle: '关于应用', aboutTitle: '关于应用',
aboutVersion: 'v0.1.0 (Hermes)', aboutVersion: 'v0.1.0 (Hermes)',
enableAiHint: '启用 AI 智能账单分类提示', enableAiHint: '启用 AI 视觉识别',
aiAssistantTitle: 'AI 智能账单识别助手', aiAssistantTitle: 'AI 智能账单识别助手',
aiProvider: 'AI 服务商', aiProvider: 'AI 服务商',
aiConfigBtn: '配置 API 连接信息', aiConfigBtn: '配置 API 连接信息',
@ -452,9 +467,30 @@ export default {
groupPreferences: '偏好', groupPreferences: '偏好',
diagnostics: '解析诊断', diagnostics: '解析诊断',
syncBackup: '同步与备份', syncBackup: '同步与备份',
appLogs: '应用日志',
preferencesEntry: '偏好设置', preferencesEntry: '偏好设置',
import: '导入账单', import: '导入账单',
}, },
logs: {
title: '应用日志中心',
export: '导出日志',
copy: '复制日志',
clear: '清空日志',
clearConfirmTitle: '确认清空日志',
clearConfirmDesc: '确定要清空内存与手机存储中的所有日志文件吗?此操作不可撤销。',
clearSuccess: '日志已清空',
exportSuccess: '日志导出成功',
copySuccess: '日志内容已复制到剪贴板',
todayLive: '今天 (实时)',
filterLevelAll: '全部 (%{count})',
searchPlaceholder: '搜索日志 Tag 或内容...',
empty: '暂无符合条件的日志记录',
totalEntries: '共 %{count} 条日志',
fileSize: '%{size} KB',
expandData: '展开附加数据',
collapseData: '收起附加数据',
logFileDate: '日志文件 (%{date})',
},
numpad: { numpad: {
from: '从', from: '从',
to: '到', to: '到',
@ -483,6 +519,20 @@ export default {
tabLiabilities: '信用与负债', tabLiabilities: '信用与负债',
tabExpenses: '支出分类', tabExpenses: '支出分类',
tabIncome: '收入分类', tabIncome: '收入分类',
rootTypes: {
Assets: '储蓄与资产',
Liabilities: '信用与负债',
Expenses: '支出分类',
Income: '收入分类',
Equity: '权益与平账',
},
rootLabels: {
Assets: '储蓄与资产',
Liabilities: '信用与负债',
Expenses: '支出分类',
Income: '收入分类',
Equity: '权益与平账',
},
addNew: '新建账户', addNew: '新建账户',
fullName: '完整路径', fullName: '完整路径',
currentBalance: '当前余额', currentBalance: '当前余额',
@ -686,6 +736,80 @@ export default {
bridgeUnavailable: '无障碍桥接不可用(需重新编译 APK', bridgeUnavailable: '无障碍桥接不可用(需重新编译 APK',
currentApp: '当前 App', currentApp: '当前 App',
floatingBall: '悬浮记账助手', floatingBall: '悬浮记账助手',
billDetectedTitle: '识别到新账单',
billDetectedReject: '拒绝/丢弃',
billDetectedEdit: '修改并入账',
billDetectedConfirm: '确认入账',
billDetectedHeader: '账单解析',
billDetectedPrompt: '是否确认入账?',
billDetectedLineDate: '日期',
billDetectedLineMerchant: '商户',
billDetectedLineAmount: '金额',
billDetectedLineCategory: '分类',
billDetectedLineAccount: '账户',
billDetectedLineNarration: '叙述',
billDetectedNone: '无',
otherPermissions: '更多权限',
notificationTitle: '通知监听服务',
notificationEnabled: '已启用',
notificationDisabled: '未启用',
notificationOpenSettings: '前往系统设置',
smsPermissionTitle: '短信读取权限',
smsPermGranted: '已授权',
smsPermRequest: '请求权限',
storagePermTitle: '存储权限',
storagePermGranted: '已授权',
storagePermRequest: '请求权限',
overlayPermTitle: '悬浮窗权限',
overlayPermGranted: '已授权',
overlayPermNotGranted: '未授权',
overlayPermOpenSettings: '前往设置',
autoBookkeeping: '自动记账层级',
layer1Rule: '规则匹配',
layer1RuleDesc: '基于正则规则的快速账单识别',
layer2Ocr: 'OCR 识别',
layer2OcrDesc: '本地 PP-OCRv6 模型识别',
layer3Ai: 'AI 视觉识别',
layer3AiDesc: '云端多模态大模型识别',
layer3AiDisabled: 'AI 尚未配置 LLM 服务商与 Key是否前往 LLM 配置页?',
ocrModelTitle: 'OCR 模型',
ocrModelNotInstalled: '未安装',
ocrModelInstalled: '已安装 (%{version})',
ocrModelInstall: '安装模型',
ocrModelInstalling: '安装中...',
ocrModelCopying: '正在复制模型文件...',
aiProviderLabel: 'AI 服务商',
aiConfigTitle: 'AI 配置',
ocrModelDownload: '下载模型',
ocrModelDownloading: '正在下载... %{progress}%',
ocrModelVerifying: '正在校验文件...',
ocrModelDownloadFail: '下载失败,请检查网络后重试',
ocrModelRedownload: '重新下载',
},
floating: {
billTitle: '调整交易草稿',
dirExpense: '支出',
dirIncome: '收入',
dirTransfer: '转账',
amountLabel: '金额',
payeeLabel: '交易对手',
narrationLabel: '描述/备注',
narrationHint: '输入交易叙述',
categoryExpense: '交易分类',
categoryIncome: '收入分类',
transferTarget: '转入账户',
accountExpense: '资金来源',
accountIncome: '存入账户',
accountTransfer: '转出账户',
openApp: '打开应用',
dismiss: '忽略',
confirm: '确认入账',
ballOcr: '识别账单',
ballRemember: '记住此页',
rememberSuccess: '已将当前页面加入识别白名单',
rememberFail: '记录失败',
pageRemembered: '已记住页面签名',
pageSignatureExists: '该页面签名已存在',
}, },
lockScreen: { lockScreen: {
locked: '应用已锁定', locked: '应用已锁定',

View File

@ -11,6 +11,7 @@
import type { AiVisionProvider } from '../domain/ocrProcessor'; import type { AiVisionProvider } from '../domain/ocrProcessor';
import type { ImportedEvent } from '../domain/types'; import type { ImportedEvent } from '../domain/types';
import { parseAiJson, sanitizeForAi } from '../domain/ai'; import { parseAiJson, sanitizeForAi } from '../domain/ai';
import { logger } from '../utils/logger';
export interface AiVisionConfig { export interface AiVisionConfig {
enabled: boolean; enabled: boolean;
@ -54,6 +55,7 @@ export class AiVisionProcessor implements AiVisionProvider {
} }
JSON {} JSON`; JSON {} JSON`;
const startTime = Date.now();
try { try {
const response = await this.fetchImpl(`${this.config.apiUrl}/chat/completions`, { const response = await this.fetchImpl(`${this.config.apiUrl}/chat/completions`, {
method: 'POST', method: 'POST',
@ -74,7 +76,13 @@ export class AiVisionProcessor implements AiVisionProvider {
}), }),
}); });
if (!response.ok) return null; const duration = Date.now() - startTime;
if (!response.ok) {
const errorText = await response.text().catch(() => '无法读取 Error Body');
logger.error('AiVision', `AI Vision API 请求失败 [HTTP ${response.status}] (耗时: ${duration}ms, 模型: ${this.config.model}): ${errorText}`);
return null;
}
const data = await response.json() as { choices: { message: { content: string } }[] }; const data = await response.json() as { choices: { message: { content: string } }[] };
const content = data.choices[0]?.message?.content ?? ''; const content = data.choices[0]?.message?.content ?? '';
const parsed = parseAiJson<{ const parsed = parseAiJson<{
@ -86,9 +94,13 @@ export class AiVisionProcessor implements AiVisionProvider {
time: string; time: string;
}>(content); }>(content);
if (!parsed || !parsed.amount) return null; if (!parsed || !parsed.amount) {
logger.warn('AiVision', `AI Vision 响应解析为空或缺少金额 (耗时: ${duration}ms, 模型: ${this.config.model})`);
return null;
}
const amount = parseFloat(parsed.amount); const amount = parseFloat(parsed.amount);
logger.info('AiVision', `AI Vision 识别成功 (耗时: ${duration}ms, 模型: ${this.config.model}): 金额 ${amount} ${parsed.currency || 'CNY'}, 对方: ${parsed.counterparty || '未知'}`);
return { return {
id: `ai-vision:${Date.now()}:${amount}`, id: `ai-vision:${Date.now()}:${amount}`,
occurredAt: parsed.time || new Date().toISOString().slice(0, 16), occurredAt: parsed.time || new Date().toISOString().slice(0, 16),
@ -99,7 +111,8 @@ export class AiVisionProcessor implements AiVisionProvider {
memo: 'AI Vision 识别', memo: 'AI Vision 识别',
raw: { source: 'ai-vision', model: this.config.model }, raw: { source: 'ai-vision', model: this.config.model },
}; };
} catch { } catch (e) {
logger.error('AiVision', `AI Vision 异常 (模型: ${this.config.model})`, e);
return null; return null;
} }
} }

View File

@ -10,10 +10,18 @@
* - removePageSignature(sig) Promise<boolean> * - removePageSignature(sig) Promise<boolean>
* - getPaymentPackages() Promise<string[]> * - getPaymentPackages() Promise<string[]>
* - getTopApp() Promise<{ package, activity }> * - getTopApp() Promise<{ package, activity }>
* - showFloatingBill(amount, merchant, time, packageName, categories, accounts, direction, currency, draftId) Promise<boolean>
* - setFloatingBallEnabled(enabled) Promise<boolean>
* - setFloatingUiConfig(config) Promise<boolean> P6 UI floatingUiConfig.ts
* - isNotificationListenerEnabled() Promise<boolean> P7
* - canDrawOverlays() Promise<boolean> P7b
* - copyOcrModelsFromAssets() Promise<string> P8 APK assets OCR filesDir
* *
* Android / /Web null * Android / /Web null
*/ */
import type { FloatingUiConfig } from './floatingUiConfig';
/** 已记住的页面签名。 */ /** 已记住的页面签名。 */
export interface PageSignature { export interface PageSignature {
/** 完整签名pkg|activity。 */ /** 完整签名pkg|activity。 */
@ -56,9 +64,14 @@ export interface NativeAccessibilityBridge {
categories: Array<{ id: string; name: string; account: string; type?: string }>, categories: Array<{ id: string; name: string; account: string; type?: string }>,
accounts: string[], accounts: string[],
direction: string, direction: string,
currency: string,
draftId: string draftId: string
): Promise<boolean>; ): Promise<boolean>;
setFloatingBallEnabled(enabled: boolean): Promise<boolean>; setFloatingBallEnabled(enabled: boolean): Promise<boolean>;
setFloatingUiConfig(config: FloatingUiConfig): Promise<boolean>;
isNotificationListenerEnabled(): Promise<boolean>;
canDrawOverlays(): Promise<boolean>;
copyOcrModelsFromAssets(): Promise<string>;
} }
/** /**

View File

@ -69,7 +69,9 @@ function parseWechatTexts(texts: string[], packageName: string): ImportedEvent |
* "创建时间" "账单详情" "对此订单有疑问" * "创建时间" "账单详情" "对此订单有疑问"
*/ */
function parseAlipayTexts(texts: string[], packageName: string): ImportedEvent | null { function parseAlipayTexts(texts: string[], packageName: string): ImportedEvent | null {
if (!texts.includes('创建时间') && !texts.includes('账单详情') && !texts.includes('对此订单有疑问')) { const isDetailPage = texts.includes('创建时间') || texts.includes('账单详情') || texts.includes('对此订单有疑问');
if (!isDetailPage) {
logger.debug('layout', `[无障碍] 支付宝文本解析跳过:未识别为账单详情页特征`);
return null; return null;
} }
@ -78,22 +80,30 @@ function parseAlipayTexts(texts: string[], packageName: string): ImportedEvent |
let time = ''; let time = '';
let direction: 'expense' | 'income' = 'expense'; let direction: 'expense' | 'income' = 'expense';
let memo = '支付宝账单'; let memo = '支付宝账单';
const amtWithYuanPattern = /^([+-])?(\d+(\.\d+)?)元$/;
for (const text of texts) { for (const text of texts) {
const match = text.match(amtWithYuanPattern); const trimmed = text.trim();
if (match) { // 匹配 "支出12.93元" / "收入50.00元" / "-12.93元" / "12.93元" / "12.93"
amountStr = match[2]; const match = trimmed.match(/^(?:(支出|收入|[+-]))?\s*(\d+(\.\d+)?)元?$/);
if (match[1] === '+') { if (match && match[2]) {
direction = 'income'; const val = match[2];
} else if (texts.some(t => t.includes('收款') || t.includes('收入') || t.includes('收益') || t.includes('收到') || t.includes('红包'))) { // 避免匹配整年或订单纯数字(如 "2026"),必须带小数点或包含支出/收入/元标志
direction = 'income'; if (val.includes('.') || trimmed.endsWith('元') || trimmed.startsWith('支出') || trimmed.startsWith('收入') || trimmed.startsWith('-') || trimmed.startsWith('+')) {
amountStr = val;
const prefix = match[1];
if (prefix === '+' || prefix === '收入') {
direction = 'income';
} else if (prefix === '-' || prefix === '支出') {
direction = 'expense';
} else if (texts.some(t => t.includes('收款') || t.includes('收入') || t.includes('收益') || t.includes('收到') || t.includes('红包'))) {
direction = 'income';
}
break;
} }
break;
} }
} }
const amtIndex = texts.findIndex(t => amtWithYuanPattern.test(t)); const amtIndex = texts.findIndex(t => /^(?:(支出|收入|[+-]))?\s*\d+(\.\d+)?元?$/.test(t.trim()));
if (amtIndex > 0) { if (amtIndex > 0) {
merchant = texts[amtIndex - 1]; merchant = texts[amtIndex - 1];
} else { } else {
@ -132,6 +142,8 @@ function parseAlipayTexts(texts: string[], packageName: string): ImportedEvent |
raw: { packageName }, raw: { packageName },
}; };
} }
logger.info('layout', `[无障碍] 支付宝详情页解析未成功原因: 金额解析无效(amountStr="${amountStr}") 或 商户为空(merchant="${merchant}") | 截获完整文本: ${JSON.stringify(texts)}`);
return null; return null;
} }

View File

@ -16,6 +16,7 @@ import { getNativeOcrBridge } from './ocrBridge';
import { AiVisionProcessor } from '../ocr/AiVisionProcessor'; import { AiVisionProcessor } from '../ocr/AiVisionProcessor';
import type { AiVisionProvider } from '../domain/ocrProcessor'; import type { AiVisionProvider } from '../domain/ocrProcessor';
import { useSettingsStore } from '../store/settingsStore'; import { useSettingsStore } from '../store/settingsStore';
import { useNumpadUiStore } from '../store/numpadUiStore';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { Alert, AppState, DeviceEventEmitter } from 'react-native'; import { Alert, AppState, DeviceEventEmitter } from 'react-native';
import { router } from 'expo-router'; import { router } from 'expo-router';
@ -25,6 +26,7 @@ import { sharedPipeline } from '../domain/pipelineSingleton';
import type { ImportedEvent } from '../domain/types'; import type { ImportedEvent } from '../domain/types';
import type { ProcessedDraft } from '../domain/billPipeline'; import type { ProcessedDraft } from '../domain/billPipeline';
import { getAccessibilityBridge } from './accessibilityBridge'; import { getAccessibilityBridge } from './accessibilityBridge';
import { t } from '../i18n';
// 从拆分模块导入 // 从拆分模块导入
import { import {
@ -50,7 +52,7 @@ export type { PendingDraft } from './floatingBillManager';
const BILLING_LISTENER_KEY = 'billingConfirmed'; const BILLING_LISTENER_KEY = 'billingConfirmed';
let billingListener: { remove(): void } | null = null; let billingListener: { remove(): void } | null = null;
async function handleBillingConfirmed(res: { draftId?: string; confirmed?: boolean; amount?: string; time?: string; direction?: string; merchant?: string; narration?: string; category?: string; account?: string }) { async function handleBillingConfirmed(res: { draftId?: string; confirmed?: boolean; amount?: string; time?: string; direction?: string; merchant?: string; narration?: string; category?: string; account?: string; currency?: string }) {
const { draftId, confirmed } = res; const { draftId, confirmed } = res;
if (!draftId) return; if (!draftId) return;
@ -75,12 +77,39 @@ async function handleBillingConfirmed(res: { draftId?: string; confirmed?: boole
try { try {
const draftDate = pending?.draft?.date || (res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0]); const draftDate = pending?.draft?.date || (res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0]);
const currency = pending?.event?.currency || 'CNY'; const currency = res.currency || pending?.event?.currency || 'CNY';
const direction = (res.direction || pending?.event?.direction || 'expense') as 'expense' | 'income' | 'transfer'; const direction = (res.direction || pending?.event?.direction || 'expense') as 'expense' | 'income' | 'transfer';
const tags = pending?.draft?.tags || undefined; const tags = pending?.draft?.tags || undefined;
const links = pending?.draft?.links || undefined; const links = pending?.draft?.links || undefined;
const metadata = pending?.draft?.metadata || undefined; const metadata = pending?.draft?.metadata || undefined;
const origPayee = pending?.draft?.payee ?? pending?.event?.counterparty ?? '';
const origCategory = pending?.draft?.postings[1]?.account ?? 'Expenses:未分类';
const origSource = pending?.draft?.postings[0]?.account ?? 'Assets:未知';
const origAmount = (pending?.draft?.postings[0]?.amount ?? pending?.event?.amount ?? '0').replace(/^-/, '');
const origDirection = pending?.event?.direction ?? 'expense';
const userPayee = res.merchant || origPayee;
const userCategory = res.category || origCategory;
const userSource = res.account || origSource;
const userAmount = res.amount || origAmount;
const userDirection = direction;
const isCategoryChanged = userCategory !== origCategory;
const isPayeeChanged = userPayee !== origPayee;
const isAmountChanged = userAmount !== origAmount;
const isSourceChanged = userSource !== origSource;
const isDirectionChanged = userDirection !== origDirection;
if (isCategoryChanged || isPayeeChanged || isAmountChanged || isSourceChanged || isDirectionChanged) {
logger.info('userCorrection', `[用户修正识别/分类] 推荐识别 vs 用户修改比对:`, {
source: pending?.event?.raw?.source ?? 'unknown',
original: { payee: origPayee, category: origCategory, sourceAccount: origSource, amount: origAmount, direction: origDirection },
corrected: { payee: userPayee, category: userCategory, sourceAccount: userSource, amount: userAmount, direction: userDirection },
changes: { categoryChanged: isCategoryChanged, payeeChanged: isPayeeChanged, amountChanged: isAmountChanged, sourceChanged: isSourceChanged, directionChanged: isDirectionChanged },
});
}
await buildAndSaveTransaction({ await buildAndSaveTransaction({
date: draftDate, date: draftDate,
amount: res.amount ?? '0', amount: res.amount ?? '0',
@ -94,7 +123,7 @@ async function handleBillingConfirmed(res: { draftId?: string; confirmed?: boole
links, links,
metadata, metadata,
}, useLedgerStore.getState(), false); }, useLedgerStore.getState(), false);
logger.info('automationPipeline', `[浮窗入账] 交易已成功在后台确认入账:${res.amount ?? '0'} CNY`); logger.info('automationPipeline', `[浮窗入账] 交易已成功在后台确认入账:${res.amount ?? '0'} ${currency}`);
} catch (e) { } catch (e) {
logger.error('automationPipeline', '后台浮窗入账失败', e); logger.error('automationPipeline', '后台浮窗入账失败', e);
} }
@ -119,11 +148,11 @@ function getOcrProcessor(): OcrProcessor | null {
lastSettingsRef = settings; lastSettingsRef = settings;
try { try {
const ocrEngine = getNativeOcrBridge(); // P8从 settingsStore 读取 OCR 模型目录,传入原生引擎从 filesystem 加载模型
const ocrEngine = getNativeOcrBridge(settings.ocrModelDir || undefined);
// settings already obtained above for config check // settings already obtained above for config check
let aiProvider: AiVisionProvider | undefined; let aiProvider: AiVisionProvider | undefined;
let aiVisionEnabled = false;
if (settings.aiEnabled && settings.aiApiKey && settings.aiBaseUrl) { if (settings.aiEnabled && settings.aiApiKey && settings.aiBaseUrl) {
aiProvider = new AiVisionProcessor({ aiProvider = new AiVisionProcessor({
enabled: true, enabled: true,
@ -131,11 +160,12 @@ function getOcrProcessor(): OcrProcessor | null {
apiKey: settings.aiApiKey, apiKey: settings.aiApiKey,
model: settings.aiModel || 'glm-4v-flash', model: settings.aiModel || 'glm-4v-flash',
}); });
aiVisionEnabled = true;
} }
ocrProcessor = new OcrProcessor(ocrEngine, aiProvider, { ocrProcessor = new OcrProcessor(ocrEngine, aiProvider, {
aiVisionEnabled, layer1Enabled: settings.layer1RuleEnabled,
layer2Enabled: settings.layer2OcrEnabled,
layer3Enabled: settings.layer3AiEnabled,
landscapeDnd: false, landscapeDnd: false,
}); });
return ocrProcessor; return ocrProcessor;
@ -217,7 +247,7 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
const draft = result.drafts[0]; const draft = result.drafts[0];
if (!draft) { if (!draft) {
logger.info('automationPipeline', `[实时入账] 处理结束,可能与已有交易重复已被去重过滤。`); logger.info('automationPipeline', `[实时入账] 处理结束,事件已被去重过滤 (事件ID: ${event.id}, 时间: ${event.occurredAt}, 金额: ${event.amount} ${event.currency}, 对方: "${event.counterparty || '未知'}")`);
return; return;
} }
@ -267,6 +297,7 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
sortedCategories, sortedCategories,
sortedAccounts, sortedAccounts,
event.direction || 'expense', event.direction || 'expense',
currency,
draftId, draftId,
); );
@ -296,35 +327,34 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
// 前台模式:弹窗 // 前台模式:弹窗
Alert.alert( Alert.alert(
'🌟 识别到新账单', t('automation.billDetectedTitle'),
`【账单解析】\n` + `${t('automation.billDetectedHeader')}\n` +
`日期: ${draft.draft.date}\n` + `${t('automation.billDetectedLineDate')}: ${draft.draft.date}\n` +
`商户: ${draft.draft.payee ?? '无'}\n` + `${t('automation.billDetectedLineMerchant')}: ${draft.draft.payee ?? t('automation.billDetectedNone')}\n` +
`金额: ${amount} ${currency}\n` + `${t('automation.billDetectedLineAmount')}: ${amount} ${currency}\n` +
`分类: ${targetAccount}\n` + `${t('automation.billDetectedLineCategory')}: ${targetAccount}\n` +
`账户: ${sourceAccount}\n` + `${t('automation.billDetectedLineAccount')}: ${sourceAccount}\n` +
`叙述: ${draft.draft.narration || '无'}\n\n` + `${t('automation.billDetectedLineNarration')}: ${draft.draft.narration || t('automation.billDetectedNone')}\n\n` +
`是否确认入账?`, t('automation.billDetectedPrompt'),
[ [
{ {
text: '拒绝/丢弃', text: t('automation.billDetectedReject'),
style: 'cancel', style: 'cancel',
onPress: () => { onPress: () => {
logger.info('automationPipeline', `[实时入账] 用户选择拒绝/丢弃交易`); logger.info('automationPipeline', `[实时入账] 用户选择拒绝/丢弃交易`);
} }
}, },
{ {
text: '修改并入账', text: t('automation.billDetectedEdit'),
onPress: () => { onPress: () => {
logger.info('automationPipeline', `[实时入账] 用户选择修改交易,跳转编辑页`); logger.info('automationPipeline', `[实时入账] 用户选择修改交易,跳转编辑页`);
router.push({ useNumpadUiStore.getState().open({
pathname: '/transaction/new', draftJson: JSON.stringify(draft.draft)
params: { draftJson: JSON.stringify(draft.draft) }
}); });
} }
}, },
{ {
text: '确认入账', text: t('automation.billDetectedConfirm'),
style: 'default', style: 'default',
onPress: async () => { onPress: async () => {
try { try {

View File

@ -8,6 +8,7 @@
import { hash } from '../domain/ledger'; import { hash } from '../domain/ledger';
import type { LedgerFile } from '../domain/types'; import type { LedgerFile } from '../domain/types';
import { logger } from '../utils/logger';
/** 备份内容(序列化为 JSON 后打包)。 */ /** 备份内容(序列化为 JSON 后打包)。 */
export interface BackupBundle { export interface BackupBundle {
@ -38,7 +39,7 @@ export function createBackupBundle(
settings?: Record<string, unknown>, settings?: Record<string, unknown>,
metadata?: Record<string, unknown>, metadata?: Record<string, unknown>,
): BackupBundle { ): BackupBundle {
return { const bundle: BackupBundle = {
version: 2, version: 2,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
files: files.filter(f => f.path !== 'main.bean'), files: files.filter(f => f.path !== 'main.bean'),
@ -46,6 +47,8 @@ export function createBackupBundle(
settings, settings,
metadata, metadata,
}; };
logger.info('backup', `创建本地备份包成功 (文件数: ${bundle.files.length + 1}, 含设置/元数据)`);
return bundle;
} }
/** 序列化为 JSON便于打包/传输)。 */ /** 序列化为 JSON便于打包/传输)。 */
@ -55,14 +58,21 @@ export function serializeBundle(bundle: BackupBundle): string {
/** 反序列化 + 校验(兼容 v1 和 v2以及旧版 mobileBean 字段名)。 */ /** 反序列化 + 校验(兼容 v1 和 v2以及旧版 mobileBean 字段名)。 */
export function deserializeBundle(json: string): BackupBundle { export function deserializeBundle(json: string): BackupBundle {
const data = JSON.parse(json) as Record<string, unknown>; try {
if (data.version !== 1 && data.version !== 2) throw new Error(`不支持的备份版本: ${data.version}`); const data = JSON.parse(json) as Record<string, unknown>;
if (!Array.isArray(data.files)) throw new Error('备份文件格式错误files 非数组'); if (data.version !== 1 && data.version !== 2) throw new Error(`不支持的备份版本: ${data.version}`);
// 兼容旧版 mobileBean 字段名 if (!Array.isArray(data.files)) throw new Error('备份文件格式错误files 非数组');
const mainBean = (typeof data.mainBean === 'string' ? data.mainBean : undefined) // 兼容旧版 mobileBean 字段名
?? (typeof data.mobileBean === 'string' ? data.mobileBean as string : undefined); const mainBean = (typeof data.mainBean === 'string' ? data.mainBean : undefined)
if (typeof mainBean !== 'string') throw new Error('备份文件格式错误mainBean 非字符串'); ?? (typeof data.mobileBean === 'string' ? data.mobileBean as string : undefined);
return { ...data, mainBean } as BackupBundle; if (typeof mainBean !== 'string') throw new Error('备份文件格式错误mainBean 非字符串');
const result = { ...data, mainBean } as BackupBundle;
logger.info('backup', `反序列化备份包成功 (版本: v${result.version}, 创建时间: ${result.createdAt})`);
return result;
} catch (e) {
logger.error('backup', `解析备份包 JSON 失败: ${e instanceof Error ? e.message : String(e)}`);
throw e;
}
} }
/** 计算备份的校验和(用于完整性校验)。 */ /** 计算备份的校验和(用于完整性校验)。 */

View File

@ -0,0 +1,147 @@
/**
* UI docs/ui-redesign-p6-plan.mdFloatingUiConfig
*
* View// RN theme
* JS theme tokens + i18n config
* AccessibilityBridge.setFloatingUiConfig SharedPreferences
* JS
*/
import type { ThemeTokens } from '../theme/tokens';
import { getAccessibilityBridge } from './accessibilityBridge';
/** t 函数签名(与 i18n.t / useT() 返回值兼容)。 */
export type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
/**
* JS UI
* camelCase FloatingUiConfig data class
*/
export interface FloatingUiConfig {
colors: {
/** 按钮/选中态背景(= theme accent。 */
accent: string;
/** accent 上的文字色(= fgInverse。 */
accentFg: string;
/** 卡片底色(= bgSecondary。 */
cardBg: string;
/** 输入框/未选中 chip 底色(= bgTertiary。 */
inputBg: string;
fgPrimary: string;
fgSecondary: string;
border: string;
/** financial.income */
income: string;
/** financial.expense */
expense: string;
/** financial.transfer */
transfer: string;
};
labels: {
/** 浮窗标题。 */
billTitle: string;
dirExpense: string;
dirIncome: string;
dirTransfer: string;
amountLabel: string;
payeeLabel: string;
narrationLabel: string;
narrationHint: string;
/** 「交易分类」 */
categoryExpense: string;
/** 「收入分类」 */
categoryIncome: string;
/** 「转入账户」 */
transferTarget: string;
/** 「资金来源」 */
accountExpense: string;
/** 「存入账户」 */
accountIncome: string;
/** 「转出账户」 */
accountTransfer: string;
openApp: string;
dismiss: string;
confirm: string;
/** 悬浮球「识别账单」 */
ballOcr: string;
/** 「记住此页」 */
ballRemember: string;
/** 记住页面成功提示(不含 emoji。 */
rememberSuccess: string;
/** 失败提示前缀(原生拼接 ': ' + e.message。 */
rememberFail: string;
/** BillingAccessibilityService Toast「已记住页面签名」前缀。 */
pageRemembered: string;
/** 「该页面签名已存在」前缀。 */
pageSignatureExists: string;
};
}
/**
* Color.parseColor hex
* - '#RRGGBB'
* - 'rgba(r,g,b,a)' '#AARRGGBB'alpha = Math.round(a*255) hex
* -
*/
export function colorToHex(color: string): string {
const m = color.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/);
if (!m) return color;
const toHex2 = (n: number) => n.toString(16).toUpperCase().padStart(2, '0');
const alpha = Math.round(parseFloat(m[4]) * 255);
return `#${toHex2(alpha)}${toHex2(Number(m[1]))}${toHex2(Number(m[2]))}${toHex2(Number(m[3]))}`;
}
/** 从 theme tokens + t() 构建完整 config。 */
export function buildFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): FloatingUiConfig {
const c = theme.colors;
return {
colors: {
accent: colorToHex(c.accent),
accentFg: colorToHex(c.fgInverse),
cardBg: colorToHex(c.bgSecondary),
inputBg: colorToHex(c.bgTertiary),
fgPrimary: colorToHex(c.fgPrimary),
fgSecondary: colorToHex(c.fgSecondary),
border: colorToHex(c.border),
income: colorToHex(c.financial.income),
expense: colorToHex(c.financial.expense),
transfer: colorToHex(c.financial.transfer),
},
labels: {
billTitle: t('floating.billTitle'),
dirExpense: t('floating.dirExpense'),
dirIncome: t('floating.dirIncome'),
dirTransfer: t('floating.dirTransfer'),
amountLabel: t('floating.amountLabel'),
payeeLabel: t('floating.payeeLabel'),
narrationLabel: t('floating.narrationLabel'),
narrationHint: t('floating.narrationHint'),
categoryExpense: t('floating.categoryExpense'),
categoryIncome: t('floating.categoryIncome'),
transferTarget: t('floating.transferTarget'),
accountExpense: t('floating.accountExpense'),
accountIncome: t('floating.accountIncome'),
accountTransfer: t('floating.accountTransfer'),
openApp: t('floating.openApp'),
dismiss: t('floating.dismiss'),
confirm: t('floating.confirm'),
ballOcr: t('floating.ballOcr'),
ballRemember: t('floating.ballRemember'),
rememberSuccess: t('floating.rememberSuccess'),
rememberFail: t('floating.rememberFail'),
pageRemembered: t('floating.pageRemembered'),
pageSignatureExists: t('floating.pageSignatureExists'),
},
};
}
/** 构建并推送到原生bridge 不可用或推送异常时静默返回 false不抛出。 */
export async function pushFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): Promise<boolean> {
const bridge = getAccessibilityBridge();
if (!bridge) return false;
try {
return await bridge.setFloatingUiConfig(buildFloatingUiConfig(theme, t));
} catch {
return false;
}
}

113
src/services/logBackend.ts Normal file
View File

@ -0,0 +1,113 @@
/**
* expo-file-system ExpoLogFileBackend
*
* app-YYYY-MM-DD.log documentDirectory/logs/
*/
import * as FileSystem from 'expo-file-system/legacy';
import type { LogFileBackend, LogFileInfo } from '../utils/logger';
const LOG_DIR = `${FileSystem.documentDirectory ?? ''}logs/`;
export class ExpoLogFileBackend implements LogFileBackend {
private dirChecked = false;
private async ensureDir(): Promise<void> {
if (this.dirChecked) return;
try {
const info = await FileSystem.getInfoAsync(LOG_DIR);
if (!info.exists) {
await FileSystem.makeDirectoryAsync(LOG_DIR, { intermediates: true });
}
this.dirChecked = true;
} catch {
// 忽略创建失败,下次重试
}
}
async appendLog(dateStr: string, logLine: string): Promise<void> {
await this.ensureDir();
const filePath = `${LOG_DIR}app-${dateStr}.log`;
try {
const info = await FileSystem.getInfoAsync(filePath);
let existing = '';
if (info.exists) {
existing = await FileSystem.readAsStringAsync(filePath);
}
await FileSystem.writeAsStringAsync(filePath, existing + logLine);
} catch {
// 极罕见写入异常,静默处理
}
}
async readLogFile(dateStr: string): Promise<string> {
await this.ensureDir();
const filePath = `${LOG_DIR}app-${dateStr}.log`;
try {
const info = await FileSystem.getInfoAsync(filePath);
if (!info.exists) return '';
return await FileSystem.readAsStringAsync(filePath);
} catch {
return '';
}
}
async listLogFiles(): Promise<LogFileInfo[]> {
await this.ensureDir();
try {
const files = await FileSystem.readDirectoryAsync(LOG_DIR);
const results: LogFileInfo[] = [];
for (const fileName of files) {
// 匹配 app-YYYY-MM-DD.log
const match = /^app-(\d{4}-\d{2}-\d{2})\.log$/.exec(fileName);
if (!match) continue;
const dateStr = match[1];
const filePath = `${LOG_DIR}${fileName}`;
const info = await FileSystem.getInfoAsync(filePath);
const size = info.exists && 'size' in info ? (info.size ?? 0) : 0;
results.push({
name: fileName,
path: filePath,
size,
date: dateStr,
});
}
// 按日期倒序排列(最新在前)
results.sort((a, b) => b.date.localeCompare(a.date));
return results;
} catch {
return [];
}
}
async deleteLogFile(dateStr: string): Promise<void> {
await this.ensureDir();
const filePath = `${LOG_DIR}app-${dateStr}.log`;
try {
await FileSystem.deleteAsync(filePath, { idempotent: true });
} catch {
// 忽略删除失败
}
}
async clearAllLogFiles(): Promise<void> {
await this.ensureDir();
try {
const files = await FileSystem.readDirectoryAsync(LOG_DIR);
for (const fileName of files) {
if (fileName.startsWith('app-') && fileName.endsWith('.log')) {
await FileSystem.deleteAsync(`${LOG_DIR}${fileName}`, { idempotent: true });
}
}
} catch {
// 忽略清理失败
}
}
}
/** 全局导出的单例日志文件后端。 */
export const expoLogBackend = new ExpoLogFileBackend();

View File

@ -0,0 +1,249 @@
/**
* OCR /P8
*
*
* 1. HuggingFace / CDN
* 2. APK assets
*
* documentDirectory/ocr_models_v6 OcrModule.kt setModelDir
*/
import * as FileSystem from 'expo-file-system/legacy';
import { useSettingsStore } from '../store/settingsStore';
import { getNativeOcrModule } from './ocrBridge';
import { logger } from '../utils/logger';
// ==================== 模型版本 Manifest ====================
/** 单个模型文件的描述。 */
export interface ModelFileEntry {
/** 文件名(如 "ppocrv6_det.onnx" */
name: string;
/** 下载 URL */
url: string;
/** SHA-256 十六进制字符串(空字符串 = 跳过校验) */
sha256: string;
/** 文件大小bytes */
size: number;
}
/** 模型版本 manifest。 */
export interface ModelManifest {
/** 版本字符串,如 "PP-OCRv6" */
version: string;
/** 模型文件列表 */
files: ModelFileEntry[];
}
/**
* PP-OCRv6 HuggingFace
* SHA256 sha256sum
*/
const BUILTIN_MANIFEST: ModelManifest = {
version: 'PP-OCRv6',
files: [
{
name: 'ppocrv6_det.onnx',
url: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_small_det_onnx/resolve/main/inference.onnx',
sha256: '',
size: 9880512,
},
{
name: 'ppocrv6_rec.onnx',
url: 'https://huggingface.co/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/main/inference.onnx',
sha256: '',
size: 21159378,
},
{
name: 'ppocrv6_dict.txt',
url: 'https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv6_dict.txt',
sha256: '',
size: 74947,
},
],
};
/** 远程 manifest JSON URL为空时使用 BUILTIN_MANIFEST。 */
const DEFAULT_MANIFEST_URL = '';
// ==================== 工具函数 ====================
/** 模型目录路径。 */
function getModelDir(): string {
return `${FileSystem.documentDirectory}ocr_models_v6/`;
}
/** 确保目录存在。 */
async function ensureModelDir(dir: string): Promise<void> {
const info = await FileSystem.getInfoAsync(dir);
if (!info.exists) {
await FileSystem.makeDirectoryAsync(dir, { intermediates: true });
}
}
// ==================== 公开 API ====================
/** 首次调用标记。 */
let _ensured = false;
/**
*
*
* @param manifestUrl - manifest JSON URL BUILTIN_MANIFEST
* @param onProgress - 0-100
* @returns
*/
export async function downloadOcrModels(
manifestUrl?: string,
onProgress?: (progress: number) => void,
): Promise<string> {
// 1. 获取 manifest
let manifest: ModelManifest;
const url = manifestUrl || DEFAULT_MANIFEST_URL;
if (url) {
onProgress?.(0);
logger.info('modelDownloader', `正在获取模型清单: ${url}`);
const manifestResult = await FileSystem.downloadAsync(url, `${getModelDir()}manifest.json`);
if (manifestResult?.status !== 200) {
throw new Error(`获取模型清单失败: HTTP ${manifestResult?.status ?? 'unknown'}`);
}
const manifestJson = await FileSystem.readAsStringAsync(manifestResult.uri);
manifest = JSON.parse(manifestJson);
} else {
manifest = BUILTIN_MANIFEST;
}
logger.info('modelDownloader', `模型版本: ${manifest.version}, 共 ${manifest.files.length} 个文件`);
await ensureModelDir(getModelDir());
// 2. 逐文件下载按文件粒度报进度legacy API 的字节级回调不可靠)
const dir = getModelDir();
const total = manifest.files.length;
for (let i = 0; i < manifest.files.length; i++) {
const entry = manifest.files[i];
const destPath = `${dir}${entry.name}`;
// 已存在且大小匹配 → 跳过
const existing = await FileSystem.getInfoAsync(destPath);
if (existing.exists && (existing as any).size === entry.size) {
logger.info('modelDownloader', `${entry.name} 已存在,跳过下载`);
onProgress?.(Math.round(((i + 1) / total) * 90));
continue;
}
try {
// 下载开始文件级进度不做字节级回调——legacy API 的 progress 回调不稳定)
onProgress?.(Math.round((i / total) * 90 + 1));
logger.info('modelDownloader', `下载 ${i + 1}/${total}: ${entry.name} (${(entry.size / 1024 / 1024).toFixed(1)} MB)`);
const downloadResult = await FileSystem.downloadAsync(entry.url, destPath);
if (downloadResult?.status !== 200) {
throw new Error(`${entry.name}: HTTP ${downloadResult?.status ?? 'unknown'}`);
}
// 校验文件已实际写入legacy API 有时返回 200 但文件为空或截断)
const downloaded = await FileSystem.getInfoAsync(destPath);
if (!downloaded.exists || ((downloaded as any).size ?? 0) < 1024) {
try { await FileSystem.deleteAsync(destPath); } catch { /* ignore */ }
throw new Error(`${entry.name}: 下载后文件异常(大小 ${(downloaded as any).size ?? 0} bytes可能网络断开`);
}
// SHA256 校验(仅当提供时才执行)
if (entry.sha256) {
const valid = await verifySha256(destPath, entry.sha256);
if (!valid) {
try { await FileSystem.deleteAsync(destPath); } catch { /* ignore */ }
throw new Error(`${entry.name}: SHA256 校验失败,文件可能损坏`);
}
}
onProgress?.(Math.round(((i + 1) / total) * 90));
} catch (err) {
logger.error('modelDownloader', `${entry.name} 下载失败`, err);
try { await FileSystem.deleteAsync(destPath); } catch { /* ignore */ }
const sizeMB = (entry.size / 1024 / 1024).toFixed(1);
const reason = (err as Error)?.message ? `: ${(err as Error).message}` : '';
throw new Error(`${entry.name} (${sizeMB} MB) 下载失败${reason}`);
}
}
// 3. 保存版本 + 通知原生
onProgress?.(100);
const store = useSettingsStore.getState();
store.setOcrModelVersion(manifest.version);
store.setOcrModelDir(dir);
const nativeModule = getNativeOcrModule();
if (nativeModule && 'setModelDir' in nativeModule) {
try {
await (nativeModule as any).setModelDir(dir);
} catch { /* 忽略 */ }
}
logger.info('modelDownloader', `OCR 模型下载完成: ${manifest.version}${dir}`);
return dir;
}
/**
* SHA-256
* expo-crypto 20MB OOM
*/
async function verifySha256(filePath: string, expected: string): Promise<boolean> {
try {
const Crypto = require('expo-crypto');
const content = await FileSystem.readAsStringAsync(filePath, {
encoding: FileSystem.EncodingType.Base64,
});
const actual = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
content,
{ encoding: Crypto.CryptoEncoding.HEX },
);
return actual.toLowerCase() === expected.toLowerCase();
} catch {
return true; // 无法校验时乐观通过
}
}
/**
* OCR
* 1.
* 2. HuggingFace / URL
*/
export async function ensureOcrModels(
onProgress?: (progress: number) => void,
): Promise<string> {
const store = useSettingsStore.getState();
// 1. 已有 → 复用
if (_ensured && store.ocrModelDir) {
return store.ocrModelDir;
}
if (store.ocrModelDir) {
_ensured = true;
const nativeModule = getNativeOcrModule();
if (nativeModule && 'setModelDir' in nativeModule) {
try { await (nativeModule as any).setModelDir(store.ocrModelDir); } catch { /* ignore */ }
}
return store.ocrModelDir;
}
// 2. 网络下载
logger.info('modelDownloader', '从网络下载 OCR 模型...');
const manifestUrl = DEFAULT_MANIFEST_URL || undefined;
const dir = await downloadOcrModels(manifestUrl, onProgress);
_ensured = true;
return dir;
}
/**
* assets UI 使
*/
export async function redownloadModels(
manifestUrl?: string,
onProgress?: (progress: number) => void,
): Promise<string> {
return downloadOcrModels(manifestUrl, onProgress);
}

View File

@ -18,6 +18,8 @@ export interface NativeOcrModule {
recognizeTextBlocks(imageBase64: string): Promise<OcrTextBlock[]>; recognizeTextBlocks(imageBase64: string): Promise<OcrTextBlock[]>;
/** OCR 引擎是否已就绪(模型加载完成)。 */ /** OCR 引擎是否已就绪(模型加载完成)。 */
isReady(): Promise<boolean>; isReady(): Promise<boolean>;
/** 设置模型文件目录(绝对路径),触发从 filesystem 重新加载模型。 */
setModelDir(dir: string): Promise<boolean>;
} }
export interface OcrTextBlock { export interface OcrTextBlock {
@ -94,11 +96,17 @@ export function getNativeOcrModule(): NativeOcrModule | null {
/** /**
* OcrEngine OcrProcessor * OcrEngine OcrProcessor
* RN NativeOcrBridge * RN NativeOcrBridge
*
* @param modelDir
*/ */
export function getNativeOcrBridge(): OcrEngine { export function getNativeOcrBridge(modelDir?: string): OcrEngine {
const native = getNativeOcrModule(); const native = getNativeOcrModule();
if (!native) { if (!native) {
throw new Error('OCR engine not available'); throw new Error('OCR engine not available');
} }
// P8设置模型目录从 filesystem 加载 ONNX替代 asset 内联)
if (modelDir) {
native.setModelDir(modelDir).catch(() => {});
}
return new NativeOcrBridge(native); return new NativeOcrBridge(native);
} }

View File

@ -65,8 +65,9 @@ export class WebDAVClient {
/** 获取远程文件信息Last-Modified / 大小)。 */ /** 获取远程文件信息Last-Modified / 大小)。 */
async stat(): Promise<{ lastModified: string | null; exists: boolean }> { async stat(): Promise<{ lastModified: string | null; exists: boolean }> {
const url = this.buildUrl(this.config.remotePath);
try { try {
const resp = await this.fetchImpl(this.buildUrl(this.config.remotePath), { const resp = await this.fetchImpl(url, {
method: 'PROPFIND', method: 'PROPFIND',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
@ -75,33 +76,43 @@ export class WebDAVClient {
}, },
body: '<?xml version="1.0"?><propfind xmlns="DAV:"><prop><getlastmodified/></prop></propfind>', body: '<?xml version="1.0"?><propfind xmlns="DAV:"><prop><getlastmodified/></prop></propfind>',
}); });
if (!resp.ok) return { lastModified: null, exists: false }; if (!resp.ok) {
logger.warn('webdavSync', `PROPFIND 请求未成功 [HTTP ${resp.status}], 目标路径: ${this.config.remotePath}`);
return { lastModified: null, exists: false };
}
const body = await resp.text(); const body = await resp.text();
const match = body.match(/<getlastmodified>([^<]+)<\/getlastmodified>/); const match = body.match(/<getlastmodified>([^<]+)<\/getlastmodified>/);
const headerLastMod = resp.headers.get('Last-Modified'); const headerLastMod = resp.headers.get('Last-Modified');
return { lastModified: match?.[1] ?? headerLastMod, exists: true }; return { lastModified: match?.[1] ?? headerLastMod, exists: true };
} catch { } catch (e) {
logger.error('webdavSync', `PROPFIND 网络或数据异常 (URL: ${url}): ${e instanceof Error ? e.message : String(e)}`, e);
return { lastModified: null, exists: false }; return { lastModified: null, exists: false };
} }
} }
/** 下载文件内容。 */ /** 下载文件内容。 */
async get(): Promise<string | null> { async get(): Promise<string | null> {
const url = this.buildUrl(this.config.remotePath);
try { try {
const resp = await this.fetchImpl(this.buildUrl(this.config.remotePath), { const resp = await this.fetchImpl(url, {
method: 'GET', method: 'GET',
headers: { 'Authorization': this.authHeader }, headers: { 'Authorization': this.authHeader },
}); });
if (!resp.ok) return null; if (!resp.ok) {
logger.warn('webdavSync', `GET 远程文件未成功 [HTTP ${resp.status}], 目标路径: ${this.config.remotePath}`);
return null;
}
return await resp.text(); return await resp.text();
} catch { } catch (e) {
logger.error('webdavSync', `GET 远程文件网络或数据异常 (URL: ${url}): ${e instanceof Error ? e.message : String(e)}`, e);
return null; return null;
} }
} }
/** 上传文件内容。 */ /** 上传文件内容。 */
async put(content: string): Promise<void> { async put(content: string): Promise<void> {
const resp = await this.fetchImpl(this.buildUrl(this.config.remotePath), { const url = this.buildUrl(this.config.remotePath);
const resp = await this.fetchImpl(url, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
@ -109,7 +120,10 @@ export class WebDAVClient {
}, },
body: content, body: content,
}); });
if (!resp.ok) throw new Error(`WebDAV PUT 失败: ${resp.status}`); if (!resp.ok) {
logger.error('webdavSync', `PUT 上传文件失败 [HTTP ${resp.status}], 目标路径: ${this.config.remotePath}`);
throw new Error(`WebDAV PUT 失败: ${resp.status}`);
}
} }
/** 列出备份目录。 */ /** 列出备份目录。 */
@ -128,7 +142,8 @@ export class WebDAVClient {
const body = await resp.text(); const body = await resp.text();
const names = [...body.matchAll(/<displayname>([^<]+)<\/displayname>/g)].map(m => m[1]); const names = [...body.matchAll(/<displayname>([^<]+)<\/displayname>/g)].map(m => m[1]);
return names.filter(n => n.includes('backup')); return names.filter(n => n.includes('backup'));
} catch { } catch (e) {
logger.warn('webdavSync', `列出备份目录失败 (路径: ${backupDir}): ${e}`);
return []; return [];
} }
} }

View File

@ -5,6 +5,8 @@
* + IF NOT EXISTS * + IF NOT EXISTS
*/ */
import { logger } from '../utils/logger';
export const SCHEMA_VERSION = 2; export const SCHEMA_VERSION = 2;
/** v1基础缓存表。 */ /** v1基础缓存表。 */
@ -54,19 +56,24 @@ async function setSchemaVersion(db: MigrationDb, version: number): Promise<void>
export async function migrateDb(db: MigrationDb): Promise<void> { export async function migrateDb(db: MigrationDb): Promise<void> {
const current = await getSchemaVersion(db); const current = await getSchemaVersion(db);
if (current >= SCHEMA_VERSION) return; // 已是最新,完全跳过(幂等) if (current >= SCHEMA_VERSION) return; // 已是最新,完全跳过(幂等)
logger.info('storage', `准备升级数据库 Schema: v${current} -> v${SCHEMA_VERSION}`);
await db.execAsync('BEGIN TRANSACTION'); await db.execAsync('BEGIN TRANSACTION');
try { try {
for (let v = current + 1; v <= SCHEMA_VERSION; v++) { for (let v = current + 1; v <= SCHEMA_VERSION; v++) {
const statements = MIGRATIONS[v]; const statements = MIGRATIONS[v];
if (!statements) continue; if (!statements) continue;
for (const sql of statements) { for (const sql of statements) {
logger.debug('storage', `执行迁移 SQL (v${v}): ${sql}`);
await db.execAsync(sql); await db.execAsync(sql);
} }
} }
await setSchemaVersion(db, SCHEMA_VERSION); await setSchemaVersion(db, SCHEMA_VERSION);
await db.execAsync('COMMIT'); await db.execAsync('COMMIT');
logger.info('storage', `数据库 Schema 成功升级到 v${SCHEMA_VERSION}`);
} catch (e) { } catch (e) {
await db.execAsync('ROLLBACK'); await db.execAsync('ROLLBACK');
logger.error('storage', `数据库 Schema 升级失败 (v${current} -> v${SCHEMA_VERSION}),已执行 ROLLBACK`, e);
throw e; throw e;
} }
} }

View File

@ -14,6 +14,8 @@ import type { TransferConfig } from '../domain/transferRecognizer';
import { sharedPipeline } from '../domain/pipelineSingleton'; import { sharedPipeline } from '../domain/pipelineSingleton';
import type { ProcessedDraft } from '../domain/billPipeline'; import type { ProcessedDraft } from '../domain/billPipeline';
import { logger } from '../utils/logger';
export type AutomationSource = 'ocr' | 'notification' | 'sms' | 'screenshot' | 'manual'; export type AutomationSource = 'ocr' | 'notification' | 'sms' | 'screenshot' | 'manual';
export interface AutomationEvent { export interface AutomationEvent {
@ -66,6 +68,7 @@ export const useAutomationStore = create<AutomationState>((set, get) => ({
stats: { ocr: 0, notification: 0, sms: 0, screenshot: 0, manual: 0 }, stats: { ocr: 0, notification: 0, sms: 0, screenshot: 0, manual: 0 },
addDetected: (source, event) => { addDetected: (source, event) => {
logger.info('automationStore', `[收到自动化账单事件] 来源: ${source}, ID: ${event.id}, 时间: ${event.occurredAt}, 金额: ${event.amount} ${event.currency}, 对方: "${event.counterparty || '未知'}"`);
set(state => ({ set(state => ({
detected: [...state.detected, { detected: [...state.detected, {
id: `${source}:${event.id}`, id: `${source}:${event.id}`,
@ -95,6 +98,7 @@ export const useAutomationStore = create<AutomationState>((set, get) => ({
isProcessing: false, isProcessing: false,
}); });
} catch (e) { } catch (e) {
logger.error('automationStore', '处理自动化账单事件异常', e);
set({ isProcessing: false, error: e instanceof Error ? e.message : String(e) }); set({ isProcessing: false, error: e instanceof Error ? e.message : String(e) });
} }
}, },
@ -103,19 +107,27 @@ export const useAutomationStore = create<AutomationState>((set, get) => ({
const { drafts } = get(); const { drafts } = get();
const draft = drafts[index]; const draft = drafts[index];
if (!draft) return null; if (!draft) return null;
logger.info('automationStore', `[自动化草稿确认] 确认自动记账草稿 [日期: ${draft.draft.date}, 商户: "${draft.draft.payee ?? '未知'}", 叙述: "${draft.draft.narration}"]`);
set({ drafts: drafts.filter((_, i) => i !== index) }); set({ drafts: drafts.filter((_, i) => i !== index) });
return draft; return draft;
}, },
rejectDraft: (index) => { rejectDraft: (index) => {
const { drafts } = get(); const { drafts } = get();
const draft = drafts[index];
if (draft) {
logger.info('automationStore', `[自动化草稿拒绝] 丢弃自动记账草稿 [日期: ${draft.draft.date}, 商户: "${draft.draft.payee ?? '未知'}", 叙述: "${draft.draft.narration}"]`);
}
set({ drafts: drafts.filter((_, i) => i !== index) }); set({ drafts: drafts.filter((_, i) => i !== index) });
}, },
clear: () => set({ clear: () => {
detected: [], logger.info('automationStore', '[自动化队列清空] 已清空自动化事件与待处理草稿');
drafts: [], set({
isProcessing: false, detected: [],
stats: { ocr: 0, notification: 0, sms: 0, screenshot: 0, manual: 0 }, drafts: [],
}), isProcessing: false,
stats: { ocr: 0, notification: 0, sms: 0, screenshot: 0, manual: 0 },
});
},
})); }));

View File

@ -10,6 +10,8 @@ import type { DedupConfig } from '../domain/dedup';
import type { TransferConfig } from '../domain/transferRecognizer'; import type { TransferConfig } from '../domain/transferRecognizer';
import type { StatementAdapter } from '../domain/statements'; import type { StatementAdapter } from '../domain/statements';
import { logger } from '../utils/logger';
/** /**
* plan.md0.1 +2.8 * plan.md0.1 +2.8
* *
@ -72,9 +74,11 @@ export const useImportStore = create<ImportState>((set, get) => ({
importCsv: (content, adapter) => { importCsv: (content, adapter) => {
try { try {
const events = importStatement(content, adapter); const events = importStatement(content, adapter);
logger.info('import', `导入 CSV 账单成功, 解析得到 ${events.length} 笔原始事件`);
set({ events, error: null }); set({ events, error: null });
return events; return events;
} catch (e) { } catch (e) {
logger.error('import', '解析 CSV 账单代码或格式失败', e);
set({ error: e instanceof Error ? e.message : String(e) }); set({ error: e instanceof Error ? e.message : String(e) });
throw e; throw e;
} }
@ -105,6 +109,7 @@ export const useImportStore = create<ImportState>((set, get) => ({
if (!draft) return null; if (!draft) return null;
// 用 draft 引用而非索引过滤,避免竞态 // 用 draft 引用而非索引过滤,避免竞态
const next = pendingDrafts.filter((d) => d !== draft); const next = pendingDrafts.filter((d) => d !== draft);
logger.info('import', `[手动确认草稿] 确认导入草稿 [日期: ${draft.draft.date}, 商户: "${draft.draft.payee ?? '未知'}", 叙述: "${draft.draft.narration}"]`);
set({ pendingDrafts: next }); set({ pendingDrafts: next });
return draft; return draft;
}, },
@ -113,13 +118,21 @@ export const useImportStore = create<ImportState>((set, get) => ({
const { pendingDrafts } = get(); const { pendingDrafts } = get();
// 使用引用过滤而非索引过滤,避免并发覆盖 // 使用引用过滤而非索引过滤,避免并发覆盖
const toRemove = new Set(indices.map(i => pendingDrafts[i])); const toRemove = new Set(indices.map(i => pendingDrafts[i]));
logger.info('import', `[批量确认草稿] 批量确认 ${toRemove.size} 笔草稿入账`);
set({ pendingDrafts: pendingDrafts.filter(d => !toRemove.has(d)) }); set({ pendingDrafts: pendingDrafts.filter(d => !toRemove.has(d)) });
}, },
rejectDraft: (index) => { rejectDraft: (index) => {
const { pendingDrafts } = get(); const { pendingDrafts } = get();
const draft = pendingDrafts[index];
if (draft) {
logger.info('import', `[手动忽略草稿] 丢弃草稿 [日期: ${draft.draft.date}, 商户: "${draft.draft.payee ?? '未知'}", 叙述: "${draft.draft.narration}"]`);
}
set({ pendingDrafts: pendingDrafts.filter((_, i) => i !== index) }); set({ pendingDrafts: pendingDrafts.filter((_, i) => i !== index) });
}, },
clear: () => set({ events: [], pendingDrafts: [], lastResult: null, error: null }), clear: () => {
logger.info('import', '[清空导入缓存] 已清空当前导入队列与草稿列表');
set({ events: [], pendingDrafts: [], lastResult: null, error: null });
},
})); }));

View File

@ -166,6 +166,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
return withLock(async () => { return withLock(async () => {
const { mobileStore } = get(); const { mobileStore } = get();
if (!mobileStore) throw new Error('账本未加载'); if (!mobileStore) throw new Error('账本未加载');
logger.info('ledgerStore', `正在全量替换 mobile.bean 内容 (新长度: ${content.length} 字节)`);
await mobileStore.replace(content); await mobileStore.replace(content);
const ledger = reparseLedger(content); const ledger = reparseLedger(content);
if (get().mobileStore !== mobileStore) return; if (get().mobileStore !== mobileStore) return;
@ -181,6 +182,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
// 去重:跳过已 open 的账户 // 去重:跳过已 open 的账户
const newAccounts = accounts.filter(acc => !ledger?.accounts.has(acc)); const newAccounts = accounts.filter(acc => !ledger?.accounts.has(acc));
if (newAccounts.length === 0) return; if (newAccounts.length === 0) return;
logger.info('ledgerStore', `自动开户 ${newAccounts.length} 个账户: ${newAccounts.join(', ')}`);
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const currency = getDefaultCurrency(ledger); const currency = getDefaultCurrency(ledger);
const openStatements = newAccounts.map(acc => `${today} open ${acc} ${currency}`).join('\n'); const openStatements = newAccounts.map(acc => `${today} open ${acc} ${currency}`).join('\n');
@ -195,6 +197,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
return withLock(async () => { return withLock(async () => {
const { mobileStore } = get(); const { mobileStore } = get();
if (!mobileStore) throw new Error('账本未加载'); if (!mobileStore) throw new Error('账本未加载');
logger.info('ledgerStore', `自动销户账户: ${accountName}`);
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
await mobileStore.modifyContent(current => `${current.trimEnd()}\n\n${today} close ${accountName}\n`.trim()); await mobileStore.modifyContent(current => `${current.trimEnd()}\n\n${today} close ${accountName}\n`.trim());
const newLedger = reparseLedger(mobileStore.getContent()); const newLedger = reparseLedger(mobileStore.getContent());
@ -209,6 +212,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
if (!mobileStore) throw new Error('账本未加载'); if (!mobileStore) throw new Error('账本未加载');
const ledger = get().ledger; const ledger = get().ledger;
const currency = getDefaultCurrency(ledger); const currency = getDefaultCurrency(ledger);
logger.info('ledgerStore', `校准账户余额: ${accountName} -> ${balance} ${currency} (日期: ${date})`);
// 计算前一天的日期用于 pad使用本地时间避免 UTC 偏移) // 计算前一天的日期用于 pad使用本地时间避免 UTC 偏移)
const [y, m, d] = date.split('-').map(Number); const [y, m, d] = date.split('-').map(Number);
const padDate = toDateString(new Date(y, m - 1, d - 1)); const padDate = toDateString(new Date(y, m - 1, d - 1));
@ -224,6 +228,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
return withLock(async () => { return withLock(async () => {
const { mobileStore } = get(); const { mobileStore } = get();
if (!mobileStore) throw new Error('账本未加载'); if (!mobileStore) throw new Error('账本未加载');
logger.info('ledgerStore', `批量追加 ${drafts.length} 笔交易到 mobile.bean`);
const mobileBean = get().mobileBean; // 锁内读取最新值 const mobileBean = get().mobileBean; // 锁内读取最新值
const serializedChunk = drafts.map(d => serializeTransaction(d)).join('\n\n'); const serializedChunk = drafts.map(d => serializeTransaction(d)).join('\n\n');
const newContent = `${mobileBean.trimEnd()}\n\n${serializedChunk}\n`.trim(); const newContent = `${mobileBean.trimEnd()}\n\n${serializedChunk}\n`.trim();
@ -244,6 +249,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
const newContent = mobileBean.slice(0, idx) + newRaw + mobileBean.slice(idx + oldRaw.length); const newContent = mobileBean.slice(0, idx) + newRaw + mobileBean.slice(idx + oldRaw.length);
await mobileStore.replace(newContent); await mobileStore.replace(newContent);
const newLedger = reparseLedger(newContent); const newLedger = reparseLedger(newContent);
logger.info('ledgerStore', `修改已保存交易成功:\n【修改前】:\n${oldRaw.trim()}\n【修改后】:\n${newRaw.trim()}`);
if (get().mobileStore !== mobileStore) return ''; if (get().mobileStore !== mobileStore) return '';
set({ mobileBean: newContent, ledger: newLedger }); set({ mobileBean: newContent, ledger: newLedger });
const newTx = newLedger.transactions.find(t => t.raw === newRaw); const newTx = newLedger.transactions.find(t => t.raw === newRaw);
@ -262,6 +268,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
newContent = newContent.replace(/\n{3,}/g, '\n\n').trim(); newContent = newContent.replace(/\n{3,}/g, '\n\n').trim();
await mobileStore.replace(newContent); await mobileStore.replace(newContent);
const newLedger = reparseLedger(newContent); const newLedger = reparseLedger(newContent);
logger.info('ledgerStore', `删除已保存交易成功:\n【被删除交易内容】:\n${raw.trim()}`);
if (get().mobileStore !== mobileStore) return; if (get().mobileStore !== mobileStore) return;
set({ mobileBean: newContent, ledger: newLedger }); set({ mobileBean: newContent, ledger: newLedger });
}); });

View File

@ -106,7 +106,7 @@ const DEFAULTS: PersistableMetadata = {
{ id: 'b2', name: '交通月预算', amount: '500', period: 'monthly', categoryAccount: 'Expenses:交通', startDate: '2026-01-01' }, { id: 'b2', name: '交通月预算', amount: '500', period: 'monthly', categoryAccount: 'Expenses:交通', startDate: '2026-01-01' },
], ],
creditCards: [ creditCards: [
{ id: 'c1', name: '示例信用卡', lastFour: '0000', bankName: 'Bank', billingDay: 5, paymentDay: 25, creditLimit: '10000', currency: 'CNY', linkedAccount: 'Liabilities:信用卡' }, { id: 'cc1', name: '招商银行信用卡', lastFour: '1589', bankName: '招商银行', billingDay: 5, paymentDay: 23, creditLimit: '50000', currency: 'CNY', linkedAccount: 'Liabilities:CreditCard:CMB' },
], ],
rules: [ rules: [
// 通用匹配规则示例 // 通用匹配规则示例
@ -141,10 +141,8 @@ export function generateId(prefix = 'id'): string {
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
} }
/** import { logger } from '../utils/logger';
* CRUD key add/update/remove action
* 7
*/
function createCrudActions<T extends { id: string }>( function createCrudActions<T extends { id: string }>(
key: keyof PersistableMetadata, key: keyof PersistableMetadata,
set: (fn: (s: MetadataState) => Partial<MetadataState>) => void, set: (fn: (s: MetadataState) => Partial<MetadataState>) => void,
@ -153,18 +151,22 @@ function createCrudActions<T extends { id: string }>(
return { return {
add: (item: T) => { add: (item: T) => {
set((s) => ({ [key]: [...((s[key] as unknown as T[])), item] }) as Partial<MetadataState>); set((s) => ({ [key]: [...((s[key] as unknown as T[])), item] }) as Partial<MetadataState>);
const name = (item as Record<string, unknown>).name || (item as Record<string, unknown>).narration || item.id;
logger.info('metadataStore', `添加元数据实体 [${key}]: ${name} (ID: ${item.id})`, item);
get().persistTo?.(toPersistable(get())); get().persistTo?.(toPersistable(get()));
}, },
update: (id: string, patch: Partial<T>) => { update: (id: string, patch: Partial<T>) => {
set((s) => ({ set((s) => ({
[key]: (s[key] as unknown as T[]).map(item => item.id === id ? { ...item, ...patch } : item), [key]: (s[key] as unknown as T[]).map(item => item.id === id ? { ...item, ...patch } : item),
}) as Partial<MetadataState>); }) as Partial<MetadataState>);
logger.info('metadataStore', `更新元数据实体 [${key}] (ID: ${id})`, patch);
get().persistTo?.(toPersistable(get())); get().persistTo?.(toPersistable(get()));
}, },
remove: (id: string) => { remove: (id: string) => {
set((s) => ({ set((s) => ({
[key]: (s[key] as unknown as T[]).filter(item => item.id !== id), [key]: (s[key] as unknown as T[]).filter(item => item.id !== id),
}) as Partial<MetadataState>); }) as Partial<MetadataState>);
logger.info('metadataStore', `删除元数据实体 [${key}] (ID: ${id})`);
get().persistTo?.(toPersistable(get())); get().persistTo?.(toPersistable(get()));
}, },
}; };

View File

@ -30,6 +30,11 @@ export interface SettingsState {
/** 引导是否完成。 */ /** 引导是否完成。 */
onboardingCompleted: boolean; onboardingCompleted: boolean;
setOnboardingCompleted: (v: boolean) => void; setOnboardingCompleted: (v: boolean) => void;
setLayer1RuleEnabled: (enabled: boolean) => void;
setLayer2OcrEnabled: (enabled: boolean) => void;
setLayer3AiEnabled: (enabled: boolean) => void;
setOcrModelVersion: (version: string) => void;
setOcrModelDir: (dir: string) => void;
// Sync Configurations // Sync Configurations
webdavUrl?: string; webdavUrl?: string;
@ -57,6 +62,18 @@ export interface SettingsState {
floatingBallEnabled: boolean; floatingBallEnabled: boolean;
setFloatingBallEnabled: (enabled: boolean) => void; setFloatingBallEnabled: (enabled: boolean) => void;
// 自动记账层级
/** 自动记账 L1正则规则匹配。 */
layer1RuleEnabled: boolean;
/** 自动记账 L2本地 OCR 识别。 */
layer2OcrEnabled: boolean;
/** 自动记账 L3云端 AI Vision。 */
layer3AiEnabled: boolean;
/** OCR 模型版本(已下载),空字符串表示未安装。 */
ocrModelVersion: string;
/** OCR 模型文件目录的绝对路径。 */
ocrModelDir: string;
// 记账面板 // 记账面板
/** 按钮是否打开全局记账面板P5 起默认 true已持久化 false 的用户保持关闭)。 */ /** 按钮是否打开全局记账面板P5 起默认 true已持久化 false 的用户保持关闭)。 */
numpadGlobalEntry: boolean; numpadGlobalEntry: boolean;
@ -117,6 +134,11 @@ export interface PersistableSettings {
numpadGlobalEntry: boolean; numpadGlobalEntry: boolean;
lastUsedAccount: string | null; lastUsedAccount: string | null;
lastUsedCategoryId: string | null; lastUsedCategoryId: string | null;
layer1RuleEnabled: boolean;
layer2OcrEnabled: boolean;
layer3AiEnabled: boolean;
ocrModelVersion: string;
ocrModelDir: string;
} }
/** /**
@ -157,6 +179,11 @@ const DEFAULTS: PersistableSettings & { skipPersist: boolean; floatingBallEnable
numpadGlobalEntry: true, numpadGlobalEntry: true,
lastUsedAccount: null, lastUsedAccount: null,
lastUsedCategoryId: null, lastUsedCategoryId: null,
layer1RuleEnabled: true,
layer2OcrEnabled: true,
layer3AiEnabled: false,
ocrModelVersion: '',
ocrModelDir: '',
skipPersist: false, skipPersist: false,
}; };
@ -190,40 +217,101 @@ export function toPersistable(state: SettingsState): PersistableSettings {
numpadGlobalEntry: state.numpadGlobalEntry, numpadGlobalEntry: state.numpadGlobalEntry,
lastUsedAccount: state.lastUsedAccount, lastUsedAccount: state.lastUsedAccount,
lastUsedCategoryId: state.lastUsedCategoryId, lastUsedCategoryId: state.lastUsedCategoryId,
layer1RuleEnabled: state.layer1RuleEnabled,
layer2OcrEnabled: state.layer2OcrEnabled,
layer3AiEnabled: state.layer3AiEnabled,
ocrModelVersion: state.ocrModelVersion,
ocrModelDir: state.ocrModelDir,
}; };
} }
import { logger } from '../utils/logger';
export const useSettingsStore = create<SettingsState>((set) => ({ export const useSettingsStore = create<SettingsState>((set) => ({
...DEFAULTS, ...DEFAULTS,
updateDedupConfig: (patch) => set((s) => ({ dedupConfig: { ...s.dedupConfig, ...patch } })), updateDedupConfig: (patch) => {
logger.info('settings', '更新去重配置', patch);
set((s) => ({ dedupConfig: { ...s.dedupConfig, ...patch } }));
},
updateTransferConfig: (patch) => set((s) => ({ transferConfig: { ...s.transferConfig, ...patch } })), updateTransferConfig: (patch) => {
logger.info('settings', '更新转账识别配置', patch);
set((s) => ({ transferConfig: { ...s.transferConfig, ...patch } }));
},
setDedupEnabled: (enabled) => set({ dedupEnabled: enabled }), setDedupEnabled: (enabled) => {
logger.info('settings', `设置自动去重功能状态: ${enabled ? '开启' : '关闭'}`);
set({ dedupEnabled: enabled });
},
setTransferRecognitionEnabled: (enabled) => set({ transferRecognitionEnabled: enabled }), setTransferRecognitionEnabled: (enabled) => {
logger.info('settings', `设置转账识别功能状态: ${enabled ? '开启' : '关闭'}`);
set({ transferRecognitionEnabled: enabled });
},
setThemeMode: (mode) => set({ themeMode: mode }), setThemeMode: (mode) => {
logger.info('settings', `切换主题模式: ${mode}`);
set({ themeMode: mode });
},
setLocale: (locale) => set({ locale }), setLocale: (locale) => {
logger.info('settings', `切换应用语言: ${locale}`);
set({ locale });
},
setAppLockEnabled: (enabled) => set({ appLockEnabled: enabled }), setAppLockEnabled: (enabled) => {
logger.info('settings', `设置应用锁安全防护: ${enabled ? '开启' : '关闭'}`);
set({ appLockEnabled: enabled });
},
updateSyncConfig: (patch) => set((s) => ({ ...s, ...patch })), updateSyncConfig: (patch) => {
const safePatch = { ...patch };
if (safePatch.webdavPassword) safePatch.webdavPassword = '***';
if (safePatch.gitPassword) safePatch.gitPassword = '***';
logger.info('settings', '更新云同步配置', safePatch);
set((s) => ({ ...s, ...patch }));
},
updateAiConfig: (patch) => set((s) => ({ ...s, ...patch })), updateAiConfig: (patch) => {
const safePatch = { ...patch };
if (safePatch.aiApiKey) safePatch.aiApiKey = '***';
logger.info('settings', '更新 AI 智记配置', safePatch);
set((s) => ({ ...s, ...patch }));
},
updateReminderConfig: (patch) => set((s) => ({ ...s, ...patch })), updateReminderConfig: (patch) => {
logger.info('settings', '更新记账提醒配置', patch);
set((s) => ({ ...s, ...patch }));
},
setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }), setOnboardingCompleted: (completed) => {
logger.info('settings', `设置新手引导完成状态: ${completed}`);
set({ onboardingCompleted: completed });
},
setFloatingBallEnabled: (enabled) => set({ floatingBallEnabled: enabled }), setFloatingBallEnabled: (enabled) => {
logger.info('settings', `设置桌面悬浮球: ${enabled ? '开启' : '关闭'}`);
set({ floatingBallEnabled: enabled });
},
setNumpadGlobalEntry: (enabled) => set({ numpadGlobalEntry: enabled }), setNumpadGlobalEntry: (enabled) => {
logger.info('settings', `设置全局记账小键盘: ${enabled ? '开启' : '关闭'}`);
set({ numpadGlobalEntry: enabled });
},
setLastUsedEntry: (account, categoryId) => set({ lastUsedAccount: account, lastUsedCategoryId: categoryId }), setLastUsedEntry: (account, categoryId) => set({ lastUsedAccount: account, lastUsedCategoryId: categoryId }),
setLayer1RuleEnabled: (enabled) => set({ layer1RuleEnabled: enabled }),
setLayer2OcrEnabled: (enabled) => set({ layer2OcrEnabled: enabled }),
setLayer3AiEnabled: (enabled) => set({ layer3AiEnabled: enabled }),
setOcrModelVersion: (version) => set({ ocrModelVersion: version }),
setOcrModelDir: (dir) => set({ ocrModelDir: dir }),
hydrate: (data) => { hydrate: (data) => {
// hydrate 期间跳过自动持久化 // hydrate 期间跳过自动持久化
// 使用单次 setState 合并所有数据,避免多次触发 subscriber // 使用单次 setState 合并所有数据,避免多次触发 subscriber

View File

@ -13,5 +13,6 @@ export function createTheme(overrides: Partial<ThemeTokens>): ThemeTokens {
radii: { ...lightTheme.radii, ...overrides.radii }, radii: { ...lightTheme.radii, ...overrides.radii },
typography: { ...lightTheme.typography, ...overrides.typography }, typography: { ...lightTheme.typography, ...overrides.typography },
shadows: { ...lightTheme.shadows, ...overrides.shadows }, shadows: { ...lightTheme.shadows, ...overrides.shadows },
opacities: { ...lightTheme.opacities, ...overrides.opacities },
}; };
} }

View File

@ -48,6 +48,10 @@ export const lightTheme: ThemeTokens = {
md: { shadowColor: '#111318', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 8, elevation: 2 }, md: { shadowColor: '#111318', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 8, elevation: 2 },
lg: { shadowColor: '#111318', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.10, shadowRadius: 12, elevation: 4 }, lg: { shadowColor: '#111318', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.10, shadowRadius: 12, elevation: 4 },
}, },
opacities: {
pressed: 0.7,
disabled: 0.4,
},
}; };
export const darkTheme: ThemeTokens = { export const darkTheme: ThemeTokens = {

View File

@ -97,10 +97,16 @@ export interface ThemeShadows {
lg: ThemeShadow; lg: ThemeShadow;
} }
export interface ThemeOpacities {
pressed: number; // 统一点击按压透明度 (0.7)
disabled: number; // 禁用透明度 (0.4)
}
export interface ThemeTokens { export interface ThemeTokens {
colors: ThemeColors; colors: ThemeColors;
spacing: ThemeSpacing; spacing: ThemeSpacing;
radii: ThemeRadii; radii: ThemeRadii;
typography: ThemeTypography; typography: ThemeTypography;
shadows: ThemeShadows; shadows: ThemeShadows;
opacities: ThemeOpacities;
} }

View File

@ -1,11 +1,10 @@
/** /**
* plan.md0.6 * plan.md0.6
* *
* AutoAccounting Logger + BeeCount Log Center * - 4 DEBUG/INFO/WARN/ERROR
* - 4 debug/info/warn/error * - 1000 UI
* - 1000 * - LogFileBackend
* - Sentryplan.md Phase 9 * - 7
* - getLogs/clearLogs UI
*/ */
export enum LogLevel { export enum LogLevel {
@ -16,14 +15,30 @@ export enum LogLevel {
} }
export interface LogEntry { export interface LogEntry {
timestamp: string; // ISO timestamp: string; // ISO 时间戳
level: LogLevel; level: LogLevel;
tag: string; // 模块标识(如 'mobileStore'、'billPipeline' tag: string; // 模块标识(如 'mobileStore'、'billPipeline'
message: string; message: string;
data?: unknown; data?: unknown;
} }
class LoggerImpl { export interface LogFileInfo {
name: string; // 文件名,例如 'app-2026-07-22.log'
path: string; // 文件完整路径/URI
size: number; // 字节大小
date: string; // 日期字符串 YYYY-MM-DD
}
/** 磁盘日志持久化后端抽象(解耦 React Native / Expo 文件系统)。 */
export interface LogFileBackend {
appendLog(dateStr: string, logLine: string): Promise<void>;
readLogFile(dateStr: string): Promise<string>;
listLogFiles(): Promise<LogFileInfo[]>;
deleteLogFile(dateStr: string): Promise<void>;
clearAllLogFiles(): Promise<void>;
}
export class LoggerImpl {
private buffer: LogEntry[] = []; private buffer: LogEntry[] = [];
private maxBuffer: number; private maxBuffer: number;
/** 最低输出级别(默认 DEBUG生产环境可调高。 */ /** 最低输出级别(默认 DEBUG生产环境可调高。 */
@ -31,10 +46,23 @@ class LoggerImpl {
/** 是否输出到 console开发环境 true。 */ /** 是否输出到 console开发环境 true。 */
consoleOutput: boolean = true; consoleOutput: boolean = true;
private fileBackend: LogFileBackend | null = null;
private pendingWriteQueue: { dateStr: string; line: string }[] = [];
private isFlushingQueue: boolean = false;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
constructor(maxBuffer = 1000) { constructor(maxBuffer = 1000) {
this.maxBuffer = maxBuffer; this.maxBuffer = maxBuffer;
} }
/** 设置磁盘持久化后端。 */
async initFileBackend(backend: LogFileBackend, options?: { maxDays?: number }): Promise<void> {
this.fileBackend = backend;
if (options?.maxDays) {
await this.cleanExpiredLogFiles(options.maxDays);
}
}
debug(tag: string, message: string, data?: unknown): void { debug(tag: string, message: string, data?: unknown): void {
this.log(LogLevel.DEBUG, tag, message, data); this.log(LogLevel.DEBUG, tag, message, data);
} }
@ -74,34 +102,191 @@ class LoggerImpl {
case LogLevel.ERROR: console.error(prefix, message, data ?? ''); break; case LogLevel.ERROR: console.error(prefix, message, data ?? ''); break;
} }
} }
// 写入文件队列
if (this.fileBackend) {
const dateStr = entry.timestamp.slice(0, 10); // YYYY-MM-DD
const line = JSON.stringify(entry);
this.pendingWriteQueue.push({ dateStr, line });
this.scheduleQueueFlush();
}
} }
/** 获取所有日志(副本)。 */ /** 防抖批量刷新写入文件。 */
private scheduleQueueFlush(): void {
if (this.flushTimer) return;
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
this.flushQueue().catch(() => {});
}, 200);
}
/** 强制立即刷新文件写入队列。 */
async flushQueue(): Promise<void> {
if (!this.fileBackend || this.isFlushingQueue || this.pendingWriteQueue.length === 0) {
return;
}
this.isFlushingQueue = true;
const itemsToFlush = [...this.pendingWriteQueue];
this.pendingWriteQueue = [];
try {
// 按日期分组写入
const groupedByDate: Record<string, string[]> = {};
for (const item of itemsToFlush) {
if (!groupedByDate[item.dateStr]) {
groupedByDate[item.dateStr] = [];
}
groupedByDate[item.dateStr].push(item.line);
}
for (const [dateStr, lines] of Object.entries(groupedByDate)) {
await this.fileBackend.appendLog(dateStr, lines.join('\n') + '\n');
}
} catch {
// 写入失败重试逻辑:将未成功写入的重新放回队列头部
this.pendingWriteQueue.unshift(...itemsToFlush);
} finally {
this.isFlushingQueue = false;
}
}
/** 获取内存中所有日志(副本)。 */
getLogs(): LogEntry[] { getLogs(): LogEntry[] {
return [...this.buffer]; return [...this.buffer];
} }
/** 按级别/标签过滤。 */ /** 按级别/标签过滤内存日志。 */
query(filter: { minLevel?: LogLevel; tag?: string }): LogEntry[] { query(filter: { minLevel?: LogLevel; tag?: string; keyword?: string }): LogEntry[] {
return this.buffer.filter(e => return this.buffer.filter(e => {
(filter.minLevel === undefined || e.level >= filter.minLevel) && if (filter.minLevel !== undefined && e.level < filter.minLevel) return false;
(filter.tag === undefined || e.tag === filter.tag), if (filter.tag !== undefined && filter.tag !== '' && e.tag !== filter.tag) return false;
); if (filter.keyword) {
const kw = filter.keyword.toLowerCase();
const msgMatch = e.message.toLowerCase().includes(kw);
const tagMatch = e.tag.toLowerCase().includes(kw);
const dataMatch = e.data ? JSON.stringify(e.data).toLowerCase().includes(kw) : false;
if (!msgMatch && !tagMatch && !dataMatch) return false;
}
return true;
});
} }
/** 清空日志。 */ /** 清空内存日志。 */
clearLogs(): void { clearLogs(): void {
this.buffer = []; this.buffer = [];
} }
/** 调整缓冲上限。 */ /** 清空内存与磁盘日志。 */
async clearAllLogs(): Promise<void> {
this.buffer = [];
this.pendingWriteQueue = [];
if (this.fileBackend) {
await this.fileBackend.clearAllLogFiles();
}
}
/** 调整内存缓冲上限。 */
setMaxBuffer(size: number): void { setMaxBuffer(size: number): void {
this.maxBuffer = size; this.maxBuffer = size;
// 立即裁剪
if (this.buffer.length > size) { if (this.buffer.length > size) {
this.buffer = this.buffer.slice(this.buffer.length - size); this.buffer = this.buffer.slice(this.buffer.length - size);
} }
} }
/** 获取所有日志文件列表。 */
async getLogFiles(): Promise<LogFileInfo[]> {
if (!this.fileBackend) return [];
return this.fileBackend.listLogFiles();
}
/** 从文件读取特定日期的日志。 */
async loadLogsFromFile(dateStr: string): Promise<LogEntry[]> {
if (!this.fileBackend) return [];
try {
const content = await this.fileBackend.readLogFile(dateStr);
if (!content.trim()) return [];
const entries: LogEntry[] = [];
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed) as LogEntry;
if (entry && entry.timestamp && entry.level !== undefined && entry.message) {
entries.push(entry);
}
} catch {
// 忽略非法数据行
}
}
return entries;
} catch {
return [];
}
}
/** 获取今天的全量日志(包含已被写入手机磁盘的历史日志 + 当前 Session 内存中的最新日志,自动去重)。 */
async getTodayLogs(): Promise<LogEntry[]> {
const todayStr = new Date().toISOString().slice(0, 10);
const diskLogs = await this.loadLogsFromFile(todayStr);
const seen = new Set<string>();
const combined: LogEntry[] = [];
for (const entry of diskLogs) {
const key = `${entry.timestamp}_${entry.tag}_${entry.message}`;
if (!seen.has(key)) {
seen.add(key);
combined.push(entry);
}
}
for (const entry of this.buffer) {
const key = `${entry.timestamp}_${entry.tag}_${entry.message}`;
if (!seen.has(key)) {
seen.add(key);
combined.push(entry);
}
}
return combined;
}
/** 清理超过 maxDays 天数的历史日志文件。 */
async cleanExpiredLogFiles(maxDays = 7): Promise<void> {
if (!this.fileBackend) return;
try {
const files = await this.fileBackend.listLogFiles();
const cutoffTime = Date.now() - maxDays * 24 * 60 * 60 * 1000;
for (const file of files) {
const fileDate = new Date(file.date).getTime();
if (!isNaN(fileDate) && fileDate < cutoffTime) {
await this.fileBackend.deleteLogFile(file.date);
}
}
} catch {
// 静默处理清理错误
}
}
/** 格式化日志列表为文本形式(可用于导出或分享)。 */
exportAsFormattedText(entries?: LogEntry[]): string {
const list = entries ?? this.buffer;
if (list.length === 0) return '--- 无日志记录 ---';
return list
.map(e => {
const levelName = LEVEL_LABELS[e.level] ?? 'INFO';
const hasData = e.data !== undefined && e.data !== null && (typeof e.data !== 'object' || Object.keys(e.data).length > 0);
const dataStr = hasData ? ` | Data: ${JSON.stringify(e.data)}` : '';
return `[${e.timestamp}] [${levelName}] [${e.tag}] ${e.message}${dataStr}`;
})
.join('\n');
}
} }
/** 全局单例日志器。 */ /** 全局单例日志器。 */
@ -119,3 +304,4 @@ export const LEVEL_LABELS: Record<LogLevel, string> = {
[LogLevel.WARN]: 'WARN', [LogLevel.WARN]: 'WARN',
[LogLevel.ERROR]: 'ERROR', [LogLevel.ERROR]: 'ERROR',
}; };

View File

@ -17,6 +17,7 @@ describe('ocrBridge', () => {
recognizeText: async () => '微信支付 金额 10 商户:x', recognizeText: async () => '微信支付 金额 10 商户:x',
recognizeTextBlocks: async () => [], recognizeTextBlocks: async () => [],
isReady: async () => true, isReady: async () => true,
setModelDir: async () => true,
}; };
const bridge = new NativeOcrBridge(native); const bridge = new NativeOcrBridge(native);
expect(await bridge.recognizeText('img')).toBe('微信支付 金额 10 商户:x'); expect(await bridge.recognizeText('img')).toBe('微信支付 金额 10 商户:x');
@ -27,6 +28,7 @@ describe('ocrBridge', () => {
recognizeText: async () => { throw new Error('原生崩溃'); }, recognizeText: async () => { throw new Error('原生崩溃'); },
recognizeTextBlocks: async () => [], recognizeTextBlocks: async () => [],
isReady: async () => false, isReady: async () => false,
setModelDir: async () => true,
}; };
const fallback = new SimulatedOcrBridge(); const fallback = new SimulatedOcrBridge();
fallback.preset('fallback', '降级文本'); fallback.preset('fallback', '降级文本');

View File

@ -0,0 +1,71 @@
import { afterEach, describe, expect, it } from 'vitest';
import { colorToHex, buildFloatingUiConfig } from '../src/services/floatingUiConfig';
import { lightTheme, darkTheme } from '../src/theme/presets';
import { setLocale, t } from '../src/i18n';
// 共享 i18n 单例:无论断言是否失败都还原 locale避免泄漏给后续测试文件
afterEach(() => setLocale('zh'));
describe('colorToHex', () => {
it("'#RRGGBB' 原样返回", () => {
expect(colorToHex('#111318')).toBe('#111318');
expect(colorToHex('#E5E7EB')).toBe('#E5E7EB');
});
it("'rgba(r,g,b,a)' → '#AARRGGBB'alpha 四舍五入)", () => {
expect(colorToHex('rgba(255,255,255,0.08)')).toBe('#14FFFFFF');
expect(colorToHex('rgba(17,19,24,0.4)')).toBe('#66111318');
expect(colorToHex('rgba(0,0,0,0.7)')).toBe('#B3000000');
});
it('其它格式原样返回', () => {
expect(colorToHex('red')).toBe('red');
expect(colorToHex('rgb(1,2,3)')).toBe('rgb(1,2,3)');
});
});
describe('buildFloatingUiConfig', () => {
it('lightTheme colors 映射正确', () => {
setLocale('zh');
const config = buildFloatingUiConfig(lightTheme, t);
expect(config.colors.accent).toBe('#111318');
expect(config.colors.accentFg).toBe('#FFFFFF');
expect(config.colors.cardBg).toBe('#FFFFFF');
expect(config.colors.inputBg).toBe('#EFF1F4');
expect(config.colors.fgPrimary).toBe('#111318');
expect(config.colors.fgSecondary).toBe('#6B7280');
expect(config.colors.border).toBe('#E5E7EB');
expect(config.colors.income).toBe('#10B981');
expect(config.colors.expense).toBe('#EF4444');
expect(config.colors.transfer).toBe('#3B82F6');
});
it('darkTheme 的 rgba border 转换为 #AARRGGBB', () => {
const config = buildFloatingUiConfig(darkTheme, t);
expect(config.colors.border).toBe('#14FFFFFF');
expect(config.colors.accent).toBe('#F3F4F6');
});
it('labels 使用 floating.* 文案且所有契约字段非空', () => {
setLocale('zh');
const config = buildFloatingUiConfig(lightTheme, t);
expect(config.labels.billTitle).toBe('调整交易草稿');
expect(config.labels.confirm).toBe('确认入账');
expect(config.labels.ballOcr).toBe('识别账单');
for (const [key, value] of Object.entries(config.labels)) {
expect(typeof value, `labels.${key} 应为字符串`).toBe('string');
expect(value.length, `labels.${key} 不应为空`).toBeGreaterThan(0);
expect(value, `labels.${key} 不应是 missing 标记`).not.toContain('[missing');
}
for (const [key, value] of Object.entries(config.colors)) {
expect(value, `colors.${key} 应为 hex`).toMatch(/^#[0-9A-F]{6,8}$/);
}
});
it('英文 locale 下 labels 为英文', () => {
setLocale('en');
const config = buildFloatingUiConfig(lightTheme, t);
expect(config.labels.billTitle).toBe('Adjust Draft');
expect(config.labels.ballOcr).toBe('Scan Bill');
});
});

167
tests/logger.test.ts Normal file
View File

@ -0,0 +1,167 @@
import { describe, expect, it, vi } from 'vitest';
import { LogFileBackend, LogFileInfo, LogLevel, createLogger } from '../src/utils/logger';
class MemoryLogBackend implements LogFileBackend {
public files: Map<string, string> = new Map();
async appendLog(dateStr: string, logLine: string): Promise<void> {
const existing = this.files.get(dateStr) ?? '';
this.files.set(dateStr, existing + logLine);
}
async readLogFile(dateStr: string): Promise<string> {
return this.files.get(dateStr) ?? '';
}
async listLogFiles(): Promise<LogFileInfo[]> {
const res: LogFileInfo[] = [];
for (const [date, content] of this.files.entries()) {
res.push({
name: `app-${date}.log`,
path: `/mock/logs/app-${date}.log`,
size: Buffer.byteLength(content, 'utf8'),
date,
});
}
return res.sort((a, b) => b.date.localeCompare(a.date));
}
async deleteLogFile(dateStr: string): Promise<void> {
this.files.delete(dateStr);
}
async clearAllLogFiles(): Promise<void> {
this.files.clear();
}
}
describe('LoggerImpl 持久化与高级功能测试', () => {
it('应当能正确初始化 LogFileBackend 并将日志追加写入内存文件', async () => {
const logger = createLogger();
logger.consoleOutput = false;
const backend = new MemoryLogBackend();
await logger.initFileBackend(backend);
logger.info('testTag', '测试消息1', { foo: 'bar' });
logger.error('testTag', '崩溃报错消息');
await logger.flushQueue();
const todayStr = new Date().toISOString().slice(0, 10);
const fileContent = await backend.readLogFile(todayStr);
expect(fileContent).toContain('测试消息1');
expect(fileContent).toContain('崩溃报错消息');
expect(fileContent).toContain('"foo":"bar"');
});
it('应当能正确按日期读取文件中的历史日志条目', async () => {
const logger = createLogger();
logger.consoleOutput = false;
const backend = new MemoryLogBackend();
const historicalDate = '2026-07-20';
const logLine1 = JSON.stringify({
timestamp: '2026-07-20T10:00:00.000Z',
level: LogLevel.WARN,
tag: 'network',
message: '网络超时',
});
const logLine2 = JSON.stringify({
timestamp: '2026-07-20T10:05:00.000Z',
level: LogLevel.ERROR,
tag: 'db',
message: '数据库锁死',
});
await backend.appendLog(historicalDate, `${logLine1}\n${logLine2}\n`);
await logger.initFileBackend(backend);
const loadedLogs = await logger.loadLogsFromFile(historicalDate);
expect(loadedLogs).toHaveLength(2);
expect(loadedLogs[0].tag).toBe('network');
expect(loadedLogs[0].level).toBe(LogLevel.WARN);
expect(loadedLogs[1].message).toBe('数据库锁死');
});
it('应当能自动清理超过指定保留天数(如 7 天)的历史日志文件', async () => {
const logger = createLogger();
logger.consoleOutput = false;
const backend = new MemoryLogBackend();
// 假设今天 2026-07-22注入 10 天前与 2 天前的日志文件
await backend.appendLog('2026-07-01', 'old logs\n'); // 超过 7 天
await backend.appendLog('2026-07-21', 'recent logs\n'); // 保留
await logger.initFileBackend(backend, { maxDays: 7 });
const files = await logger.getLogFiles();
const dates = files.map(f => f.date);
expect(dates).not.toContain('2026-07-01');
expect(dates).toContain('2026-07-21');
});
it('应当能格式化导出人类可读文本字符串', () => {
const logger = createLogger();
logger.consoleOutput = false;
logger.info('billPipeline', '接收到微信账单', { amount: 15.5 });
logger.warn('dedup', '发现重复交易', { id: 'tx-123' });
const exportText = logger.exportAsFormattedText();
expect(exportText).toContain('[INFO] [billPipeline] 接收到微信账单');
expect(exportText).toContain('[WARN] [dedup] 发现重复交易');
expect(exportText).toContain('"amount":15.5');
});
it('query 方法应当支持按关键词过滤日志', () => {
const logger = createLogger();
logger.consoleOutput = false;
logger.info('tagA', '普通记账事件');
logger.error('tagB', '支付接口网络超时', { status: 504 });
const result = logger.query({ keyword: '超时' });
expect(result).toHaveLength(1);
expect(result[0].tag).toBe('tagB');
});
it('clearAllLogs 应当同时清空内存与磁盘中的全部日志', async () => {
const logger = createLogger();
logger.consoleOutput = false;
const backend = new MemoryLogBackend();
await logger.initFileBackend(backend);
logger.info('tag', '一条日志');
await logger.flushQueue();
expect(logger.getLogs()).toHaveLength(1);
expect((await backend.listLogFiles()).length).toBeGreaterThan(0);
await logger.clearAllLogs();
expect(logger.getLogs()).toHaveLength(0);
expect((await backend.listLogFiles()).length).toBe(0);
});
it('getTodayLogs 应当能正确合并磁盘上的历史 session 日志与当前内存中的新日志(自动去重)', async () => {
const logger = createLogger();
logger.consoleOutput = false;
const backend = new MemoryLogBackend();
await logger.initFileBackend(backend);
const todayStr = new Date().toISOString().slice(0, 10);
const earlierSessionLog = JSON.stringify({
timestamp: `${todayStr}T08:00:00.000Z`,
level: LogLevel.INFO,
tag: 'layout',
message: '早前 Session 启动日志',
});
await backend.appendLog(todayStr, `${earlierSessionLog}\n`);
// 当前 Session 新记录的内存日志
logger.info('billPipeline', '新 Session 产生的日志');
const todayLogs = await logger.getTodayLogs();
expect(todayLogs.some(e => e.message === '早前 Session 启动日志')).toBe(true);
expect(todayLogs.some(e => e.message === '新 Session 产生的日志')).toBe(true);
});
});

View File

@ -290,7 +290,7 @@ describe('OcrProcessor 分层处理', () => {
id: 'ai-vision', occurredAt: '2026-07-13', amount: '99', currency: 'CNY', direction: 'expense', id: 'ai-vision', occurredAt: '2026-07-13', amount: '99', currency: 'CNY', direction: 'expense',
counterparty: 'AI识别', memo: '', raw: {}, counterparty: 'AI识别', memo: '', raw: {},
}); });
const processor = new OcrProcessor(engine, aiProvider, { aiVisionEnabled: true, landscapeDnd: false }); const processor = new OcrProcessor(engine, aiProvider, { layer1Enabled: true, layer2Enabled: true, layer3Enabled: true, landscapeDnd: false });
const result = await processor.process('img4'); const result = await processor.process('img4');
expect(result.layer).toBe('layer3-ai'); expect(result.layer).toBe('layer3-ai');
expect(result.event?.amount).toBe('99'); expect(result.event?.amount).toBe('99');
@ -298,7 +298,7 @@ describe('OcrProcessor 分层处理', () => {
it('横屏免打扰跳过', async () => { it('横屏免打扰跳过', async () => {
const engine = new MockOcrEngine(); const engine = new MockOcrEngine();
const processor = new OcrProcessor(engine, undefined, { aiVisionEnabled: false, landscapeDnd: true }); const processor = new OcrProcessor(engine, undefined, { layer1Enabled: true, layer2Enabled: true, layer3Enabled: false, landscapeDnd: true });
const result = await processor.process('img5', undefined, true); const result = await processor.process('img5', undefined, true);
expect(result.skipped).toBe('landscape-dnd'); expect(result.skipped).toBe('landscape-dnd');
}); });