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

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 不覆盖,尊重用户选择)。
**模板适配决策**(已通读 7 页源码):
- budget/rules/remark-template → 直套T1
- category → `headerContent` 放支出/收入 chips新增按当前类型T2
- recurring → 卡片保留显式编辑/删除按钮(用 handlers.openEdit/confirmDelete不用长按T2
@ -19,6 +20,7 @@
### Task 1: 管理页模板化批次 1budget / rules / remark-template
**Files:**
- Modify: `src/app/budget/index.tsx`、`src/app/rules/index.tsx`、`src/app/remark-template/index.tsx`
- [ ] **Step 1: 三页套 ManagementScreen**(参照试点 `src/app/tag/index.tsx` 与模板 `src/components/ManagementScreen.tsx`
@ -26,6 +28,7 @@
通用映射:页面 state 消失modal/editing 由模板持有);手写 header/addBtn/Alert/FormModal 全删;`footer` 传 `common.clickEditLongDelete` 提示;`deleteConfirmTitle` 传各页 `t('xxx.deleteTitle')`
**budget/index.tsx**
- `items={budgets}`、`keyExtractor={b => b.id}`、`addLabel={t('budget.add')}`、`emptyText={t('budget.empty')}`
- renderItem保留现有 Card + 进度条结构Pressable onPress={openEdit} onLongPress={confirmDelete} 包裹)。`today` 改用 `toDateString(new Date())`(修掉 `toISOString().slice(0,10)` 的 UTC 偏移)
- formFields5 字段name/amount decimal-pad/period/categoryAccount/startDatedefaultValue 从 editing 取
@ -33,12 +36,14 @@
- onDelete`removeBudget(budget.id)`deleteConfirmText`t('budget.deleteConfirm', { name: budget.name })`
**rules/index.tsx**
- `items={[...rules].sort((a, b) => b.priority - a.priority)}`
- renderItem保留规则行narration/P 徽标/条件/目标账户+命中次数);**删除 `fontFamily: theme.typography.caption.fontFamily`**(审计项)
- formFields7 字段priority numeric/counterpartyContains/memoContains/sourceAccount/categoryAccount/narration/tags
- onSubmit保留现有 Rule 组装逻辑addRule `{...rule, id: generateId('rule'), hits: 0}` / updateRule
**remark-template/index.tsx**
- 2 字段name/templaterenderItem 的模板文本 `fontFamily: 'monospace'``fontVariant: ['tabular-nums']`(审计项)
- 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
**Files:**
- Modify: `src/app/category/index.tsx`、`src/app/recurring/index.tsx`、`src/app/credit-card/index.tsx`
- [ ] **Step 1: category/index.tsx — headerContent 类型 chips**
@ -87,6 +93,7 @@ Run: `grep -ln "FormModal\|arrow-back" src/app/category/index.tsx src/app/recurr
### Task 3: ScreenHeader 统一account + settings 三页)
**Files:**
- Modify: `src/app/account/index.tsx`、`src/app/settings/sync.tsx`、`src/app/settings/ai.tsx`、`src/app/settings/preferences.tsx`
- [ ] **Step 1: account/index.tsx**
@ -107,6 +114,7 @@ Run: `grep -rn "arrow-back" src/app/ | grep -v ScreenHeader` → 无输出
### Task 4: 图表与杂项清理fontFamily / emoji / 星期头 i18n
**Files:**
- Modify: `src/components/charts/CategoryPie.tsx`、`src/components/charts/TrendLine.tsx`、`src/components/charts/NetWorthChart.tsx`、`src/components/CalendarView.tsx`、`src/components/charts/CalendarHeatmap.tsx`、`src/components/DatePickerField.tsx`、`src/components/NumpadKeyboard.tsx`、`src/components/NumpadSheet.tsx`、`src/ai/monthlySummary.ts`、`src/app/ai/chat.tsx`、`src/app/transaction/[id].tsx`、`src/app/_error.tsx`、`src/app/import/index.tsx`、`src/components/PostingEditor.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
- [ ] **Step 1: fontFamily 清零**
@ -142,6 +150,7 @@ Run: `grep -rn "fontFamily" src/ --include="*.tsx" | grep -v "_error.tsx"` →
### Task 5: 收尾(翻默认 + modal 守卫 + MonthlyReport 删除 + budgets 时区修复 + duplicate 增强)
**Files:**
- Modify: `src/store/settingsStore.ts`、`src/components/NumpadSheet.tsx`、`src/components/NumpadSheetHost.tsx`、`src/domain/budgets.ts`、`src/app/(tabs)/transactions.tsx`、`tests/budgets.test.ts`(或现有 budget 测试文件)
- Delete: `src/components/charts/MonthlyReport.tsx`
@ -207,6 +216,7 @@ Run: `npm run typecheck` → 无错误
Run: `npm run android`
走查清单(浅/暗双主题):
1. 6 个管理页(分类/预算/规则/周期/信用卡/备注模板新增、点按编辑、删除recurring 为显式按钮,其余长按)、空态文案
2. account 页:类型 Tab、开户、调余额、关户
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
import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
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.WritableNativeMap
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
/**
* 无障碍服务 RN 桥接模块plan.md3.6 无障碍服务JS 接线
@ -20,6 +22,10 @@ import com.facebook.react.bridge.ReadableArray
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
companion object {
private const val TAG = "AccessibilityBridge"
}
init {
ReactContextHolder.context = reactContext
}
@ -195,10 +201,11 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
categories: ReadableArray,
accounts: ReadableArray,
direction: String,
currency: String,
draftId: String,
promise: Promise
) {
val context = reactContext.currentActivity ?: BillingAccessibilityService.instance
val context = BillingAccessibilityService.instance ?: reactContext.currentActivity
if (context == null) {
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
return
@ -222,7 +229,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
android.os.Handler(android.os.Looper.getMainLooper()).post {
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()
promise.resolve(true)
} catch (e: Exception) {
@ -247,4 +254,71 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
}
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 {
val shouldShow = floatingBallEnabled
&& pkg != null
&& !filterPackage(pkg, "")
&& !filterPackage(pkg, topActivity ?: "")
&& !isLandscape()
if (shouldShow) {
@ -380,11 +380,12 @@ class BillingAccessibilityService : AccessibilityService() {
val pkg = topPackage ?: return
val activity = topActivity ?: ""
val sig = "$pkg|$activity"
val uiLabels = FloatingUiConfigStore.load(this).labels
if (pageSignatures.add(sig)) {
savePageSignatures()
Log.i(TAG, "已记住页面: $sig")
handler.post {
android.widget.Toast.makeText(this, "已记住页面签名:\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 {
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()
// 自身 App只放行 MainActivity避免悬浮球容器触发自毁式关闭
if (pkg == packageName) {
if (className.isEmpty()) return false
return !className.endsWith(".MainActivity")
}
// 桌面启动器(悬浮球在桌面无意义)

View File

@ -17,9 +17,11 @@ import android.widget.LinearLayout
import android.text.InputType
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.math.BigDecimal
/**
* 浮窗账单提示直接呈现高度优化支持三方向切换的修改入账面板
* P6 重设计颜色/文案走 FloatingUiConfigStore新增币种 chip金额校验改正则+BigDecimal
*/
class FloatingBillView(
private val context: Context,
@ -30,12 +32,27 @@ class FloatingBillView(
private val packageName: String,
private val categories: List<Map<String, String>> = emptyList(),
private val accounts: List<String> = emptyList(),
private val initialDirection: String = "expense"
private val initialDirection: String = "expense",
private val initialCurrency: String = "CNY"
) {
companion object {
private const val TAG = "FloatingBillView"
private val DEFAULT_CURRENCY_LIST = listOf("CNY", "USD", "HKD", "JPY", "EUR", "GBP")
}
// 从 JS 下发的配置读取颜色与文案(缺失字段回退默认值,保证旧 JS 行为不变)
private val config = FloatingUiConfigStore.load(context)
// 显示币种列表;若 initialCurrency 不在默认列表中则临时插入首位
private val currencyList: List<String> = run {
val list = DEFAULT_CURRENCY_LIST.toMutableList()
if (!list.contains(initialCurrency)) {
list.add(0, initialCurrency)
}
list
}
private var currentCurrency: String = initialCurrency
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private var view: View? = null
private val handler = Handler(Looper.getMainLooper())
@ -49,6 +66,20 @@ class FloatingBillView(
private var accountContainer: LinearLayout? = null
private var saveBtn: Button? = null
// 预解析常用颜色parseColorOr 异常时回退默认值)
private val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
private val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
private val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
private val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
private val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
private val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
private val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
private val colorIncome = FloatingUiConfigStore.parseColorOr(config.colors.income, "#FF10B981")
private val colorExpense = FloatingUiConfigStore.parseColorOr(config.colors.expense, "#FFE11D48")
private val colorTransfer = FloatingUiConfigStore.parseColorOr(config.colors.transfer, "#FF10B981")
// 容器边框直接使用 config border不再从 accent 派生 alpha
private fun dp(value: Float): Int {
val density = context.resources.displayMetrics.density
return (value * density).toInt()
@ -74,29 +105,34 @@ class FloatingBillView(
currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense"
// 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部
val overlayType = if (context is android.accessibilityservice.AccessibilityService) {
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY
} else {
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
}
val windowParams = WindowManager.LayoutParams(
dp(310f),
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
overlayType,
0, // 0 标志代表可获取焦点
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
y = dp(12f) // 尽量靠下以留出上方账单对照
y = dp(48f) // 留出底部导航栏空间 + 按钮/键盘安全
}
// 2. 使用 55% 透明度 OLED 磨砂效果背景,发光靛蓝细边框
// 2. 实色卡片背景(不再半透明磨砂),圆角 16dp1dp 描边
val containerBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(14f).toFloat()
setColor(0x8C050506.toInt()) // 55% 透明度 OLED 黑色
setStroke(dp(1.2f), 0x995E6AD2.toInt()) // 60% 透明度靛蓝发光边框
cornerRadius = dp(16f).toFloat()
setColor(colorCardBg)
setStroke(dp(1f), colorBorder) // 使用 config border 颜色
}
val container = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
background = containerBg
setPadding(dp(12f), dp(8f), dp(12f), dp(8f))
setPadding(dp(16f), dp(16f), dp(16f), dp(16f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
@ -114,9 +150,9 @@ class FloatingBillView(
}
val titleText = TextView(context).apply {
text = "调整交易草稿"
textSize = 12f
setTextColor(0xFF98A2FF.toInt())
text = config.labels.billTitle
textSize = 13f
setTextColor(colorFgPrimary)
paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
@ -126,12 +162,12 @@ class FloatingBillView(
val segmentContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
background = GradientDrawable().apply {
cornerRadius = dp(4f).toFloat()
setColor(0x20FFFFFF.toInt()) // 12% white opacity
cornerRadius = dp(6f).toFloat()
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 tabViews = mutableListOf<TextView>()
@ -139,11 +175,11 @@ class FloatingBillView(
for (i in tabViews.indices) {
val active = tabDirections[i] == currentDirection
tabViews[i].apply {
setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
setTextColor(if (active) colorAccentFg else colorFgSecondary)
background = if (active) {
GradientDrawable().apply {
cornerRadius = dp(4f).toFloat()
setColor(0xFF5E6AD2.toInt()) // Indigo highlight
cornerRadius = dp(6f).toFloat()
setColor(colorAccent)
}
} else {
null
@ -173,40 +209,75 @@ class FloatingBillView(
headerLayout.addView(segmentContainer)
container.addView(headerLayout)
// 金额编辑
// 金额行:币种 chip+ 金额输入框(右)
val amountLabel = TextView(context).apply {
text = "金额"
textSize = 9f
text = config.labels.amountLabel
textSize = 10f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setPadding(0, dp(4f), 0, dp(1f))
setTextColor(colorFgSecondary)
setPadding(0, dp(8f), 0, dp(1f))
}
container.addView(amountLabel)
val amountInput = EditText(context).apply {
textSize = 13f
setTextColor(0xFFFFFFFF.toInt())
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
val amountRow = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
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 {
text = "交易对手"
textSize = 9f
text = config.labels.payeeLabel
textSize = 10f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(1f))
}
container.addView(merchantLabel)
@ -214,56 +285,57 @@ class FloatingBillView(
val merchantInput = EditText(context).apply {
setText(merchant)
textSize = 12f
setTextColor(0xFFFFFFFF.toInt())
setTextColor(colorFgPrimary)
setHintTextColor(colorFgSecondary)
background = GradientDrawable().apply {
setColor(0x4012131A.toInt())
cornerRadius = dp(6f).toFloat()
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
cornerRadius = dp(10f).toFloat()
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(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
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 {
text = "描述/备注"
textSize = 9f
text = config.labels.narrationLabel
textSize = 10f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(1f))
}
container.addView(narrationLabel)
val narrationInput = EditText(context).apply {
hint = "输入交易叙述"
setHintTextColor(0xFF6B7280.toInt())
hint = config.labels.narrationHint
setHintTextColor(colorFgSecondary)
textSize = 12f
setTextColor(0xFFFFFFFF.toInt())
setTextColor(colorFgPrimary)
background = GradientDrawable().apply {
setColor(0x4012131A.toInt())
cornerRadius = dp(6f).toFloat()
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
cornerRadius = dp(10f).toFloat()
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(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
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 分类/转入选择
categoryLabel = TextView(context).apply {
text = "交易分类"
textSize = 9f
text = config.labels.categoryExpense
textSize = 10f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(2f))
}
container.addView(categoryLabel)
@ -281,14 +353,14 @@ class FloatingBillView(
)
}
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 资金出入账户选择
accountLabel = TextView(context).apply {
text = "资金来源账户"
textSize = 9f
text = config.labels.accountExpense
textSize = 10f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(2f))
}
container.addView(accountLabel)
@ -306,7 +378,7 @@ class FloatingBillView(
)
}
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 {
@ -320,16 +392,16 @@ class FloatingBillView(
// 1) 打开应用按钮
val openAppBtn = Button(context).apply {
text = "打开应用"
setTextColor(0xFFD1D5DB.toInt())
text = config.labels.openApp
setTextColor(colorFgPrimary)
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat()
setColor(0xFF1F2937.toInt())
setStroke(dp(1f), 0xFF374151.toInt())
cornerRadius = dp(12f).toFloat()
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
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 {
val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim()
@ -342,18 +414,17 @@ class FloatingBillView(
}
// 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 {
text = "忽略"
setTextColor(0xFFD1D5DB.toInt())
background = cancelBg
text = config.labels.dismiss
setTextColor(colorFgPrimary)
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener {
sendCancelEvent()
dismiss()
@ -361,18 +432,17 @@ class FloatingBillView(
}
// 3) 确认入账按钮
val saveBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat()
setColor(0xFF5E6AD2.toInt())
}
saveBtn = Button(context).apply {
text = "确认入账"
setTextColor(0xFFFFFFFF.toInt())
background = saveBg
text = config.labels.confirm
setTextColor(colorAccentFg)
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorAccent)
}
textSize = 11f
paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1.3f)
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1.3f)
setOnClickListener {
val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim()
@ -388,21 +458,23 @@ class FloatingBillView(
btnContainer.addView(saveBtn)
container.addView(btnContainer)
// 构建金额安全校验
// 金额安全校验BigDecimal + 正则)
val amountWatcher = object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: android.text.Editable?) {
try {
val text = s?.toString()?.trim() ?: ""
val value = text.toDoubleOrNull()
val isValid = value != null && !value.isNaN() && !value.isInfinite() && value > 0.0
val pattern = Regex("^\\d+(\\.\\d{1,2})?$")
val value = text.toBigDecimalOrNull()
val isValid = pattern.matches(text) && value != null && value > BigDecimal.ZERO
saveBtn?.isEnabled = isValid
saveBtn?.background = GradientDrawable().apply {
cornerRadius = dp(8f).toFloat()
setColor(if (isValid) 0xFF5E6AD2.toInt() else 0xFF374151.toInt())
shape = GradientDrawable.RECTANGLE
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) {
Log.e(TAG, "金额校验异常", e)
saveBtn?.isEnabled = false
@ -419,7 +491,7 @@ class FloatingBillView(
view = container
windowManager.addView(view, windowParams)
Log.i(TAG, "悬浮修改记账面板已显示: ¥$amount")
Log.i(TAG, "悬浮修改记账面板已显示: $currentCurrency $amount")
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
@ -438,11 +510,11 @@ class FloatingBillView(
false
}
// 15 秒无操作自动消失(如果用户没有交互的话)
// 30 秒无操作自动消失(如果用户没有交互的话)
handler.postDelayed({
sendCancelEvent()
dismiss()
}, 15000L)
}, 30000L)
} catch (e: Exception) {
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
@ -459,7 +531,7 @@ class FloatingBillView(
// === 1. 绘制第一行 (分类 / 转入) ===
if (currentDirection == "expense") {
categoryLabel?.text = "交易分类"
categoryLabel?.text = config.labels.categoryExpense
val filteredCats = categories.filter { it["type"] == "expense" }
for (cat in filteredCats) {
val catAccount = cat["account"] ?: ""
@ -470,14 +542,14 @@ class FloatingBillView(
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
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 ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
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") {
categoryLabel?.text = "收入分类"
categoryLabel?.text = config.labels.categoryIncome
val filteredCats = categories.filter { it["type"] == "income" }
for (cat in filteredCats) {
val catAccount = cat["account"] ?: ""
@ -514,14 +586,14 @@ class FloatingBillView(
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
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 ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
} else {
// transfer
categoryLabel?.text = "转入账户"
// transfer — 第一行 = 转入账户,选中色 = transfer
categoryLabel?.text = config.labels.transferTarget
for (acct in accounts) {
val shortName = acct.split(":").lastOrNull() ?: acct
val chip = createChipView(shortName)
@ -557,14 +629,14 @@ class FloatingBillView(
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF10B981.toInt())
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorTransfer)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
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 ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF10B981.toInt())
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorTransfer)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
// === 2. 绘制第二行 (资金账户) ===
if (currentDirection == "expense") {
accountLabel?.text = "资金来源"
accountLabel?.text = config.labels.accountExpense
} else if (currentDirection == "income") {
accountLabel?.text = "存入账户"
accountLabel?.text = config.labels.accountIncome
} 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) {
val shortName = acct.split(":").lastOrNull() ?: acct
val chip = createChipView(shortName)
@ -607,15 +682,15 @@ class FloatingBillView(
for (pair in accountChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
cornerRadius = dp(14f).toFloat()
if (active) {
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
setColor(accountSelectedColor)
} else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
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) {
val act = p.first == selectedCategoryAccount
p.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (act) setColor(0xFF10B981.toInt())
cornerRadius = dp(14f).toFloat()
if (act) setColor(colorTransfer)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
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 ->
val active = pair.first == selectedSourceAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
cornerRadius = dp(14f).toFloat()
if (active) {
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
setColor(accountSelectedColor)
} else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
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("direction", currentDirection)
putString("packageName", packageName)
putString("currency", currentCurrency)
putBoolean("confirmed", true)
putBoolean("editRequested", false)
putBoolean("isManualEdit", true)
@ -703,6 +779,7 @@ class FloatingBillView(
putString("time", time)
putString("direction", currentDirection)
putString("packageName", packageName)
putString("currency", currentCurrency)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
@ -736,4 +813,13 @@ class FloatingBillView(
} catch (_: Exception) {}
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 {
private const val TAG = "FloatingHelper"
private const val PREFS_NAME = "billing_accessibility_prefs"
private const val KEY_X = "floating_ball_x"
private const val KEY_Y = "floating_ball_y"
private var lastX = 0
private var lastY = 400
}
@ -41,7 +44,7 @@ class FloatingHelper(
private val params = WindowManager.LayoutParams(
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,
PixelFormat.TRANSLUCENT
).apply {
@ -57,6 +60,25 @@ class FloatingHelper(
try {
val context = service
// 加载浮层 UI 配置(颜色与文案均从 JS 下发的 config 读取,未下发时回退默认值)
val config = FloatingUiConfigStore.load(service)
// 预解析颜色
val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
// 位置持久化:从 SharedPreferences 读取,回退到 companion 缓存
val prefs = service.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
params.x = prefs.getInt(KEY_X, lastX)
params.y = prefs.getInt(KEY_Y, lastY)
lastX = params.x
lastY = params.y
val density = service.resources.displayMetrics.density
fun dp(value: Float) = (value * density).toInt()
@ -79,25 +101,31 @@ class FloatingHelper(
layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f))
}
// 边缘指示竖线accent 色取 RGB + 固定 0xB0 alpha保持 70% 透明)
val indicatorColor = (colorAccent and 0x00FFFFFF) or 0xB0000000.toInt()
val indicatorView = View(context).apply {
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(1.5f).toFloat() // 高度圆润
setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框
cornerRadius = dp(1.5f).toFloat()
setColor(indicatorColor)
}
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)
bubbleView = bubbleLayout
// 3. 创建展开菜单 (垂直布局,高紧凑度设计)
// 菜单背景:实色 cardBgper plan §T4
val menuBgColor = colorCardBg
// 菜单描边border 色 + 半透明 alpha
val menuBorderColor = (colorBorder and 0x00FFFFFF) or 0x22000000.toInt()
val menuBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(10f).toFloat()
setColor(0xA611131E.toInt()) // 65% OLED high-transparency black
setStroke(dp(0.8f), 0x22FFFFFF.toInt()) // subtle 13% white border
setColor(menuBgColor)
setStroke(dp(0.8f), menuBorderColor)
}
menuView = LinearLayout(context).apply {
@ -107,30 +135,37 @@ class FloatingHelper(
setPadding(dp(4f), dp(4f), dp(4f), dp(4f))
visibility = View.GONE
layoutParams = LinearLayout.LayoutParams(
dp(96f), // ultra-compact width: 96dp
dp(96f),
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = dp(4f) // spacing with indicator line
leftMargin = dp(4f)
}
}
// Vector icons
val sizePx = dp(14f)
val strokePx = dp(1.4f).toFloat()
val ocrIcon = ScanIconDrawable(0xFF818CF8.toInt(), strokePx, 0xFFF87171.toInt(), sizePx)
val pinIcon = PinIconDrawable(0xFF34D399.toInt(), strokePx, sizePx)
// ScanIconDrawableaccentFg 色(在 accent 底按钮上可见laserColor 保留红色语义
val ocrIcon = ScanIconDrawable(colorAccentFg, strokePx, 0xFFF87171.toInt(), sizePx)
// PinIconDrawablefgSecondary 色
val pinIcon = PinIconDrawable(colorFgSecondary, strokePx, sizePx)
// Button 1: 识别账单主操作accent 底 accentFg 字)
// 背景常时accent 色取 RGB + 固定 0x1F alpha约 12%
val btnOcrBgNormal = (colorAccent and 0x00FFFFFF) or 0x1F000000.toInt()
// 按下加深accent 色取 RGB + 固定 0x40 alpha约 25%
val btnOcrBgPressed = (colorAccent and 0x00FFFFFF) or 0x40000000.toInt()
// Button 1: "识别账单"
val btnOcr = TextView(context).apply {
text = "识别账单"
setTextColor(0xFFFFFFFF.toInt())
text = config.labels.ballOcr
setTextColor(colorAccentFg)
textSize = 11f
paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x1F5E6AD2.toInt()) // translucent indigo background
setColor(btnOcrBgNormal)
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
@ -145,13 +180,13 @@ class FloatingHelper(
MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x405E6AD2.toInt())
setColor(btnOcrBgPressed)
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x1F5E6AD2.toInt())
setColor(btnOcrBgNormal)
}
if (event.action == MotionEvent.ACTION_UP) {
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 {
text = "记住此页"
setTextColor(0xFFE5E7EB.toInt())
text = config.labels.ballRemember
setTextColor(colorFgPrimary)
textSize = 11f
paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x15FFFFFF.toInt()) // subtle translucent gray
setColor(btnRememberBgNormal)
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
@ -188,13 +228,13 @@ class FloatingHelper(
MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x30FFFFFF.toInt())
setColor(btnRememberBgPressed)
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x15FFFFFF.toInt())
setColor(btnRememberBgNormal)
}
if (event.action == MotionEvent.ACTION_UP) {
view.performClick()
@ -206,9 +246,9 @@ class FloatingHelper(
setOnClickListener {
try {
service.rememberCurrentPage()
FloatingTip(service, "📌 已将当前页面加入识别白名单!", FloatingTip.TipPosition.TOP, 2500L).show()
FloatingTip(service, config.labels.rememberSuccess, FloatingTip.TipPosition.TOP, 2500L).show()
} 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()
}
@ -257,7 +297,7 @@ class FloatingHelper(
} else {
// 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘)
if (params.x < service.resources.displayMetrics.widthPixels / 2) {
params.x = -dp(1.5f) // 指示器 3dp 宽,中心对齐边缘
params.x = -dp(1.5f)
} else {
params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f)
}
@ -265,6 +305,12 @@ class FloatingHelper(
lastX = params.x
lastY = params.y
// 位置持久化:吸附后写入 SharedPreferences
prefs.edit()
.putInt(KEY_X, params.x)
.putInt(KEY_Y, params.y)
.apply()
}
true
}

View File

@ -6,14 +6,10 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* 浮窗提示plan.md3.8 浮窗账单提示
@ -47,6 +43,10 @@ class FloatingTip(
/** 显示浮窗提示。 */
fun show() {
try {
val config = FloatingUiConfigStore.load(context)
val bgColor = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF000000") or 0xFF000000.toInt() // 强制不透明化
val textColor = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
@ -65,42 +65,20 @@ class FloatingTip(
val container = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
setBackgroundColor(0xF0333333.toInt())
setBackgroundColor(bgColor)
setPadding(32, 16, 32, 16)
}
val text = TextView(context).apply {
text = message
textSize = 13f
setTextColor(0xFFFFFFFF.toInt())
}
// 倒计时进度条
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)
setTextColor(textColor)
}
container.addView(text)
container.addView(progress)
view = container
windowManager.addView(view, layoutParams)
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)
} catch (e: Exception) {
Log.e(TAG, "FloatingTip 显示失败: ${e.message}")
@ -121,7 +99,7 @@ class FloatingTip(
*/
class RepeatToast(private val context: Context, private val message: String) {
fun show() {
val tip = FloatingTip(context, "$message", FloatingTip.TipPosition.TOP, 2000L)
val tip = FloatingTip(context, message, FloatingTip.TipPosition.TOP, 2000L)
tip.show()
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;
// 2a. 注入 importAccessibilityBridgePackage
if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) {
content = content.replace(/^import\s+[\w.]+\.AccessibilityBridgePackage\s*$/gm, '');
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
);
}
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
if (!content.includes('add(AccessibilityBridgePackage())')) {
@ -166,7 +165,7 @@ function withAccessibilityService(config) {
$: {
'android:name': `${PACKAGE}.BillingAccessibilityService`,
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
'android:label': '账单识别',
'android:label': '浮记-账单识别',
'android:exported': 'false',
},
'intent-filter': [{

View File

@ -9,7 +9,7 @@
**PP-OCRv6 small**det 2.5M 参数 + rec 5.3M 参数,精度显著优于 v5 mobile
| 指标 | PP-OCRv5 mobile | PP-OCRv6 small |
|------|----------------|----------------|
| ------------- | --------------- | -------------- |
| det Hmean | 75.2% | 84.1% |
| rec W-Avg | 73.7% | 81.3% |
| rec ONNX 大小 | ~17 MB | ~21 MB |
@ -56,7 +56,7 @@ curl -L -o ppocrv6_dict.txt \
## 性能配置(参考 AutoAccounting OcrProcessor.kt
| 优化项 | 配置 |
|--------|------|
| -------- | -------------------------------------- |
| 引擎 | ONNX Runtime Android |
| 执行器 | CPU兼容性最稳部分设备 GPU 会崩溃) |
| 线程 | intraOp=2 / interOp=2 |

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
@ -85,7 +86,7 @@ export default function TransactionsScreen() {
// cost/price 不带:复制是新建交易,丢 cost 属可接受简化(含 cost 的交易可从编辑入口进高级模式)
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 移除)
@ -137,7 +138,7 @@ export default function TransactionsScreen() {
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
keyExtractor={(item, index) => `${item.id}-${index}`}
renderItem={({ item: tx }) => (
<SwipeableTransactionCard
transaction={tx}

View File

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

View File

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

View File

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

View File

@ -17,6 +17,7 @@ import { useRouter } from 'expo-router';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai';
import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant';
import { ScreenHeader } from '../../components/ScreenHeader';
@ -123,7 +124,7 @@ export default function AIChatScreen() {
return (
<Pressable
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 }]}
>
<Text style={[theme.typography.bodySmall, { color: dirColor, fontWeight: '700' }]}>

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import type { DuplicateDetail } from '../../domain/dedup';
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
@ -143,7 +144,7 @@ export default function ImportScreen() {
// 6. 自动执行 Pipeline用户无需手动查找运行按钮
if (ledger) {
setStatus(t('importFlow.pipelineRunning'));
processEvents(ledger, []).then(result => {
processEvents(ledger, ledger.transactions || []).then(result => {
setManualDuplicates(result.duplicates);
setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length }));
}).catch(e => {
@ -158,7 +159,7 @@ export default function ImportScreen() {
const runPipeline = () => {
if (!ledger) return;
processEvents(ledger, []).then(result => {
processEvents(ledger, ledger.transactions || []).then(result => {
setManualDuplicates(result.duplicates);
setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount }));
}).catch(e => setStatus(String(e)));
@ -269,11 +270,59 @@ export default function ImportScreen() {
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
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));
setStatus(t('importFlow.forcedImport'));
Alert.alert('记账成功', `已成功将「${event.counterparty || event.memo || '交易'}」单独记入账本`);
} catch (err) {
setStatus(String(err));
Alert.alert('记账失败', String(err));
}
};
const handleLinkDuplicate = async (event: ImportedEvent) => {
if (!ledger) return;
try {
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const classification = classifyWithCategories(event, rules, categories, ledger);
// 寻找对应的历史匹配交易
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === event.id);
const matchedTx = detail?.matchedWith?.rawTransaction;
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 && (
<View style={styles.duplicatesList}>
{manualDuplicates.map((item) => {
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === item.id);
const matchedTx = detail?.matchedWith?.rawTransaction;
const reason = detail?.reason || t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') });
const result = {
isDuplicate: true,
confidence: 'medium' as const,
reason: t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') })
confidence: (detail?.confidence ?? 'medium') as any,
reason,
};
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 }]}>
{item.memo || t('importFlow.noDesc')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })}
</Text>
{matchedTx && (
<View style={{ backgroundColor: `${theme.colors.accent}12`, padding: 8, borderRadius: 8, marginVertical: 4 }}>
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontWeight: '700' }]}>
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, marginTop: 2 }]}>
{matchedTx.date} {matchedTx.payee ? matchedTx.payee + ' - ' : ''}{matchedTx.narration} ({matchedTx.postings[0]?.amount} {matchedTx.postings[0]?.currency})
</Text>
</View>
)}
<DedupBanner
result={result}
onAccept={() => handleAcceptDuplicate(item)}
onReject={() => handleRejectDuplicate(item)}
onLink={() => handleLinkDuplicate(item)}
/>
</Card>
);

View File

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

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

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

View File

@ -64,7 +64,7 @@ export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps)
{state.routes.slice(0, 2).map(renderTab)}
<View style={styles.plusSlot}>
<Pressable
onPress={() => { if (numpadGlobalEntry) openNumpad(); else router.navigate('/transaction/new'); }}
onPress={() => openNumpad()}
style={[styles.plus, {
backgroundColor: theme.colors.accent,
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 { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
interface CardProps {
title?: string;
children: React.ReactNode;
onPress?: () => void;
onLongPress?: () => void;
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 scale = useRef(new Animated.Value(1)).current;
const [isCollapsed, setIsCollapsed] = useState(collapsible ? (defaultCollapsed ?? false) : false);
const handlePressIn = useCallback(() => {
Animated.spring(scale, {
@ -41,10 +48,41 @@ export function Card({ title, children, onPress, style }: CardProps) {
...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 (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={style}
@ -100,4 +138,6 @@ export function Card({ title, children, onPress, style }: CardProps) {
const styles = StyleSheet.create({
card: { gap: 8 },
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 { Pressable, StyleSheet, Text, View } from 'react-native';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import { getCategoryColor } from '../theme/palette';
import type { Category } from '../domain/categories';
import { CategoryIcon } from './CategoryIcon';
import { Touchable } from './Touchable';
interface CategoryPickerProps {
categories: Category[];
@ -22,15 +23,14 @@ export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPic
const color = getCategoryColor(cat.id);
return (
<Pressable
<Touchable
key={cat.id}
onPress={() => onSelect(cat)}
style={({ pressed }) => [
style={[
styles.item,
{
backgroundColor: active ? color + '15' : theme.colors.bgTertiary,
borderColor: active ? color : theme.colors.border,
opacity: pressed ? 0.75 : 1,
}
]}
>
@ -47,7 +47,7 @@ export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPic
>
{cat.name}
</Text>
</Pressable>
</Touchable>
);
})}
</View>

View File

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

View File

@ -1,4 +1,4 @@
/**
/**
* plan.md CRUD
*
* , + /
@ -24,6 +24,13 @@ import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n';
import { Button } from './Button';
import { Touchable } from './Touchable';
export interface FormOption {
label: string; // 中文名称
value: string; // 英文前缀 (如 Assets)
subLabel?: string;
}
export interface FormField {
/** 字段 key,对应 values 对象的属性名。 */
@ -40,6 +47,14 @@ export interface FormField {
multiline?: boolean;
/** 密码输入(遮蔽文本)。 */
secureTextEntry?: boolean;
/** 控件类型:'text' (默认) | 'select' 单选卡片组 | 'dropdown' 下拉选择框 */
type?: 'text' | 'select' | 'dropdown';
/** select / dropdown 类型的可选项。 */
options?: FormOption[];
/** 在同一行内布局时的占比弹性系数。 */
flex?: number;
/** 行号标识,相同 row 的字段组合在同一行中。 */
row?: number;
}
interface FormModalProps {
@ -50,15 +65,30 @@ interface FormModalProps {
onCancel: () => void;
/** 确认按钮文本(默认「确定」)。 */
confirmLabel?: string;
/** 字段值变更时的联动回调,返回新值覆盖。 */
onValuesChange?: (changedKey: string, newValue: string, prevValues: Record<string, string>) => Record<string, string> | void;
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
children?: React.ReactNode;
}
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel, children }: FormModalProps) {
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel, onValuesChange, children }: FormModalProps) {
const { theme } = useTheme();
const t = useT();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const [values, setValues] = useState<Record<string, string>>({});
const [activeDropdownKey, setActiveDropdownKey] = useState<string | null>(null);
const [focusedKey, setFocusedKey] = useState<string | null>(null);
const updateFieldValue = (key: string, val: string) => {
setValues(prev => {
const next = { ...prev, [key]: val };
if (onValuesChange) {
const overridden = onValuesChange(key, val, next);
return overridden ? { ...next, ...overridden } : next;
}
return next;
});
};
// 每次 visible 变为 true 时,用 fields 的 defaultValue 初始化
useEffect(() => {
@ -68,46 +98,206 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
init[f.key] = f.defaultValue ?? '';
}
setValues(init);
setActiveDropdownKey(null);
setFocusedKey(null);
}
}, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onCancel}>
<Pressable
style={[styles.sheet, {
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.xl,
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border,
}]}
onPress={(e) => e.stopPropagation()}
>
<View style={[styles.header, { borderBottomColor: theme.colors.divider }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{title}</Text>
<Pressable onPress={onCancel} hitSlop={8}>
<Ionicons name="close" size={20} color={theme.colors.fgSecondary} />
</Pressable>
</View>
// 将包含 flex 的连续字段组合成同行渲染组
const renderField = (f: FormField) => {
const isFocused = focusedKey === f.key || activeDropdownKey === f.key;
<ScrollView style={styles.body}>
{fields.map(f => (
<View key={f.key} style={styles.field}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4 }]}>
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) => setValues(prev => ({ ...prev, [f.key]: text }))}
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}
style={[
commonStyles.input,
f.flex ? { height: 44 } : undefined,
{
backgroundColor: isFocused ? theme.colors.bgPrimary : theme.colors.bgTertiary,
borderColor: isFocused ? theme.colors.accent : theme.colors.border,
borderWidth: isFocused ? 1.5 : StyleSheet.hairlineWidth,
},
]}
/>
</View>
))}
);
};
// 分组具有 flex 且相同 row 的连续字段
const fieldElements: React.ReactNode[] = [];
let flexGroup: FormField[] = [];
let currentGroupRow: number | undefined = undefined;
const flushFlexGroup = () => {
if (flexGroup.length > 0) {
fieldElements.push(
<View key={`row-${flexGroup[0].key}`} style={styles.rowFieldGroup}>
{flexGroup.map(renderField)}
</View>
);
flexGroup = [];
}
};
for (const f of fields) {
if (f.flex) {
const fieldRow = f.row ?? 1;
if (flexGroup.length > 0 && currentGroupRow !== fieldRow) {
flushFlexGroup();
}
currentGroupRow = fieldRow;
flexGroup.push(f);
} else {
flushFlexGroup();
currentGroupRow = undefined;
fieldElements.push(renderField(f));
}
}
flushFlexGroup();
const activeField = fields.find(f => f.key === activeDropdownKey);
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onCancel}>
<Pressable
style={[
styles.sheet,
theme.shadows.lg,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
},
]}
onPress={(e) => e.stopPropagation()}
>
{/* 顶部 Drag Handle */}
<View style={[styles.dragHandle, { backgroundColor: theme.colors.divider }]} />
<View style={styles.header}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{title}</Text>
<Touchable onPress={onCancel} hitSlop={8} style={[styles.closeCircleBtn, { backgroundColor: theme.colors.bgTertiary }]}>
<Ionicons name="close" size={16} color={theme.colors.fgSecondary} />
</Touchable>
</View>
<ScrollView style={styles.body} showsVerticalScrollIndicator={false}>
{fieldElements}
</ScrollView>
{children}
@ -118,6 +308,64 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
</View>
</Pressable>
</Pressable>
{/* 下拉菜单弹出 Modal */}
<Modal visible={activeDropdownKey !== null} transparent animationType="fade" onRequestClose={() => setActiveDropdownKey(null)}>
<Pressable style={[styles.menuOverlay, { backgroundColor: theme.colors.overlay }]} onPress={() => setActiveDropdownKey(null)}>
<Pressable style={[styles.dropdownMenu, theme.shadows.lg, { backgroundColor: theme.colors.bgSecondary, borderColor: theme.colors.border }]} onPress={e => e.stopPropagation()}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, letterSpacing: 0.5, fontWeight: '600' }]}>
{activeField?.label}
</Text>
{activeField?.options?.map(opt => {
const isSelected = (values[activeField.key] ?? '') === opt.value;
const hasColon = opt.value.includes(':');
const pathPrefix = hasColon ? opt.value.slice(0, opt.value.lastIndexOf(':')) : '';
const shortName = hasColon ? opt.value.slice(opt.value.lastIndexOf(':') + 1) : opt.label;
return (
<Touchable
key={opt.value}
style={[
styles.dropdownMenuItem,
{
backgroundColor: isSelected ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
},
]}
onPress={() => {
updateFieldValue(activeField.key, opt.value);
setActiveDropdownKey(null);
}}
>
<View style={{ flex: 1 }}>
{pathPrefix ? (
<Text style={[styles.dropdownValueText, { color: theme.colors.fgSecondary, fontSize: 10, lineHeight: 12 }]}>
{pathPrefix}
</Text>
) : null}
<Text
style={[
styles.dropdownLabelText,
{
color: isSelected ? theme.colors.accent : theme.colors.fgPrimary,
fontWeight: isSelected ? '700' : '600',
fontSize: 14,
lineHeight: 18,
},
]}
>
{shortName}
</Text>
</View>
{isSelected && (
<Ionicons name="checkmark-circle" size={18} color={theme.colors.accent} />
)}
</Touchable>
);
})}
</Pressable>
</Pressable>
</Modal>
</Modal>
);
}
@ -130,26 +378,113 @@ const styles = StyleSheet.create({
sheet: {
maxHeight: '80%',
padding: 20,
paddingTop: 12,
paddingBottom: 36,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
},
dragHandle: {
width: 36,
height: 4,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 16,
opacity: 0.8,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: 12,
borderBottomWidth: 1,
marginBottom: 12,
marginBottom: 16,
},
closeCircleBtn: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
body: {
maxHeight: 400,
},
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: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 8,
gap: 10,
marginTop: 12,
},
menuOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
dropdownMenu: {
width: '100%',
maxWidth: 320,
borderRadius: 20,
padding: 18,
borderWidth: StyleSheet.hairlineWidth,
gap: 10,
},
dropdownMenuItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
},
});

View File

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

View File

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

View File

@ -21,11 +21,13 @@ import { useLedgerStore } from '../store/ledgerStore';
import { useMetadataStore } from '../store/metadataStore';
import { useSettingsStore } from '../store/settingsStore';
import { TAG_COLORS } from '../theme/palette';
import { AccountCreateModal } from './AccountCreateModal';
import { BottomSheet } from './BottomSheet';
import { Button } from './Button';
import { CategoryIcon } from './CategoryIcon';
import { CategoryPicker } from './CategoryPicker';
import { DatePickerField } from './DatePickerField';
import { FormModal, type FormField } from './FormModal';
import { NumpadKeyboard } from './NumpadKeyboard';
import { PostingEditor } from './PostingEditor';
import { TagPicker } from './TagPicker';
@ -39,11 +41,12 @@ import { matchCategory } from '../domain/categories';
import type { Category } from '../domain/categories';
import type { Tag } from '../domain/tags';
import type { Posting } from '../domain/types';
import { logger } from '../utils/logger';
type Direction = 'expense' | 'income' | 'transfer';
interface NumpadSheetProps {
presentation: 'page' | 'modal';
presentation?: 'modal' | 'page';
editId?: string;
draftJson?: string;
autoOcr?: boolean;
@ -52,7 +55,7 @@ interface NumpadSheetProps {
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 commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
@ -61,6 +64,7 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
const ledger = useLedgerStore(s => s.ledger);
const editTransaction = useLedgerStore(s => s.editTransaction);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
const categories = useMetadataStore(s => s.categories);
const tags = useMetadataStore(s => s.tags);
const lastUsedAccount = useSettingsStore(s => s.lastUsedAccount);
@ -85,6 +89,42 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
const [isSubmitted, setIsSubmitted] = useState(false);
const [accountPicker, setAccountPicker] = useState<'source' | 'target' | null>(null);
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('');
@ -130,6 +170,8 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
return [...(pinned ? [pinned] : []), ...rest].slice(0, 3);
}, [availableCategories, lastUsedCategoryId]);
const parsedOriginalDraftRef = useRef<{ payee?: string; categoryAccount?: string; sourceAccount?: string; amount?: string; direction?: string } | null>(null);
// ===== 预填draftJson草稿 =====
useEffect(() => {
if (!draftJson) return;
@ -150,6 +192,13 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
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) {
setPostings(draft.postings.map((p: any) => ({
account: p.account, amount: p.amount, currency: p.currency,
@ -324,8 +373,7 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
};
const closeSoon = () => {
if (presentation === 'modal') onClose?.();
else setTimeout(() => router.back(), 600);
onClose?.();
};
// modal 工具行关闭按钮:脏数据时先确认(提交成功走 closeSoon 直关isSubmitted 已置位不会误拦)
@ -416,6 +464,29 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
setIsSubmitted(true);
closeSoon();
} 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({
date,
amount: amt,
@ -698,6 +769,15 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
{/* 全部账户 BottomSheet */}
<BottomSheet visible={accountPicker !== null} onClose={() => setAccountPicker(null)} title={t('numpad.allAccounts')}>
<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 => (
<Pressable
key={acct}
@ -714,6 +794,14 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
</ScrollView>
</BottomSheet>
{/* 快捷新建账户 AccountCreateModal (全应用 100% 统一复用) */}
<AccountCreateModal
visible={isQuickAddingAccount}
defaultType="Assets"
onConfirm={handleQuickAddAccount}
onCancel={() => setIsQuickAddingAccount(false)}
/>
{/* 全部分类 BottomSheet */}
<BottomSheet visible={categoryPickerOpen} onClose={() => setCategoryPickerOpen(false)} title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
<ScrollView style={styles.accountList}>

View File

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

View File

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

View File

@ -1,9 +1,10 @@
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 { compareDecimals } from './decimal';
import type { Category } from './categories';
import type { ImportedEvent, LedgerIndex, Rule, Transaction, TransactionDraft } from './types';
import { logger } from '../utils/logger';
/**
* AutoAccounting BillService.analyze + deduplicationMutex
@ -44,6 +45,8 @@ export interface PipelineResult {
drafts: ProcessedDraft[];
/** 被去重丢弃的事件。 */
duplicates: ImportedEvent[];
/** 详细的去重匹配信息。 */
duplicateDetails?: DuplicateDetail[];
/** 转账识别统计。 */
transferCount: number;
}
@ -98,25 +101,45 @@ export class BillPipeline {
return { drafts: [], duplicates: [], transferCount: 0 };
}
logger.info('billPipeline', `开始批次处理账单事件 (事件总数: ${events.length})`);
// 1. 转账识别(先于去重)
const { transfers, remaining: afterTransfer } = recognizeTransfers(
events,
ctx.transferConfig,
);
for (const t of transfers) {
logger.info('billPipeline', `[转账识别] 命中转账对 (置信度: ${t.confidence}): "${t.draft.narration}", 金额: ${t.draft.postings[1]?.amount}, 原因: ${t.reason}`);
}
// 2. 多通道去重(剩余事件之间)
const { accepted: afterBatchDedup, duplicates: batchDups } = batchDedup(
const { accepted: afterBatchDedup, duplicates: batchDups, duplicateDetails: batchDetails } = batchDedup(
afterTransfer,
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. 与历史交易去重(避免重复入账)
const { accepted: afterHistoryDedup, duplicates: historyDups } = dedupAgainstHistory(
const { accepted: afterHistoryDedup, duplicates: historyDups, duplicateDetails: historyDetails } = dedupAgainstHistory(
afterBatchDedup,
ctx.history,
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
const drafts: ProcessedDraft[] = [];
const transferDups: ImportedEvent[] = [];
@ -131,6 +154,7 @@ export class BillPipeline {
});
} else {
// 转账已在历史中,将两个源事件标记为重复
logger.info('billPipeline', `[转账历史去重] 转账草稿 "${transfer.draft.narration}" 已存在于历史账本中,标记丢弃`);
const leftEvt = events.find(e => e.id === transfer.leftEventId);
const rightEvt = events.find(e => e.id === transfer.rightEventId);
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 {
drafts,
duplicates: [...batchDups, ...historyDups, ...transferDups],
duplicateDetails: [...batchDetails, ...historyDetails],
transferCount: transfers.length,
};
}

View File

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

View File

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

View File

@ -1,4 +1,4 @@
/**
/**
* OCR plan.md3.1 +3.9 JS
*
* Layer 1: 规则匹配
@ -12,6 +12,7 @@ import { DEDUP_CACHE_MAX, IMAGE_HASH_TRUNCATE_LEN } from './constants';
import { matchOcrRule, parseOcrBill, checkIsDetailPage } from './ocr';
import { hash } from './ledger';
import type { ImportedEvent } from './types';
import { logger } from '../utils/logger';
/** OCR 引擎抽象(生产用原生 PP-OCRv5 Module测试用 mock。 */
export interface OcrEngine {
@ -38,8 +39,12 @@ export interface OcrProcessResult {
}
export interface OcrProcessorConfig {
/** 是否启用 Layer 3 AI默认 false需用户启用。 */
aiVisionEnabled: boolean;
/** 是否启用 Layer 1 规则匹配,默认 true。 */
layer1Enabled: boolean;
/** 是否启用 Layer 2 OCR 识别,默认 true。 */
layer2Enabled: boolean;
/** 是否启用 Layer 3 AI Vision默认 false。 */
layer3Enabled: boolean;
/** 横屏免打扰模式。 */
landscapeDnd: boolean;
}
@ -66,7 +71,7 @@ export class OcrProcessor {
constructor(
private readonly ocrEngine: OcrEngine,
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 {
const result = await this.doProcess(request.imageBase64, request.packageName, request.isLandscape);
request.resolve(result);
} catch {
} catch (e) {
logger.error('ocrProcessor', '处理队列包含未捕获异常', e);
request.resolve({ event: null, layer: 'none', skipped: 'non-bill' });
} finally {
this.processing = false;
@ -105,6 +111,8 @@ export class OcrProcessor {
packageName?: string,
isLandscape = false,
): Promise<OcrProcessResult> {
const startTime = Date.now();
// 横屏免打扰
if (this.config.landscapeDnd && isLandscape) {
return { event: null, layer: 'none', skipped: 'landscape-dnd' };
@ -119,8 +127,14 @@ export class OcrProcessor {
}
try {
// Layer 2: OCR 识别Layer 1 基于文本,需先 OCR
const ocrText = await this.ocrEngine.recognizeText(imageBase64);
let ocrText = '';
// L1 和 L2 都需要 OCR 文本:如果二者都关了,跳过 OCR 调用
if (this.config.layer1Enabled || this.config.layer2Enabled) {
const ocrStartTime = Date.now();
ocrText = await this.ocrEngine.recognizeText(imageBase64);
const ocrDuration = Date.now() - ocrStartTime;
logger.debug('ocrProcessor', `Native OCR 引擎识别完成 (耗时: ${ocrDuration}ms, 字符数: ${ocrText.length})`);
// OCR 成功后才标记已处理,避免异常时永久阻断重试
this.processedHashes.set(imageHash, Date.now());
@ -133,44 +147,71 @@ export class OcrProcessor {
this.processedHashes.delete(key);
}
}
if (!ocrText || !ocrText.trim()) {
// 无文本,尝试 Layer 3
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 规则匹配,
// 以避免"银行卡"等规则在详情页上误提取(例如误将"消费1次"提取为"-1元")。
// Layer 1: 规则匹配(仅当启用且非详情页以防火误提取)
if (this.config.layer1Enabled && ocrText) {
const isDetailPage = checkIsDetailPage(ocrText);
if (!isDetailPage) {
// Layer 1: 规则匹配(基于 OCR 文本)
const ruleMatch = matchOcrRule(ocrText, packageName);
if (ruleMatch.matched && ruleMatch.event) {
const totalDuration = Date.now() - startTime;
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: 退化解析
const fallback = parseOcrBill(ocrText, packageName);
if (fallback) {
return { event: fallback, layer: 'layer2-ocr', ocrText };
}
// 非账单内容billGuard尝试 Layer 3
return await this.tryLayer3(imageBase64, ocrText);
} catch {
// Layer 2: OCR 解析(仅当启用)
if (this.config.layer2Enabled && ocrText) {
const fallback = parseOcrBill(ocrText, packageName);
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 };
}
}
// Layer 3: AI Vision仅当启用
if (this.config.layer3Enabled) {
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' };
}
}
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' };
}
const startTime = Date.now();
try {
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' };
} catch {
} catch (e) {
logger.error('ocrProcessor', '[Layer 3] AI Vision 调用异常', e);
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
}
}

View File

@ -186,6 +186,9 @@ export function classifyWithCategories(
narration,
tags: rule?.tags,
sourceEventIds: [event.id],
metadata: {
import_id: event.id,
},
postings: [
{ account: sourceAccountRaw, amount: sourceAmount, 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),
narration: `转账:${from}${to}`,
sourceEventIds: [left.id, right.id],
metadata: {
import_id: `${left.id},${right.id}`,
},
postings: [
{ account: from, amount: `-${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 { parseDecimal, formatDecimal } from './decimal';
@ -143,8 +143,9 @@ function findPair(
const fromAccount = resolveSourceAccount(expense);
const toAccount = resolveSourceAccount(income);
// ★ 核心校验:双方必须都是资产负债表科目
// ★ 核心校验:双方必须都是资产负债表科目,且不能是同一明确账户(避免已知账户自转账)
if (!isBalanceSheetAccount(fromAccount) || !isBalanceSheetAccount(toAccount)) continue;
if (fromAccount !== 'Assets:Unknown' && fromAccount === toAccount) continue;
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.',
summary: 'Summary',
noSummary: '(no summary)',
postings: 'Postings',
postings: 'Fund Flow',
tags: 'Tags',
links: 'Links',
metadata: 'Additional Info',
@ -132,7 +132,7 @@ export default {
unlinkFail: 'Unlink failed',
relatedTransactions: 'Related Transactions',
readOnlyLinks: 'Read-only transaction, links cannot be edited.',
linkMobileTx: 'Link Mobile Transaction',
linkMobileTx: 'Select Transaction to Link',
searchTxPlaceholder: 'Search narration or account...',
noLinkableTx: 'No linkable mobile transactions',
edit: 'Edit',
@ -283,11 +283,15 @@ export default {
fieldPaymentDay: 'Payment Day (1-28)',
fieldLimit: 'Credit Limit',
fieldCurrency: 'Currency',
fieldLinkedAccount: 'Linked Beancount Account',
fieldLinkedAccount: 'Linked Credit Account',
labelBank: 'Bank',
labelBillingDay: 'Billing Day',
labelPaymentDay: 'Payment Day',
labelLimit: 'Limit',
billingPeriod: 'Current Billing Period',
dueDate: 'Payment Due Date',
statementAmount: 'Statement Amount',
availableCredit: 'Available Credit',
daySuffix: '',
unnamed: 'Untitled Card',
empty: 'No credit cards yet. Tap above to add one.',
@ -376,6 +380,16 @@ export default {
start: 'Get Started',
enableLock: 'Enable App Lock',
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: {
title: 'Read-only Compatibility & Diagnostics',
@ -423,7 +437,7 @@ export default {
backupSuccess: 'Backup successful: %{path}',
restoreSuccess: 'Restore successful, ledger refreshed',
maintenanceSuccess: 'Database maintenance completed',
algorithmConfig: 'Auto-Classification Rules Settings',
algorithmConfig: 'Dedup & Transfer Recognition',
selectLang: 'Select language',
preferencesTitle: 'Preferences & Security',
entryTitle: 'Entry',
@ -432,10 +446,11 @@ export default {
recurringTitle: 'Recurring Transactions',
systemFeaturesTitle: 'Features & System',
aiSettingsTitle: 'AI & Smart Entry',
llmTitle: 'LLM Config',
syncSettingsTitle: 'Cloud Sync & Backup',
aboutTitle: 'About',
aboutVersion: 'v0.1.0 (Hermes)',
enableAiHint: 'Enable AI bill classification hints',
enableAiHint: 'Enable AI Vision',
aiAssistantTitle: 'AI Bill Recognition Assistant',
aiProvider: 'AI Provider',
aiConfigBtn: 'Configure API connection',
@ -450,9 +465,30 @@ export default {
groupPreferences: 'Preferences',
diagnostics: 'Parse diagnostics',
syncBackup: 'Sync & backup',
appLogs: 'App Logs',
preferencesEntry: 'Preferences',
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: {
from: 'From',
to: 'To',
@ -481,6 +517,20 @@ export default {
tabLiabilities: 'Liabilities',
tabExpenses: 'Expense 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',
fullName: 'Full path',
currentBalance: 'Current balance',
@ -684,6 +734,80 @@ export default {
bridgeUnavailable: 'Accessibility bridge unavailable (rebuild APK required)',
currentApp: 'Current App',
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: {
locked: 'App Locked',

View File

@ -86,7 +86,7 @@ export default {
notFoundDesc: '找不到 ID 为 %{id} 的交易。可能已被删除或尚未加载。',
summary: '摘要',
noSummary: '(无摘要)',
postings: '分录',
postings: '资金流向',
tags: '标签',
links: '链接',
metadata: '附加信息',
@ -134,7 +134,7 @@ export default {
unlinkFail: '断开失败',
relatedTransactions: '已关联的其他交易',
readOnlyLinks: '只读交易,不可编辑关联标签。',
linkMobileTx: '关联手机端交易',
linkMobileTx: '选择要关联的交易',
searchTxPlaceholder: '搜索账单摘要或账户...',
noLinkableTx: '没有可供关联的手机端账单',
edit: '编辑',
@ -285,11 +285,15 @@ export default {
fieldPaymentDay: '还款日 (1-28)',
fieldLimit: '额度',
fieldCurrency: '币种',
fieldLinkedAccount: '关联 Beancount 账户',
fieldLinkedAccount: '关联信用账户',
labelBank: '银行',
labelBillingDay: '账单日',
labelPaymentDay: '还款日',
labelLimit: '额度',
billingPeriod: '当前账单周期',
dueDate: '到期还款日',
statementAmount: '本期应还',
availableCredit: '剩余可用额度',
daySuffix: '日',
unnamed: '未命名信用卡',
empty: '暂无信用卡,点击上方添加',
@ -378,6 +382,16 @@ export default {
start: '开始使用',
enableLock: '启用应用锁',
lockEnabled: '✓ 启用应用锁',
permAccessibility: '无障碍服务',
permNotification: '通知监听',
permSms: '短信读取',
permStorage: '相册/存储',
permOverlay: '悬浮窗',
permGranted: '已授权',
permNotGranted: '未授权',
permOpenSettings: '前往设置',
permRequest: '请求权限',
permAllDone: '全部已授权',
},
diagnostics: {
title: '只读兼容与诊断',
@ -425,7 +439,7 @@ export default {
backupSuccess: '备份成功: %{path}',
restoreSuccess: '数据恢复成功,账本已刷新',
maintenanceSuccess: '数据库优化维护完成',
algorithmConfig: '自动分类匹配配置',
algorithmConfig: '去重与转账识别',
selectLang: '选择系统语言',
preferencesTitle: '偏好与安全设置',
entryTitle: '记账',
@ -434,10 +448,11 @@ export default {
recurringTitle: '周期性记账管理',
systemFeaturesTitle: '功能与系统设置',
aiSettingsTitle: '智能记账与 AI 设置',
llmTitle: 'LLM 配置',
syncSettingsTitle: '云同步与本地备份',
aboutTitle: '关于应用',
aboutVersion: 'v0.1.0 (Hermes)',
enableAiHint: '启用 AI 智能账单分类提示',
enableAiHint: '启用 AI 视觉识别',
aiAssistantTitle: 'AI 智能账单识别助手',
aiProvider: 'AI 服务商',
aiConfigBtn: '配置 API 连接信息',
@ -452,9 +467,30 @@ export default {
groupPreferences: '偏好',
diagnostics: '解析诊断',
syncBackup: '同步与备份',
appLogs: '应用日志',
preferencesEntry: '偏好设置',
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: {
from: '从',
to: '到',
@ -483,6 +519,20 @@ export default {
tabLiabilities: '信用与负债',
tabExpenses: '支出分类',
tabIncome: '收入分类',
rootTypes: {
Assets: '储蓄与资产',
Liabilities: '信用与负债',
Expenses: '支出分类',
Income: '收入分类',
Equity: '权益与平账',
},
rootLabels: {
Assets: '储蓄与资产',
Liabilities: '信用与负债',
Expenses: '支出分类',
Income: '收入分类',
Equity: '权益与平账',
},
addNew: '新建账户',
fullName: '完整路径',
currentBalance: '当前余额',
@ -686,6 +736,80 @@ export default {
bridgeUnavailable: '无障碍桥接不可用(需重新编译 APK',
currentApp: '当前 App',
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: {
locked: '应用已锁定',

View File

@ -11,6 +11,7 @@
import type { AiVisionProvider } from '../domain/ocrProcessor';
import type { ImportedEvent } from '../domain/types';
import { parseAiJson, sanitizeForAi } from '../domain/ai';
import { logger } from '../utils/logger';
export interface AiVisionConfig {
enabled: boolean;
@ -54,6 +55,7 @@ export class AiVisionProcessor implements AiVisionProvider {
}
JSON {} JSON`;
const startTime = Date.now();
try {
const response = await this.fetchImpl(`${this.config.apiUrl}/chat/completions`, {
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 content = data.choices[0]?.message?.content ?? '';
const parsed = parseAiJson<{
@ -86,9 +94,13 @@ export class AiVisionProcessor implements AiVisionProvider {
time: string;
}>(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);
logger.info('AiVision', `AI Vision 识别成功 (耗时: ${duration}ms, 模型: ${this.config.model}): 金额 ${amount} ${parsed.currency || 'CNY'}, 对方: ${parsed.counterparty || '未知'}`);
return {
id: `ai-vision:${Date.now()}:${amount}`,
occurredAt: parsed.time || new Date().toISOString().slice(0, 16),
@ -99,7 +111,8 @@ export class AiVisionProcessor implements AiVisionProvider {
memo: 'AI Vision 识别',
raw: { source: 'ai-vision', model: this.config.model },
};
} catch {
} catch (e) {
logger.error('AiVision', `AI Vision 异常 (模型: ${this.config.model})`, e);
return null;
}
}

View File

@ -10,10 +10,18 @@
* - removePageSignature(sig) Promise<boolean>
* - getPaymentPackages() Promise<string[]>
* - 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
*/
import type { FloatingUiConfig } from './floatingUiConfig';
/** 已记住的页面签名。 */
export interface PageSignature {
/** 完整签名pkg|activity。 */
@ -56,9 +64,14 @@ export interface NativeAccessibilityBridge {
categories: Array<{ id: string; name: string; account: string; type?: string }>,
accounts: string[],
direction: string,
currency: string,
draftId: string
): 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 {
if (!texts.includes('创建时间') && !texts.includes('账单详情') && !texts.includes('对此订单有疑问')) {
const isDetailPage = texts.includes('创建时间') || texts.includes('账单详情') || texts.includes('对此订单有疑问');
if (!isDetailPage) {
logger.debug('layout', `[无障碍] 支付宝文本解析跳过:未识别为账单详情页特征`);
return null;
}
@ -78,22 +80,30 @@ function parseAlipayTexts(texts: string[], packageName: string): ImportedEvent |
let time = '';
let direction: 'expense' | 'income' = 'expense';
let memo = '支付宝账单';
const amtWithYuanPattern = /^([+-])?(\d+(\.\d+)?)元$/;
for (const text of texts) {
const match = text.match(amtWithYuanPattern);
if (match) {
amountStr = match[2];
if (match[1] === '+') {
const trimmed = text.trim();
// 匹配 "支出12.93元" / "收入50.00元" / "-12.93元" / "12.93元" / "12.93"
const match = trimmed.match(/^(?:(支出|收入|[+-]))?\s*(\d+(\.\d+)?)元?$/);
if (match && match[2]) {
const val = match[2];
// 避免匹配整年或订单纯数字(如 "2026"),必须带小数点或包含支出/收入/元标志
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;
}
}
}
const amtIndex = texts.findIndex(t => amtWithYuanPattern.test(t));
const amtIndex = texts.findIndex(t => /^(?:(支出|收入|[+-]))?\s*\d+(\.\d+)?元?$/.test(t.trim()));
if (amtIndex > 0) {
merchant = texts[amtIndex - 1];
} else {
@ -132,6 +142,8 @@ function parseAlipayTexts(texts: string[], packageName: string): ImportedEvent |
raw: { packageName },
};
}
logger.info('layout', `[无障碍] 支付宝详情页解析未成功原因: 金额解析无效(amountStr="${amountStr}") 或 商户为空(merchant="${merchant}") | 截获完整文本: ${JSON.stringify(texts)}`);
return null;
}

View File

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

View File

@ -8,6 +8,7 @@
import { hash } from '../domain/ledger';
import type { LedgerFile } from '../domain/types';
import { logger } from '../utils/logger';
/** 备份内容(序列化为 JSON 后打包)。 */
export interface BackupBundle {
@ -38,7 +39,7 @@ export function createBackupBundle(
settings?: Record<string, unknown>,
metadata?: Record<string, unknown>,
): BackupBundle {
return {
const bundle: BackupBundle = {
version: 2,
createdAt: new Date().toISOString(),
files: files.filter(f => f.path !== 'main.bean'),
@ -46,6 +47,8 @@ export function createBackupBundle(
settings,
metadata,
};
logger.info('backup', `创建本地备份包成功 (文件数: ${bundle.files.length + 1}, 含设置/元数据)`);
return bundle;
}
/** 序列化为 JSON便于打包/传输)。 */
@ -55,6 +58,7 @@ export function serializeBundle(bundle: BackupBundle): string {
/** 反序列化 + 校验(兼容 v1 和 v2以及旧版 mobileBean 字段名)。 */
export function deserializeBundle(json: string): BackupBundle {
try {
const data = JSON.parse(json) as Record<string, unknown>;
if (data.version !== 1 && data.version !== 2) throw new Error(`不支持的备份版本: ${data.version}`);
if (!Array.isArray(data.files)) throw new Error('备份文件格式错误files 非数组');
@ -62,7 +66,13 @@ export function deserializeBundle(json: string): BackupBundle {
const mainBean = (typeof data.mainBean === 'string' ? data.mainBean : undefined)
?? (typeof data.mobileBean === 'string' ? data.mobileBean as string : undefined);
if (typeof mainBean !== 'string') throw new Error('备份文件格式错误mainBean 非字符串');
return { ...data, mainBean } as BackupBundle;
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[]>;
/** OCR 引擎是否已就绪(模型加载完成)。 */
isReady(): Promise<boolean>;
/** 设置模型文件目录(绝对路径),触发从 filesystem 重新加载模型。 */
setModelDir(dir: string): Promise<boolean>;
}
export interface OcrTextBlock {
@ -94,11 +96,17 @@ export function getNativeOcrModule(): NativeOcrModule | null {
/**
* OcrEngine OcrProcessor
* RN NativeOcrBridge
*
* @param modelDir
*/
export function getNativeOcrBridge(): OcrEngine {
export function getNativeOcrBridge(modelDir?: string): OcrEngine {
const native = getNativeOcrModule();
if (!native) {
throw new Error('OCR engine not available');
}
// P8设置模型目录从 filesystem 加载 ONNX替代 asset 内联)
if (modelDir) {
native.setModelDir(modelDir).catch(() => {});
}
return new NativeOcrBridge(native);
}

View File

@ -65,8 +65,9 @@ export class WebDAVClient {
/** 获取远程文件信息Last-Modified / 大小)。 */
async stat(): Promise<{ lastModified: string | null; exists: boolean }> {
const url = this.buildUrl(this.config.remotePath);
try {
const resp = await this.fetchImpl(this.buildUrl(this.config.remotePath), {
const resp = await this.fetchImpl(url, {
method: 'PROPFIND',
headers: {
'Authorization': this.authHeader,
@ -75,33 +76,43 @@ export class WebDAVClient {
},
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 match = body.match(/<getlastmodified>([^<]+)<\/getlastmodified>/);
const headerLastMod = resp.headers.get('Last-Modified');
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 };
}
}
/** 下载文件内容。 */
async get(): Promise<string | null> {
const url = this.buildUrl(this.config.remotePath);
try {
const resp = await this.fetchImpl(this.buildUrl(this.config.remotePath), {
const resp = await this.fetchImpl(url, {
method: 'GET',
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();
} catch {
} catch (e) {
logger.error('webdavSync', `GET 远程文件网络或数据异常 (URL: ${url}): ${e instanceof Error ? e.message : String(e)}`, e);
return null;
}
}
/** 上传文件内容。 */
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',
headers: {
'Authorization': this.authHeader,
@ -109,7 +120,10 @@ export class WebDAVClient {
},
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 names = [...body.matchAll(/<displayname>([^<]+)<\/displayname>/g)].map(m => m[1]);
return names.filter(n => n.includes('backup'));
} catch {
} catch (e) {
logger.warn('webdavSync', `列出备份目录失败 (路径: ${backupDir}): ${e}`);
return [];
}
}

View File

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

View File

@ -14,6 +14,8 @@ import type { TransferConfig } from '../domain/transferRecognizer';
import { sharedPipeline } from '../domain/pipelineSingleton';
import type { ProcessedDraft } from '../domain/billPipeline';
import { logger } from '../utils/logger';
export type AutomationSource = 'ocr' | 'notification' | 'sms' | 'screenshot' | 'manual';
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 },
addDetected: (source, event) => {
logger.info('automationStore', `[收到自动化账单事件] 来源: ${source}, ID: ${event.id}, 时间: ${event.occurredAt}, 金额: ${event.amount} ${event.currency}, 对方: "${event.counterparty || '未知'}"`);
set(state => ({
detected: [...state.detected, {
id: `${source}:${event.id}`,
@ -95,6 +98,7 @@ export const useAutomationStore = create<AutomationState>((set, get) => ({
isProcessing: false,
});
} catch (e) {
logger.error('automationStore', '处理自动化账单事件异常', 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 draft = drafts[index];
if (!draft) return null;
logger.info('automationStore', `[自动化草稿确认] 确认自动记账草稿 [日期: ${draft.draft.date}, 商户: "${draft.draft.payee ?? '未知'}", 叙述: "${draft.draft.narration}"]`);
set({ drafts: drafts.filter((_, i) => i !== index) });
return draft;
},
rejectDraft: (index) => {
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) });
},
clear: () => set({
clear: () => {
logger.info('automationStore', '[自动化队列清空] 已清空自动化事件与待处理草稿');
set({
detected: [],
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 { StatementAdapter } from '../domain/statements';
import { logger } from '../utils/logger';
/**
* plan.md0.1 +2.8
*
@ -72,9 +74,11 @@ export const useImportStore = create<ImportState>((set, get) => ({
importCsv: (content, adapter) => {
try {
const events = importStatement(content, adapter);
logger.info('import', `导入 CSV 账单成功, 解析得到 ${events.length} 笔原始事件`);
set({ events, error: null });
return events;
} catch (e) {
logger.error('import', '解析 CSV 账单代码或格式失败', e);
set({ error: e instanceof Error ? e.message : String(e) });
throw e;
}
@ -105,6 +109,7 @@ export const useImportStore = create<ImportState>((set, get) => ({
if (!draft) return null;
// 用 draft 引用而非索引过滤,避免竞态
const next = pendingDrafts.filter((d) => d !== draft);
logger.info('import', `[手动确认草稿] 确认导入草稿 [日期: ${draft.draft.date}, 商户: "${draft.draft.payee ?? '未知'}", 叙述: "${draft.draft.narration}"]`);
set({ pendingDrafts: next });
return draft;
},
@ -113,13 +118,21 @@ export const useImportStore = create<ImportState>((set, get) => ({
const { pendingDrafts } = get();
// 使用引用过滤而非索引过滤,避免并发覆盖
const toRemove = new Set(indices.map(i => pendingDrafts[i]));
logger.info('import', `[批量确认草稿] 批量确认 ${toRemove.size} 笔草稿入账`);
set({ pendingDrafts: pendingDrafts.filter(d => !toRemove.has(d)) });
},
rejectDraft: (index) => {
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) });
},
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 () => {
const { mobileStore } = get();
if (!mobileStore) throw new Error('账本未加载');
logger.info('ledgerStore', `正在全量替换 mobile.bean 内容 (新长度: ${content.length} 字节)`);
await mobileStore.replace(content);
const ledger = reparseLedger(content);
if (get().mobileStore !== mobileStore) return;
@ -181,6 +182,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
// 去重:跳过已 open 的账户
const newAccounts = accounts.filter(acc => !ledger?.accounts.has(acc));
if (newAccounts.length === 0) return;
logger.info('ledgerStore', `自动开户 ${newAccounts.length} 个账户: ${newAccounts.join(', ')}`);
const today = new Date().toISOString().slice(0, 10);
const currency = getDefaultCurrency(ledger);
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 () => {
const { mobileStore } = get();
if (!mobileStore) throw new Error('账本未加载');
logger.info('ledgerStore', `自动销户账户: ${accountName}`);
const today = new Date().toISOString().slice(0, 10);
await mobileStore.modifyContent(current => `${current.trimEnd()}\n\n${today} close ${accountName}\n`.trim());
const newLedger = reparseLedger(mobileStore.getContent());
@ -209,6 +212,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
if (!mobileStore) throw new Error('账本未加载');
const ledger = get().ledger;
const currency = getDefaultCurrency(ledger);
logger.info('ledgerStore', `校准账户余额: ${accountName} -> ${balance} ${currency} (日期: ${date})`);
// 计算前一天的日期用于 pad使用本地时间避免 UTC 偏移)
const [y, m, d] = date.split('-').map(Number);
const padDate = toDateString(new Date(y, m - 1, d - 1));
@ -224,6 +228,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
return withLock(async () => {
const { mobileStore } = get();
if (!mobileStore) throw new Error('账本未加载');
logger.info('ledgerStore', `批量追加 ${drafts.length} 笔交易到 mobile.bean`);
const mobileBean = get().mobileBean; // 锁内读取最新值
const serializedChunk = drafts.map(d => serializeTransaction(d)).join('\n\n');
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);
await mobileStore.replace(newContent);
const newLedger = reparseLedger(newContent);
logger.info('ledgerStore', `修改已保存交易成功:\n【修改前】:\n${oldRaw.trim()}\n【修改后】:\n${newRaw.trim()}`);
if (get().mobileStore !== mobileStore) return '';
set({ mobileBean: newContent, ledger: newLedger });
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();
await mobileStore.replace(newContent);
const newLedger = reparseLedger(newContent);
logger.info('ledgerStore', `删除已保存交易成功:\n【被删除交易内容】:\n${raw.trim()}`);
if (get().mobileStore !== mobileStore) return;
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' },
],
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: [
// 通用匹配规则示例
@ -141,10 +141,8 @@ export function generateId(prefix = 'id'): string {
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
/**
* CRUD key add/update/remove action
* 7
*/
import { logger } from '../utils/logger';
function createCrudActions<T extends { id: string }>(
key: keyof PersistableMetadata,
set: (fn: (s: MetadataState) => Partial<MetadataState>) => void,
@ -153,18 +151,22 @@ function createCrudActions<T extends { id: string }>(
return {
add: (item: T) => {
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()));
},
update: (id: string, patch: Partial<T>) => {
set((s) => ({
[key]: (s[key] as unknown as T[]).map(item => item.id === id ? { ...item, ...patch } : item),
}) as Partial<MetadataState>);
logger.info('metadataStore', `更新元数据实体 [${key}] (ID: ${id})`, patch);
get().persistTo?.(toPersistable(get()));
},
remove: (id: string) => {
set((s) => ({
[key]: (s[key] as unknown as T[]).filter(item => item.id !== id),
}) as Partial<MetadataState>);
logger.info('metadataStore', `删除元数据实体 [${key}] (ID: ${id})`);
get().persistTo?.(toPersistable(get()));
},
};

View File

@ -30,6 +30,11 @@ export interface SettingsState {
/** 引导是否完成。 */
onboardingCompleted: boolean;
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
webdavUrl?: string;
@ -57,6 +62,18 @@ export interface SettingsState {
floatingBallEnabled: boolean;
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 的用户保持关闭)。 */
numpadGlobalEntry: boolean;
@ -117,6 +134,11 @@ export interface PersistableSettings {
numpadGlobalEntry: boolean;
lastUsedAccount: 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,
lastUsedAccount: null,
lastUsedCategoryId: null,
layer1RuleEnabled: true,
layer2OcrEnabled: true,
layer3AiEnabled: false,
ocrModelVersion: '',
ocrModelDir: '',
skipPersist: false,
};
@ -190,40 +217,101 @@ export function toPersistable(state: SettingsState): PersistableSettings {
numpadGlobalEntry: state.numpadGlobalEntry,
lastUsedAccount: state.lastUsedAccount,
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) => ({
...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 }),
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 期间跳过自动持久化
// 使用单次 setState 合并所有数据,避免多次触发 subscriber

View File

@ -13,5 +13,6 @@ export function createTheme(overrides: Partial<ThemeTokens>): ThemeTokens {
radii: { ...lightTheme.radii, ...overrides.radii },
typography: { ...lightTheme.typography, ...overrides.typography },
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 },
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 = {

View File

@ -97,10 +97,16 @@ export interface ThemeShadows {
lg: ThemeShadow;
}
export interface ThemeOpacities {
pressed: number; // 统一点击按压透明度 (0.7)
disabled: number; // 禁用透明度 (0.4)
}
export interface ThemeTokens {
colors: ThemeColors;
spacing: ThemeSpacing;
radii: ThemeRadii;
typography: ThemeTypography;
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
* - 1000
* - Sentryplan.md Phase 9
* - getLogs/clearLogs UI
* - 4 DEBUG/INFO/WARN/ERROR
* - 1000 UI
* - LogFileBackend
* - 7
*/
export enum LogLevel {
@ -16,14 +15,30 @@ export enum LogLevel {
}
export interface LogEntry {
timestamp: string; // ISO
timestamp: string; // ISO 时间戳
level: LogLevel;
tag: string; // 模块标识(如 'mobileStore'、'billPipeline'
message: string;
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 maxBuffer: number;
/** 最低输出级别(默认 DEBUG生产环境可调高。 */
@ -31,10 +46,23 @@ class LoggerImpl {
/** 是否输出到 console开发环境 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) {
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 {
this.log(LogLevel.DEBUG, tag, message, data);
}
@ -74,34 +102,191 @@ class LoggerImpl {
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[] {
return [...this.buffer];
}
/** 按级别/标签过滤。 */
query(filter: { minLevel?: LogLevel; tag?: string }): LogEntry[] {
return this.buffer.filter(e =>
(filter.minLevel === undefined || e.level >= filter.minLevel) &&
(filter.tag === undefined || e.tag === filter.tag),
);
/** 按级别/标签过滤内存日志。 */
query(filter: { minLevel?: LogLevel; tag?: string; keyword?: string }): LogEntry[] {
return this.buffer.filter(e => {
if (filter.minLevel !== undefined && e.level < filter.minLevel) return false;
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 {
this.buffer = [];
}
/** 调整缓冲上限。 */
/** 清空内存与磁盘日志。 */
async clearAllLogs(): Promise<void> {
this.buffer = [];
this.pendingWriteQueue = [];
if (this.fileBackend) {
await this.fileBackend.clearAllLogFiles();
}
}
/** 调整内存缓冲上限。 */
setMaxBuffer(size: number): void {
this.maxBuffer = size;
// 立即裁剪
if (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.ERROR]: 'ERROR',
};

View File

@ -17,6 +17,7 @@ describe('ocrBridge', () => {
recognizeText: async () => '微信支付 金额 10 商户:x',
recognizeTextBlocks: async () => [],
isReady: async () => true,
setModelDir: async () => true,
};
const bridge = new NativeOcrBridge(native);
expect(await bridge.recognizeText('img')).toBe('微信支付 金额 10 商户:x');
@ -27,6 +28,7 @@ describe('ocrBridge', () => {
recognizeText: async () => { throw new Error('原生崩溃'); },
recognizeTextBlocks: async () => [],
isReady: async () => false,
setModelDir: async () => true,
};
const fallback = new SimulatedOcrBridge();
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',
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');
expect(result.layer).toBe('layer3-ai');
expect(result.event?.amount).toBe('99');
@ -298,7 +298,7 @@ describe('OcrProcessor 分层处理', () => {
it('横屏免打扰跳过', async () => {
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);
expect(result.skipped).toBe('landscape-dnd');
});