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:
parent
2e73b5a2c6
commit
bf04400852
62
AGENTS.md
Normal file
62
AGENTS.md
Normal 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.
|
||||||
54
README.md
54
README.md
@ -1,26 +1,54 @@
|
|||||||
# Bean Mobile
|
# DriftLedger (浮记)
|
||||||
|
|
||||||
面向 Beancount 用户的离线移动端原型:桌面账本只读,移动端仅写入 `mobile.bean`;CSV 账单先生成复式分录草稿,再由用户确认。
|
面向 [Beancount](https://beancount.github.io/) 用户的离线移动记账客户端。桌面账本只读,移动端仅追加写入 `main.bean`;支持 OCR、无障碍服务、通知监听、短信等多渠道自动记账。
|
||||||
|
|
||||||
## 启动
|
**技术栈**:React Native + Expo (CNG) · TypeScript (strict) · Zustand · expo-sqlite · PP-OCRv6 (ONNX Runtime)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install --legacy-peer-deps # 安装依赖(必须带 --legacy-peer-deps)
|
||||||
npm run start
|
npm run start # 启动 Metro 开发服务器
|
||||||
|
npm run android # 构建并运行 Android
|
||||||
|
npm test # 运行 Vitest 全量测试
|
||||||
|
npm run typecheck # TypeScript 类型检查
|
||||||
```
|
```
|
||||||
|
|
||||||
使用 Expo CNG 开发构建运行:`npm run android` 或 `npm run ios`。应用配置启用了 `expo-sqlite` 的 SQLCipher 构建选项,实际密钥管理应接入 SecureStore/Keychain 后再发布。
|
> 本应用为侧载开源项目,不上架应用商店。Android 构建详见 [docs/android-build-guide.md](docs/android-build-guide.md)。
|
||||||
|
|
||||||
|
## 模块概览
|
||||||
|
|
||||||
|
| 目录 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `src/domain/` | 纯业务逻辑:`.bean` 解析器、账单管道、去重、规则引擎、OCR 处理器 |
|
||||||
|
| `src/app/` | Expo Router 文件路由(`(tabs)/` 底部导航) |
|
||||||
|
| `src/components/` | React Native UI 组件与图表 |
|
||||||
|
| `src/store/` | Zustand 状态管理(ledger / import / settings / metadata / automation) |
|
||||||
|
| `src/services/` | 平台服务(同步、备份、安全、OCR 桥接) |
|
||||||
|
| `src/storage/` | SQLite 读缓存 + 版本化迁移 |
|
||||||
|
| `src/theme/` | Token 主题系统(浅色 / OLED 暗色) |
|
||||||
|
| `src/i18n/` | 中英文国际化 |
|
||||||
|
| `plugins/` | Expo Config Plugins 原生模块(OCR、无障碍、通知、短信、截图) |
|
||||||
|
| `tests/` | Vitest 单元测试(mock 后端,覆盖 domain 层) |
|
||||||
|
|
||||||
|
## 文档导航
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| [docs/architecture.md](docs/architecture.md) | 系统架构与数据流 |
|
||||||
|
| [docs/development.md](docs/development.md) | 开发指南(环境、命令、规范、测试) |
|
||||||
|
| [docs/android-build-guide.md](docs/android-build-guide.md) | Android 打包与体积优化 |
|
||||||
|
| [docs/design/](docs/design/) | UI 重设计规格与分阶段实施计划 |
|
||||||
|
| [design-system/](design-system/beancount-mobile/MASTER.md) | 设计系统 Token 定义 |
|
||||||
|
| [AGENTS.md](AGENTS.md) | AI 编程助手贡献指南 |
|
||||||
|
| [plan.md](plan.md) | 设计决策与实施路线图(权威文档) |
|
||||||
|
|
||||||
## 账本约定
|
## 账本约定
|
||||||
|
|
||||||
在主账本添加一次:
|
在桌面主账本中添加一次:
|
||||||
|
|
||||||
```beancount
|
```beancount
|
||||||
include "mobile.bean"
|
include "main.bean"
|
||||||
```
|
```
|
||||||
|
|
||||||
应用不改写主账本或其他桌面维护文件;确认的交易只追加到 `mobile.bean`。导出的账本包由用户再同步到 Git、WebDAV 或 iCloud。
|
应用不改写主账本或其他桌面维护文件;确认的交易只追加到 `main.bean`。
|
||||||
|
|
||||||
## 当前边界
|
|
||||||
|
|
||||||
已实现账务校验、基础 `.bean` 解析、CSV 账单规范化、去重、规则分类、转账候选和 `mobile.bean` 序列化。Tree-sitter 原生模块、真实文件选择/ZIP 导出、正式支付宝/微信/各银行字段映射、SecureStore 密钥和多设备冲突处理仍需在真机集成阶段接入。
|
|
||||||
|
|||||||
116
docs/architecture.md
Normal file
116
docs/architecture.md
Normal 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 字段本地 |
|
||||||
@ -9,6 +9,7 @@
|
|||||||
**与 spec 的偏差**:①account 页是「账户浏览器」(类型 Tab + 开户/调余额/关户三个交互),不套 ManagementScreen,只做 ScreenHeader 统一;②`numpadGlobalEntry` 翻默认只影响新安装(已持久化的 false 不覆盖,尊重用户选择)。
|
**与 spec 的偏差**:①account 页是「账户浏览器」(类型 Tab + 开户/调余额/关户三个交互),不套 ManagementScreen,只做 ScreenHeader 统一;②`numpadGlobalEntry` 翻默认只影响新安装(已持久化的 false 不覆盖,尊重用户选择)。
|
||||||
|
|
||||||
**模板适配决策**(已通读 7 页源码):
|
**模板适配决策**(已通读 7 页源码):
|
||||||
|
|
||||||
- budget/rules/remark-template → 直套(T1)
|
- budget/rules/remark-template → 直套(T1)
|
||||||
- category → `headerContent` 放支出/收入 chips,新增按当前类型(T2)
|
- category → `headerContent` 放支出/收入 chips,新增按当前类型(T2)
|
||||||
- recurring → 卡片保留显式编辑/删除按钮(用 handlers.openEdit/confirmDelete),不用长按(T2)
|
- recurring → 卡片保留显式编辑/删除按钮(用 handlers.openEdit/confirmDelete),不用长按(T2)
|
||||||
@ -19,6 +20,7 @@
|
|||||||
### Task 1: 管理页模板化批次 1(budget / rules / remark-template)
|
### Task 1: 管理页模板化批次 1(budget / rules / remark-template)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/app/budget/index.tsx`、`src/app/rules/index.tsx`、`src/app/remark-template/index.tsx`
|
- Modify: `src/app/budget/index.tsx`、`src/app/rules/index.tsx`、`src/app/remark-template/index.tsx`
|
||||||
|
|
||||||
- [ ] **Step 1: 三页套 ManagementScreen**(参照试点 `src/app/tag/index.tsx` 与模板 `src/components/ManagementScreen.tsx`)
|
- [ ] **Step 1: 三页套 ManagementScreen**(参照试点 `src/app/tag/index.tsx` 与模板 `src/components/ManagementScreen.tsx`)
|
||||||
@ -26,6 +28,7 @@
|
|||||||
通用映射:页面 state 消失(modal/editing 由模板持有);手写 header/addBtn/Alert/FormModal 全删;`footer` 传 `common.clickEditLongDelete` 提示;`deleteConfirmTitle` 传各页 `t('xxx.deleteTitle')`。
|
通用映射:页面 state 消失(modal/editing 由模板持有);手写 header/addBtn/Alert/FormModal 全删;`footer` 传 `common.clickEditLongDelete` 提示;`deleteConfirmTitle` 传各页 `t('xxx.deleteTitle')`。
|
||||||
|
|
||||||
**budget/index.tsx**:
|
**budget/index.tsx**:
|
||||||
|
|
||||||
- `items={budgets}`、`keyExtractor={b => b.id}`、`addLabel={t('budget.add')}`、`emptyText={t('budget.empty')}`
|
- `items={budgets}`、`keyExtractor={b => b.id}`、`addLabel={t('budget.add')}`、`emptyText={t('budget.empty')}`
|
||||||
- renderItem:保留现有 Card + 进度条结构(Pressable onPress={openEdit} onLongPress={confirmDelete} 包裹)。`today` 改用 `toDateString(new Date())`(修掉 `toISOString().slice(0,10)` 的 UTC 偏移)
|
- renderItem:保留现有 Card + 进度条结构(Pressable onPress={openEdit} onLongPress={confirmDelete} 包裹)。`today` 改用 `toDateString(new Date())`(修掉 `toISOString().slice(0,10)` 的 UTC 偏移)
|
||||||
- formFields:5 字段(name/amount decimal-pad/period/categoryAccount/startDate),defaultValue 从 editing 取
|
- formFields:5 字段(name/amount decimal-pad/period/categoryAccount/startDate),defaultValue 从 editing 取
|
||||||
@ -33,12 +36,14 @@
|
|||||||
- onDelete:`removeBudget(budget.id)`;deleteConfirmText:`t('budget.deleteConfirm', { name: budget.name })`
|
- onDelete:`removeBudget(budget.id)`;deleteConfirmText:`t('budget.deleteConfirm', { name: budget.name })`
|
||||||
|
|
||||||
**rules/index.tsx**:
|
**rules/index.tsx**:
|
||||||
|
|
||||||
- `items={[...rules].sort((a, b) => b.priority - a.priority)}`
|
- `items={[...rules].sort((a, b) => b.priority - a.priority)}`
|
||||||
- renderItem:保留规则行(narration/P 徽标/条件/目标账户+命中次数);**删除 `fontFamily: theme.typography.caption.fontFamily`**(审计项)
|
- renderItem:保留规则行(narration/P 徽标/条件/目标账户+命中次数);**删除 `fontFamily: theme.typography.caption.fontFamily`**(审计项)
|
||||||
- formFields:7 字段(priority numeric/counterpartyContains/memoContains/sourceAccount/categoryAccount/narration/tags)
|
- formFields:7 字段(priority numeric/counterpartyContains/memoContains/sourceAccount/categoryAccount/narration/tags)
|
||||||
- onSubmit:保留现有 Rule 组装逻辑;addRule `{...rule, id: generateId('rule'), hits: 0}` / updateRule
|
- onSubmit:保留现有 Rule 组装逻辑;addRule `{...rule, id: generateId('rule'), hits: 0}` / updateRule
|
||||||
|
|
||||||
**remark-template/index.tsx**:
|
**remark-template/index.tsx**:
|
||||||
|
|
||||||
- 2 字段(name/template);renderItem 的模板文本 `fontFamily: 'monospace'` 改 `fontVariant: ['tabular-nums']`(审计项)
|
- 2 字段(name/template);renderItem 的模板文本 `fontFamily: 'monospace'` 改 `fontVariant: ['tabular-nums']`(审计项)
|
||||||
- onSubmit:name 空回退 `t('common.untitled')`
|
- onSubmit:name 空回退 `t('common.untitled')`
|
||||||
|
|
||||||
@ -52,6 +57,7 @@ Run: `grep -ln "FormModal\|arrow-back" src/app/budget/index.tsx src/app/rules/in
|
|||||||
### Task 2: 管理页模板化批次 2(category / recurring / credit-card)
|
### Task 2: 管理页模板化批次 2(category / recurring / credit-card)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/app/category/index.tsx`、`src/app/recurring/index.tsx`、`src/app/credit-card/index.tsx`
|
- Modify: `src/app/category/index.tsx`、`src/app/recurring/index.tsx`、`src/app/credit-card/index.tsx`
|
||||||
|
|
||||||
- [ ] **Step 1: category/index.tsx — headerContent 类型 chips**
|
- [ ] **Step 1: category/index.tsx — headerContent 类型 chips**
|
||||||
@ -87,6 +93,7 @@ Run: `grep -ln "FormModal\|arrow-back" src/app/category/index.tsx src/app/recurr
|
|||||||
### Task 3: ScreenHeader 统一(account + settings 三页)
|
### Task 3: ScreenHeader 统一(account + settings 三页)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/app/account/index.tsx`、`src/app/settings/sync.tsx`、`src/app/settings/ai.tsx`、`src/app/settings/preferences.tsx`
|
- Modify: `src/app/account/index.tsx`、`src/app/settings/sync.tsx`、`src/app/settings/ai.tsx`、`src/app/settings/preferences.tsx`
|
||||||
|
|
||||||
- [ ] **Step 1: account/index.tsx**
|
- [ ] **Step 1: account/index.tsx**
|
||||||
@ -107,6 +114,7 @@ Run: `grep -rn "arrow-back" src/app/ | grep -v ScreenHeader` → 无输出
|
|||||||
### Task 4: 图表与杂项清理(fontFamily / emoji / 星期头 i18n)
|
### Task 4: 图表与杂项清理(fontFamily / emoji / 星期头 i18n)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/components/charts/CategoryPie.tsx`、`src/components/charts/TrendLine.tsx`、`src/components/charts/NetWorthChart.tsx`、`src/components/CalendarView.tsx`、`src/components/charts/CalendarHeatmap.tsx`、`src/components/DatePickerField.tsx`、`src/components/NumpadKeyboard.tsx`、`src/components/NumpadSheet.tsx`、`src/ai/monthlySummary.ts`、`src/app/ai/chat.tsx`、`src/app/transaction/[id].tsx`、`src/app/_error.tsx`、`src/app/import/index.tsx`、`src/components/PostingEditor.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
|
- Modify: `src/components/charts/CategoryPie.tsx`、`src/components/charts/TrendLine.tsx`、`src/components/charts/NetWorthChart.tsx`、`src/components/CalendarView.tsx`、`src/components/charts/CalendarHeatmap.tsx`、`src/components/DatePickerField.tsx`、`src/components/NumpadKeyboard.tsx`、`src/components/NumpadSheet.tsx`、`src/ai/monthlySummary.ts`、`src/app/ai/chat.tsx`、`src/app/transaction/[id].tsx`、`src/app/_error.tsx`、`src/app/import/index.tsx`、`src/components/PostingEditor.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: fontFamily 清零**
|
- [ ] **Step 1: fontFamily 清零**
|
||||||
@ -142,6 +150,7 @@ Run: `grep -rn "fontFamily" src/ --include="*.tsx" | grep -v "_error.tsx"` →
|
|||||||
### Task 5: 收尾(翻默认 + modal 守卫 + MonthlyReport 删除 + budgets 时区修复 + duplicate 增强)
|
### Task 5: 收尾(翻默认 + modal 守卫 + MonthlyReport 删除 + budgets 时区修复 + duplicate 增强)
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
|
|
||||||
- Modify: `src/store/settingsStore.ts`、`src/components/NumpadSheet.tsx`、`src/components/NumpadSheetHost.tsx`、`src/domain/budgets.ts`、`src/app/(tabs)/transactions.tsx`、`tests/budgets.test.ts`(或现有 budget 测试文件)
|
- Modify: `src/store/settingsStore.ts`、`src/components/NumpadSheet.tsx`、`src/components/NumpadSheetHost.tsx`、`src/domain/budgets.ts`、`src/app/(tabs)/transactions.tsx`、`tests/budgets.test.ts`(或现有 budget 测试文件)
|
||||||
- Delete: `src/components/charts/MonthlyReport.tsx`
|
- Delete: `src/components/charts/MonthlyReport.tsx`
|
||||||
|
|
||||||
@ -207,6 +216,7 @@ Run: `npm run typecheck` → 无错误
|
|||||||
|
|
||||||
Run: `npm run android`
|
Run: `npm run android`
|
||||||
走查清单(浅/暗双主题):
|
走查清单(浅/暗双主题):
|
||||||
|
|
||||||
1. 6 个管理页(分类/预算/规则/周期/信用卡/备注模板):+新增、点按编辑、删除(recurring 为显式按钮,其余长按)、空态文案
|
1. 6 个管理页(分类/预算/规则/周期/信用卡/备注模板):+新增、点按编辑、删除(recurring 为显式按钮,其余长按)、空态文案
|
||||||
2. account 页:类型 Tab、开户、调余额、关户
|
2. account 页:类型 Tab、开户、调余额、关户
|
||||||
3. settings/sync、ai、preferences:header 统一有返回箭头
|
3. settings/sync、ai、preferences:header 统一有返回箭头
|
||||||
317
docs/design/ui-redesign-p6-plan.md
Normal file
317
docs/design/ui-redesign-p6-plan.md
Normal 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()` → 原生存 SharedPreferences(JSON)→ 三个浮层组件读取。原生保留现有硬编码值作为**默认值兜底**(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 accent:light #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"` 六位数 hex(theme tokens 里有 rgba 的字段不要直接下发——accent/cardBg/fg/border 等都是纯色,确认无 rgba;border 深色主题是 `rgba(255,255,255,0.08)`,下发前换算为 hex:深色用 `#14202429`? **不许发明值**——正确做法:原生 `Color.parseColor` 支持 `#AARRGGBB`,JS 侧把 rgba(255,255,255,0.08) 换算为 `#14FFFFFF`(alpha 0.08≈0x14)。写一个 `rgbaToHex` 小工具处理这种情况,纯色 hex 原样通过)。
|
||||||
|
- 原生端用 `Color.parseColor` 解析,异常时回退默认值。
|
||||||
|
|
||||||
|
### 原生持久化
|
||||||
|
|
||||||
|
- SharedPreferences 文件名:`floating_ui_config`,单键 `config_json` 存整个 JSON。
|
||||||
|
- 新增 `FloatingUiConfigStore.kt`(object):`save(context, ReadableMap)` / `load(context): FloatingUiConfig`(data class,字段缺省值=现状硬编码值,保证旧 JS 行为不变)。
|
||||||
|
- `FloatingUiConfig` data class 字段名与上面契约一致(camelCase)。
|
||||||
|
|
||||||
|
### 事件契约变更(T3+T5)
|
||||||
|
|
||||||
|
- `showFloatingBill` 增加参数 `currency: String`(放在 `direction` 之后、`draftId` 之前)。
|
||||||
|
- `billingConfirmed` / `billingOpenApp` 事件 map 增加 `putString("currency", currentCurrency)`。
|
||||||
|
- 浮窗金额行左侧显示当前币种 chip,点击在 `listOf("CNY","USD","HKD","JPY","EUR","GBP")` 中循环切换;初始值 = 传入 currency(不在列表则插入到首位)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: JS 侧 — i18n 键 + floatingUiConfig 服务 + bridge 接口 + 推送接线 + 前台 Alert i18n
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`、`src/services/accessibilityBridge.ts`、`src/app/_layout.tsx`、`src/services/automationPipeline.ts`
|
||||||
|
- Create: `src/services/floatingUiConfig.ts`、`tests/floating-ui-config.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n 新增 `floating.*` 键组(zh/en 对等,`%{name}` 插值语法)**
|
||||||
|
|
||||||
|
```
|
||||||
|
floating.billTitle 调整交易草稿 / Adjust Draft
|
||||||
|
floating.dirExpense 支出 / Expense (复用现有方向键也行,但浮层独立一组避免耦合)
|
||||||
|
floating.dirIncome 收入 / Income
|
||||||
|
floating.dirTransfer 转账 / Transfer
|
||||||
|
floating.amountLabel 金额 / Amount
|
||||||
|
floating.payeeLabel 交易对手 / Payee
|
||||||
|
floating.narrationLabel 描述/备注 / Narration
|
||||||
|
floating.narrationHint 输入交易叙述 / Enter narration
|
||||||
|
floating.categoryExpense 交易分类 / Category
|
||||||
|
floating.categoryIncome 收入分类 / Income Category
|
||||||
|
floating.transferTarget 转入账户 / To Account
|
||||||
|
floating.accountExpense 资金来源 / From Account
|
||||||
|
floating.accountIncome 存入账户 / To Account
|
||||||
|
floating.accountTransfer 转出账户 / From Account
|
||||||
|
floating.openApp 打开应用 / Open App
|
||||||
|
floating.dismiss 忽略 / Dismiss
|
||||||
|
floating.confirm 确认入账 / Confirm
|
||||||
|
floating.ballOcr 识别账单 / Scan Bill
|
||||||
|
floating.ballRemember 记住此页 / Remember Page
|
||||||
|
floating.rememberSuccess 已将当前页面加入识别白名单 / Page added to recognition whitelist
|
||||||
|
floating.rememberFail 记录失败 / Failed to save
|
||||||
|
floating.pageRemembered 已记住页面签名 / Page signature saved
|
||||||
|
floating.pageSignatureExists 该页面签名已存在 / Page signature already exists
|
||||||
|
```
|
||||||
|
|
||||||
|
(若现有键已有同文案可复用,但键组保持独立 `floating.*`;先 grep 避免重复键名。)
|
||||||
|
|
||||||
|
同时给前台 Alert 加键(放 `automation.*` 下,先读现有 automation 节):
|
||||||
|
```
|
||||||
|
automation.billDetectedTitle 识别到新账单 / New Bill Detected (无 emoji)
|
||||||
|
automation.billDetectedReject 拒绝/丢弃 / Discard
|
||||||
|
automation.billDetectedEdit 修改并入账 / Edit & Save
|
||||||
|
automation.billDetectedConfirm 确认入账 / Confirm
|
||||||
|
```
|
||||||
|
Alert 的多行消息体(日期/商户/金额/分类/账户/叙述)逐行用既有标签键拼,不为每行加新键(行标签可复用表单/详情现有键,先 grep `transaction.payee` 之类;没有合适的就在 automation 节加 `billDetectedLine*` 键)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: `src/services/floatingUiConfig.ts`**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface FloatingUiConfig { colors: {...}; labels: {...} } // 按契约
|
||||||
|
|
||||||
|
/** rgba(r,g,b,a) → #AARRGGBB;#RRGGBB 原样返回。 */
|
||||||
|
export function colorToHex(color: string): string;
|
||||||
|
|
||||||
|
/** 从 theme tokens + t() 构建完整 config。 */
|
||||||
|
export function buildFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): FloatingUiConfig;
|
||||||
|
|
||||||
|
/** 构建并推送到原生(bridge 不可用时静默返回 false)。 */
|
||||||
|
export async function pushFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): Promise<boolean>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: bridge 接口扩展**
|
||||||
|
|
||||||
|
`accessibilityBridge.ts`:`NativeAccessibilityBridge` 加 `setFloatingUiConfig(config: FloatingUiConfig): Promise<boolean>;`,文件头注释方法清单补一行。`showFloatingBill` 签名加 `currency: string`(direction 之后、draftId 之前)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: _layout.tsx 推送接线**
|
||||||
|
|
||||||
|
- 启动时(setFloatingBallEnabled 同一处,`src/app/_layout.tsx:139` 附近)也 `pushFloatingUiConfig(...)`。注意此处拿不到 useTheme 的 theme(在 ThemeProvider 外层?先读 _layout 结构确认)——若拿不到,启动这次推送可移到下一步的组件里统一做,避免重复。
|
||||||
|
- 在 ThemeProvider **内部**挂一个小组件(如 `FloatingUiConfigSyncer`,可直接写在 _layout.tsx 里):`const { theme } = useTheme(); const t = useT(); useEffect(() => { pushFloatingUiConfig(theme, t); }, [theme, t])` —— 主题切换/语言切换/启动都会重推。t 引用随 locale 变化(确认 useT 返回的 t 在语言切换时引用变化,先读 src/i18n 实现;若 t 引用稳定,则依赖里加 locale)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: automationPipeline.ts 前台 Alert i18n + 去 emoji**
|
||||||
|
|
||||||
|
`src/services/automationPipeline.ts:298` 的 `Alert.alert('🌟 识别到新账单', ...)`:标题/按钮全部改 t();消息体保留原信息结构。该文件是 service 层非组件——确认文件里如何拿 t(若没有,用 `i18n.t(...)` 直接调,读 src/i18n/index.ts 导出了什么)。`showFloatingBill` 调用处(:262)传第 7 个参数 `currency`(用 :225 已提取的 currency 变量)。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 测试 + 验证**
|
||||||
|
|
||||||
|
`tests/floating-ui-config.test.ts`:
|
||||||
|
- `colorToHex`:`'#111318'` 原样;`'rgba(255,255,255,0.08)'` → `#14FFFFFF`;`'rgba(17,19,24,0.4)'` → `#66111318`(alpha 四舍五入 Math.round(a*255))
|
||||||
|
- `buildFloatingUiConfig`:用 lightTheme + 真实 zh t() 构建,断言 colors.accent === '#111318'、labels.billTitle === '调整交易草稿'、所有契约字段非空(遍历 keys)
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm test` → 全绿(i18n parity 测试覆盖新键)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 原生 config 基础设施(FloatingUiConfigStore + bridge 方法 + FloatingTip/Toast 改读)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `plugins/accessibility/android/FloatingUiConfigStore.kt`
|
||||||
|
- Modify: `plugins/accessibility/android/AccessibilityBridgeModule.kt`、`plugins/accessibility/android/FloatingTip.kt`、`plugins/accessibility/android/BillingAccessibilityService.kt`
|
||||||
|
|
||||||
|
- [ ] **Step 1: FloatingUiConfigStore.kt**
|
||||||
|
|
||||||
|
- `data class FloatingUiConfig(...)`:colors/labels 全部字段,默认值 = 现状硬编码(accent `#5E6AD2`、cardBg `#FF050506`? —— **不**:默认值应是「现状视觉」,但现状 cardBg 是 0x8C050506 半透明。默认 cardBg 用 `#F2050506`? 简化:默认值就用现状各硬编码颜色换算成 `#AARRGGBB`/`#RRGGBB` 字符串,逐字段列注释对应原 Kotlin 常量)。labels 默认值 = 现状中文硬编码文案(去 emoji)。
|
||||||
|
- `object FloatingUiConfigStore`:`private const val PREFS = "floating_ui_config"`;`fun save(context: Context, map: ReadableMap)`(遍历契约字段,缺失字段跳过不覆盖,JSONObject 组装后写入);`fun load(context: Context): FloatingUiConfig`(JSONObject 读取,缺失/解析失败逐字段回退默认;`optString`)。颜色字段提供 `fun parseColorOr(value: String, fallback: Int): Int` 工具(`Color.parseColor` try/catch)。
|
||||||
|
- org.json 可用(Android 内置),无需新依赖。
|
||||||
|
|
||||||
|
- [ ] **Step 2: AccessibilityBridgeModule.setFloatingUiConfig**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@ReactMethod
|
||||||
|
fun setFloatingUiConfig(config: ReadableMap, promise: Promise) {
|
||||||
|
try {
|
||||||
|
FloatingUiConfigStore.save(reactContext, config)
|
||||||
|
promise.resolve(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("CONFIG_SAVE_FAIL", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: FloatingTip/RepeatToast 改读 config**
|
||||||
|
|
||||||
|
- FloatingTip.show():背景色 `0xF0333333` → config accent(不透明化:解析后 `or 0xFF000000.toInt()`),文字色 → accentFg,ProgressBar `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、圆角 12dp;confirm=accent 底 accentFg 字 bold;openApp/dismiss=inputBg 底 fgPrimary 字
|
||||||
|
- 所有颜色经 `FloatingUiConfigStore.load(context)` + parseColorOr 兜底;所有文案经 labels
|
||||||
|
|
||||||
|
- [ ] **Step 1: 构造签名 + showFloatingBill 参数**
|
||||||
|
|
||||||
|
`FloatingBillView` 构造加 `initialCurrency: String = "CNY"`;`AccessibilityBridgeModule.showFloatingBill` 加 `currency: String` 参数(direction 后 draftId 前)并透传。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 币种 chip**
|
||||||
|
|
||||||
|
金额行:水平布局,左侧币种 chip(inputBg+border,fgPrimary 字,显示 currentCurrency),右侧金额输入框(weight 1)。点击 chip 在 `listOf("CNY","USD","HKD","JPY","EUR","GBP")` 循环(initialCurrency 不在列表则临时插到首位)。sendSaveEvent/sendOpenAppEvent 的 map 加 `putString("currency", currentCurrency)`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 金额校验改正则 + BigDecimal**
|
||||||
|
|
||||||
|
`afterTextChanged`:`Regex("^\\d+(\\.\\d{1,2})?$")` 匹配且 `BigDecimal(text) > BigDecimal.ZERO` 才启用保存(BigDecimal 构造 try/catch)。禁用时按钮底色 inputBg + fgSecondary 字。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 全面替换颜色与文案 + 自动消失 30s**
|
||||||
|
|
||||||
|
逐段按上面视觉规格重写 show() 与 rebuildChips()(结构不变,只换色值来源、文案来源、尺寸);`15000L` → `30000L`。标签按方向切换的文案(交易分类/收入分类/转入账户、资金来源/存入账户/转出账户)全部走 labels。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 编译验证**(同 T2 Step 5 流程)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: FloatingHelper 重设计 + 位置持久化
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `plugins/accessibility/android/FloatingHelper.kt`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 颜色与文案走 config**
|
||||||
|
|
||||||
|
- 指示竖线:accent(保持 70% 透明度:解析后 alpha 设为 0xB0)
|
||||||
|
- 菜单:实色 cardBg、圆角 12dp、1dp border
|
||||||
|
- 「识别账单」按钮:accent 底 accentFg 字(主操作);「记住此页」:inputBg 底 fgPrimary 字;按压态透明度变化保留(按下时 alpha 0.8)
|
||||||
|
- ScanIconDrawable/PinIconDrawable 颜色:Scan 用 accentFg(在 accent 底按钮上),Pin 用 fgSecondary
|
||||||
|
- 文案 labels.ballOcr / ballRemember / rememberSuccess / rememberFail(带 ': ' + message 拼接)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 位置持久化**
|
||||||
|
|
||||||
|
- SharedPreferences 复用 `billing_accessibility_prefs`:键 `floating_ball_x` / `floating_ball_y`
|
||||||
|
- `show()` 初始化:`params.x/y = prefs.getInt(...)`,无键时用现状默认(0/400);拖动抬起(吸附后)写 prefs
|
||||||
|
- companion 的 lastX/lastY 保留为进程内缓存但初始从 prefs 读(或直接用局部变量+prefs,简化则删 companion——注意多实例语义,BillingAccessibilityService 单例,安全)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 编译验证**(同 T2 Step 5 流程)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 币种链路 JS 消费侧
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/services/automationPipeline.ts`、`src/app/_layout.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: billingConfirmed 处理器用 res.currency**
|
||||||
|
|
||||||
|
读 `src/services/automationPipeline.ts:50-110` 的 handleBillingConfirmed:`const currency = pending?.event?.currency || 'CNY'`(:78)→ 优先 `res.currency`(浮窗用户改过),`res.currency || pending?.event?.currency || 'CNY'`。确认 res 类型定义处加 currency 字段(NativeOpenAppEvent 之类,grep 类型声明一起改)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: billingOpenApp 处理器**
|
||||||
|
|
||||||
|
`src/app/_layout.tsx:255` `const currency = 'CNY'` → `const currency = res.currency || 'CNY'`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm test` → 全绿
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: P6 审计 + 验收
|
||||||
|
|
||||||
|
- [ ] **Step 1: 审计**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "0xFF5E6AD2\|0x995E6AD2\|0xB05E6AD2\|0x1F5E6AD2\|0x405E6AD2" plugins/accessibility/android/ # 无输出(旧靛蓝清零,默认值除外——FloatingUiConfigStore 默认值允许保留并注释)
|
||||||
|
grep -rn '"[^"]*[一-鿿]' plugins/accessibility/android/*.kt | grep -v "FloatingUiConfigStore\|//" # 除 Store 默认值与注释外无硬编码中文 UI 字符串
|
||||||
|
grep -rn "⚠\|📌\|🌟" src/ plugins/ # 无输出
|
||||||
|
grep -n "currency" src/services/accessibilityBridge.ts # showFloatingBill 含 currency
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 全量测试 + typecheck + Kotlin 编译**
|
||||||
|
|
||||||
|
Run: `npm test` → 全绿;`npm run typecheck` → 无错误;`cd android && ./gradlew :app:compileDebugKotlin -q` → 成功
|
||||||
|
|
||||||
|
- [ ] **Step 3: 手工走查(需设备)**
|
||||||
|
|
||||||
|
Run: `npm run android`
|
||||||
|
1. 设置里切换 浅/深主题 → 触发一次浮窗(可用 automation 页调试入口或真实账单)→ 浮窗颜色跟随主题
|
||||||
|
2. 切换英文 → 浮窗/悬浮球/提示文案全英文
|
||||||
|
3. 拖动悬浮球 → 杀进程重启无障碍服务 → 位置保持
|
||||||
|
4. 浮窗点币种 chip 切换 USD → 确认入账 → 交易币种为 USD
|
||||||
|
5. 金额输入 `1e10` / `0` / `0.005` → 保存按钮禁用;`12.50` → 可用
|
||||||
|
6. 浮窗 30 秒无操作自动消失;触摸后不消失
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 终审修订(已实施,以实际代码为准)
|
||||||
|
|
||||||
|
**终审日期**:2026-07-22,审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
|
||||||
|
|
||||||
|
| # | 级别 | 问题 | 修复 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C1 | Critical | FloatingBillView 标题 `setTextColor(colorAccentFg)` — 浅色主题下白卡白字、深色主题下黑底黑字,标题不可见 | 改为 `colorFgPrimary` |
|
||||||
|
| I1 | Important | 容器边框从 accent 派生 alpha 而非用 config.border | 删除 `colorContainerBorder`,直接使用 `colorBorder` |
|
||||||
|
| I2 | Important | FloatingHelper btnRemember 背景从 fgPrimary 派生,计划要求 inputBg | 加 `colorInputBg` 解析,btnRemember 改从 inputBg 派生 |
|
||||||
|
| I3 | Important | 悬浮球菜单背景半透明(65% cardBg),计划要求实色 | 菜单背景直接使用 `colorCardBg`(full opacity),仅描边保留半透明 |
|
||||||
|
| M1 | Minor | FloatingTip 背景未强制不透明化(plan §T2 Step 3) | 加 `or 0xFF000000.toInt()` 保护 |
|
||||||
|
| M2 | Minor | 金额校验先 BigDecimal 解析再正则,正则先匹配可早期短路 | 调整为先 `pattern.matches(text)` 再 `value != null && value > BigDecimal.ZERO` |
|
||||||
|
|
||||||
|
**偏差说明(无需修复)**:
|
||||||
|
- FloatingHelper 菜单背景 / 按钮背景 / 指示线的 alpha 分量组合(`(colorX and 0x00FFFFFF) or alpha`)是原生浮层叠加其他 App 所必需的透明度控制,不是颜色硬编码。颜色 RGB 分量完全来自 config。
|
||||||
|
- FloatingTip 默认 fallback `#FF000000` 是纯黑(非靛蓝),因为 accent 在极浅色主题下是 `#111318`(近黑),fallback 用黑色是安全的极限退化。
|
||||||
|
- ScanIconDrawable laserColor `0xFFF87171` 保留为语义色(扫描红光),不受主题控制。
|
||||||
192
docs/design/ui-redesign-p7-plan.md
Normal file
192
docs/design/ui-redesign-p7-plan.md
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
# P7:权限快捷引导 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax. **不执行任何 git 操作**(用户要求,改动留工作区)。不创建额外任务清单。
|
||||||
|
|
||||||
|
**Goal:** 引导页权限步骤从纯文字改可操作状态清单;automation 页补齐通知监听/短信/存储三项权限的状态检测与快速跳转按钮。不新增原生权限,只加 UI 入口和一座通知监听状态检测 bridge 方法。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景决策
|
||||||
|
|
||||||
|
- **不新增权限**。所有权限已在 AndroidManifest 中声明(通过 Config Plugin),P7 只加 UI 入口。
|
||||||
|
- **通知监听状态检测**需要一个原生 bridge 方法(`isNotificationListenerEnabled`),因为 RN 侧无法直接检查。用 `NotificationManager.getEnabledListenerPackages()`。
|
||||||
|
- **短信 + 存储**用 React Native 内置 `PermissionsAndroid` API。
|
||||||
|
- **引导页 UI** 沿用现有 Card + Button 组件,与 P1-P5 设计系统一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 新增桥接方法 `isNotificationListenerEnabled`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `plugins/accessibility/android/AccessibilityBridgeModule.kt`
|
||||||
|
- Modify: `src/services/accessibilityBridge.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: AccessibilityBridgeModule.kt 加方法**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@ReactMethod
|
||||||
|
fun isNotificationListenerEnabled(promise: Promise) {
|
||||||
|
try {
|
||||||
|
val pm = reactContext.packageManager
|
||||||
|
val enabledListeners = android.app.NotificationManager::class.java
|
||||||
|
.getMethod("getEnabledListenerPackages")
|
||||||
|
.invoke(reactContext.getSystemService(Context.NOTIFICATION_SERVICE)) as? List<String>
|
||||||
|
val myPkg = reactContext.packageName
|
||||||
|
promise.resolve(enabledListeners?.any { it.startsWith(myPkg) } == true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.resolve(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:`getEnabledListenerPackages()` 在 Android 11+ 需 `isNotificationListenerEnabled(ComponentName)`;优先用反射避免 API level lint 错误,反射失败返回 false。
|
||||||
|
|
||||||
|
- [ ] **Step 2: JS 侧 bridge 接口**
|
||||||
|
|
||||||
|
`NativeAccessibilityBridge` 加 `isNotificationListenerEnabled(): Promise<boolean>;`
|
||||||
|
|
||||||
|
- [ ] **Step 3: 编译验证**
|
||||||
|
|
||||||
|
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 引导页权限步骤(`_onboarding.tsx` 重写 permissions 步骤)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/_onboarding.tsx`
|
||||||
|
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`(少量新键)
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n 新键(zh/en 对等)**
|
||||||
|
|
||||||
|
在 `onboarding` section 加:
|
||||||
|
```
|
||||||
|
permAccessibility 无障碍服务 / Accessibility Service
|
||||||
|
permNotification 通知监听 / Notification Listener
|
||||||
|
permOverlay 悬浮窗 / Overlay
|
||||||
|
permSms 短信读取 / SMS
|
||||||
|
permStorage 相册/存储 / Photos & Storage
|
||||||
|
permGranted 已授权 / Granted
|
||||||
|
permNotGranted 未授权 / Not Granted
|
||||||
|
permOpenSettings 前往设置 / Open Settings
|
||||||
|
permRequest 请求权限 / Request
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 重写 permissions 步骤**
|
||||||
|
|
||||||
|
替换 `_onboarding.tsx:48` 的 `key: 'permissions'` 步骤内容。在当前 description 下方加权限状态清单(Card 包裹,每行:图标 + 名称 + 状态点 + 操作按钮)。
|
||||||
|
|
||||||
|
权限列表:
|
||||||
|
1. **无障碍服务**(`BIND_ACCESSIBILITY_SERVICE`)—— 最核心。检测 `serviceRunning`(从 `getAccessibilityBridge().isServiceRunning()` 异步取)。未授权 → 跳转 `ACCESSIBILITY_SETTINGS`
|
||||||
|
2. **通知监听**(`BIND_NOTIFICATION_LISTENER_SERVICE`)—— 检测 `bridge.isNotificationListenerEnabled()`。未授权 → 跳转 `ACTION_NOTIFICATION_LISTENER_SETTINGS`
|
||||||
|
3. **短信**(`RECEIVE_SMS`)—— `PermissionsAndroid.check()`。未授权 → `PermissionsAndroid.request()`
|
||||||
|
4. **存储**(`READ_MEDIA_IMAGES`,Android 13+ 用 `READ_MEDIA_IMAGES`,否则 `READ_EXTERNAL_STORAGE`)—— 同上
|
||||||
|
|
||||||
|
**实现细节**:
|
||||||
|
- `const [permStates, setPermStates] = useState<Record<string, boolean | null>>({})` —— null = 加载中
|
||||||
|
- `useEffect(() => { checkAllPermissions(); }, [])` —— 步骤切到 permissions 时触发
|
||||||
|
- `checkAllPermissions`:调 `PermissionsAndroid.check()` 检查短信和存储;调 bridge 检查无障碍和通知监听
|
||||||
|
- 每行渲染:`Ionicons` 图标 + `permLabel` + 右侧状态文字(绿「已授权」/ 灰「未授权」)+ 未授权时显示操作按钮
|
||||||
|
- 「全部已授权」时显示一个大绿色勾 + 提示文字,按钮不显示
|
||||||
|
|
||||||
|
**权限 API 版本适配**:
|
||||||
|
- `PermissionsAndroid.PERMISSIONS` 里 Android 13+ 是 `READ_MEDIA_IMAGES`,低版本用 `READ_EXTERNAL_STORAGE`
|
||||||
|
- 统一用 `Platform.Version >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE'`
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm test` → 全绿
|
||||||
|
Run: `grep -rn "onboarding.perm" src/i18n/zh.ts src/i18n/en.ts` → 确认键对等
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: automation 页补齐权限入口
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/automation/index.tsx`
|
||||||
|
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`(少量新键)
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n 新键**
|
||||||
|
|
||||||
|
在 `automation` section 加:
|
||||||
|
```
|
||||||
|
notificationTitle 通知监听服务 / Notification Listener
|
||||||
|
notificationEnabled 已启用 / Enabled
|
||||||
|
notificationDisabled 未启用 / Disabled
|
||||||
|
notificationOpenSettings 前往系统设置 / Open Settings
|
||||||
|
smsPermissionTitle 短信读取权限 / SMS Permission
|
||||||
|
smsPermissionGranted 已授权 / Granted
|
||||||
|
smsPermissionRequest 请求权限 / Request
|
||||||
|
storagePermissionTitle 存储权限 / Storage Permission
|
||||||
|
storagePermissionGranted 已授权 / Granted
|
||||||
|
storagePermissionRequest 请求权限 / Request
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: automation 页加权限卡片**
|
||||||
|
|
||||||
|
在现有「无障碍服务状态与控制」Card **之后**加一个新 Card:`<Card title={t('automation.otherPermissions')}>`。
|
||||||
|
|
||||||
|
内容三行:
|
||||||
|
1. **通知监听**:异步 `bridge.isNotificationListenerEnabled()` → 状态文字 + 未启用时「前往系统设置」按钮(`Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS')`)
|
||||||
|
2. **短信**:`PermissionsAndroid.check('android.permission.RECEIVE_SMS')` + `PermissionsAndroid.request()`
|
||||||
|
3. **存储**:`PermissionsAndroid.check(StoragePermission)` + `PermissionsAndroid.request()`
|
||||||
|
|
||||||
|
**实现模式**:
|
||||||
|
```tsx
|
||||||
|
const [notifEnabled, setNotifEnabled] = useState(false);
|
||||||
|
const [smsGranted, setSmsGranted] = useState(false);
|
||||||
|
const [storageGranted, setStorageGranted] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
// 异步获取各权限状态
|
||||||
|
checkNotif(); checkSms(); checkStorage();
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
每行渲染:`Ionicons` 图标 + 权限名称 + 状态文字 + 未授权时的按钮。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm test` → 全绿
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 编译 + 测试 + 终审
|
||||||
|
|
||||||
|
- [ ] **Step 1: 编译验证**
|
||||||
|
|
||||||
|
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
|
||||||
|
|
||||||
|
- [ ] **Step 2: 全量测试**
|
||||||
|
|
||||||
|
`npm test` → 全绿;`npm run typecheck` → 0 错误
|
||||||
|
|
||||||
|
- [ ] **Step 3: 审计**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
|
||||||
|
grep -rn "fontFamily" src/app/_onboarding.tsx src/app/automation/index.tsx # 无输出
|
||||||
|
grep -rnE "📊|💰|💸|📝|🔄|🎉|✨|⚠|📌|🌟" src/i18n/ src/app/_onboarding.tsx src/app/automation/index.tsx # 无输出(预存 i18n fallbackWarn ⚠ 除外)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 手工走查(需设备)**
|
||||||
|
|
||||||
|
1. 清数据 → 启动应用 → 引导页第 5 步(权限)→ 看到 4 个权限状态
|
||||||
|
2. 点未授权的无障碍 → 跳系统无障碍设置 → 返回看到状态已变
|
||||||
|
3. 点未授权的通知监听 → 跳系统通知设置
|
||||||
|
4. 点短信 → 弹出系统权限对话框
|
||||||
|
5. 引导页全部完成后 → 进入 automation 页 → 看到新增的权限卡片
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 终审修订(已实施,以实际代码为准)
|
||||||
|
|
||||||
|
**终审日期**:2026-07-22,审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
|
||||||
|
|
||||||
|
| # | 级别 | 问题 | 处理 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| — | — | 无 Critical / Important 发现 | — |
|
||||||
|
|
||||||
|
**偏差说明**:
|
||||||
|
1. `onboarding.permOverlay`(悬浮窗)未加入引导页——计划 Task 2 决策列为可选项,实际未实现。不影响核心功能(sideload 自动授予 + 无障碍运行时浮窗走 ACCESSIBILITY_OVERLAY 不依赖此权限)。
|
||||||
|
2. T3 中 `Platform.Version >= 33` 改为 `Number(Platform.Version) >= 33`——React Native 的 `Platform.Version` 类型为 `string | number`,直接比较会 TS 报错。
|
||||||
|
3. T1 的 `isNotificationListenerEnabled` 使用反射而非 `enabledListenerPackages` 属性——minSdk=24 < API 27,反射是兼容方案。
|
||||||
338
docs/design/ui-redesign-p8-plan.md
Normal file
338
docs/design/ui-redesign-p8-plan.md
Normal 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 拷贝到 documentDirectory,OcrModule 从 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:「自动记账层级」**
|
||||||
|
- 三行 Switch:L1 规则匹配(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`:三选一 chip(openai/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
68
docs/development.md
Normal 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
31
plugins/README.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Expo Config Plugins
|
||||||
|
|
||||||
|
本目录包含所有原生模块的 Expo Config Plugin。由于 `android/` 目录由 `expo prebuild` 自动生成且被 Git 忽略,所有原生代码必须以 Config Plugin 形式存在。
|
||||||
|
|
||||||
|
## 插件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins/<name>/
|
||||||
|
app.plugin.js # prebuild 时执行,注入 Kotlin/XML、修改 gradle、注册服务
|
||||||
|
package.json # 插件元数据
|
||||||
|
android/*.kt # 原生 Kotlin 代码
|
||||||
|
assets/ # 模型文件等静态资源(仅 ppocr)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前插件
|
||||||
|
|
||||||
|
| 插件 | 功能 | 备注 |
|
||||||
|
|------|------|------|
|
||||||
|
| `ppocr` | PP-OCRv6 检测+识别 | ONNX Runtime,模型在 `assets/` |
|
||||||
|
| `accessibility` | 无障碍服务账单抓取 + 悬浮窗 + OCR 贴片 | 仅侧载场景 |
|
||||||
|
| `notification-listener` | 通知栏账单监听 | |
|
||||||
|
| `sms-receiver` | 短信账单解析 | |
|
||||||
|
| `screenshot-monitor` | 截图触发 OCR | |
|
||||||
|
| `size-optimization` | ABI 分包 + NDK 版本统一 | 仅影响 gradle 配置 |
|
||||||
|
|
||||||
|
## 开发须知
|
||||||
|
|
||||||
|
1. 每个插件函数**必须 `return config`**,否则后续插件会崩溃
|
||||||
|
2. 修改插件后需重新运行 `npx expo prebuild --platform android`
|
||||||
|
3. 原生服务通过 `NativeEventEmitter` 向 JS 层推送事件,**不直接写入**数据库或文件
|
||||||
|
4. `ppocr` 依赖 `onnxruntime-android:1.20.0`,prebuild 后请验证版本
|
||||||
@ -1,5 +1,6 @@
|
|||||||
package com.beancount.mobile.accessibility
|
package com.beancount.mobile.accessibility
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
import com.facebook.react.bridge.ReactMethod
|
import com.facebook.react.bridge.ReactMethod
|
||||||
@ -7,6 +8,7 @@ import com.facebook.react.bridge.Promise
|
|||||||
import com.facebook.react.bridge.WritableNativeArray
|
import com.facebook.react.bridge.WritableNativeArray
|
||||||
import com.facebook.react.bridge.WritableNativeMap
|
import com.facebook.react.bridge.WritableNativeMap
|
||||||
import com.facebook.react.bridge.ReadableArray
|
import com.facebook.react.bridge.ReadableArray
|
||||||
|
import com.facebook.react.bridge.ReadableMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 无障碍服务 RN 桥接模块(plan.md「3.6 无障碍服务」JS 接线)。
|
* 无障碍服务 RN 桥接模块(plan.md「3.6 无障碍服务」JS 接线)。
|
||||||
@ -20,6 +22,10 @@ import com.facebook.react.bridge.ReadableArray
|
|||||||
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
|
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
|
||||||
ReactContextBaseJavaModule(reactContext) {
|
ReactContextBaseJavaModule(reactContext) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AccessibilityBridge"
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
ReactContextHolder.context = reactContext
|
ReactContextHolder.context = reactContext
|
||||||
}
|
}
|
||||||
@ -188,17 +194,18 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
/** 显示账单浮窗(直接在当前其他应用上方渲染,不返回 App 内) */
|
/** 显示账单浮窗(直接在当前其他应用上方渲染,不返回 App 内) */
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun showFloatingBill(
|
fun showFloatingBill(
|
||||||
amount: String,
|
amount: String,
|
||||||
merchant: String,
|
merchant: String,
|
||||||
time: String,
|
time: String,
|
||||||
packageName: String,
|
packageName: String,
|
||||||
categories: ReadableArray,
|
categories: ReadableArray,
|
||||||
accounts: ReadableArray,
|
accounts: ReadableArray,
|
||||||
direction: String,
|
direction: String,
|
||||||
|
currency: String,
|
||||||
draftId: String,
|
draftId: String,
|
||||||
promise: Promise
|
promise: Promise
|
||||||
) {
|
) {
|
||||||
val context = reactContext.currentActivity ?: BillingAccessibilityService.instance
|
val context = BillingAccessibilityService.instance ?: reactContext.currentActivity
|
||||||
if (context == null) {
|
if (context == null) {
|
||||||
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
|
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
|
||||||
return
|
return
|
||||||
@ -222,7 +229,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
|
|
||||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||||
try {
|
try {
|
||||||
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction)
|
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction, currency)
|
||||||
floatingView.show()
|
floatingView.show()
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@ -247,4 +254,71 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
}
|
}
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从 APK assets 复制 OCR 模型文件到 filesDir/ocr_models_v6。 */
|
||||||
|
@ReactMethod
|
||||||
|
fun copyOcrModelsFromAssets(promise: Promise) {
|
||||||
|
try {
|
||||||
|
val destDir = java.io.File(reactContext.filesDir, "ocr_models_v6")
|
||||||
|
if (!destDir.exists()) destDir.mkdirs()
|
||||||
|
val assetManager = reactContext.assets
|
||||||
|
val files = listOf("ppocrv6_det.onnx", "ppocrv6_rec.onnx", "ppocrv6_dict.txt")
|
||||||
|
for (filename in files) {
|
||||||
|
val destFile = java.io.File(destDir, filename)
|
||||||
|
if (destFile.exists() && destFile.length() > 0) continue
|
||||||
|
assetManager.open(filename).use { input ->
|
||||||
|
java.io.FileOutputStream(destFile).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promise.resolve(destDir.absolutePath)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("COPY_MODEL_FAIL", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查通知监听服务是否已启用(反射调用 getEnabledListenerPackages,避免 API level lint 错误)。 */
|
||||||
|
@ReactMethod
|
||||||
|
fun isNotificationListenerEnabled(promise: Promise) {
|
||||||
|
try {
|
||||||
|
// 通过 Settings.Secure 读取系统设置(公开 API,无需反射,不受 hidden API 限制)
|
||||||
|
// enabled_notification_listeners 格式:
|
||||||
|
// "com.pkg1/com.pkg1.Service:com.pkg2/com.pkg2.Service"
|
||||||
|
val flat = android.provider.Settings.Secure.getString(
|
||||||
|
reactContext.contentResolver,
|
||||||
|
"enabled_notification_listeners"
|
||||||
|
) ?: ""
|
||||||
|
val myPkg = reactContext.packageName
|
||||||
|
promise.resolve(flat.split(":").any { it.startsWith(myPkg) })
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "isNotificationListenerEnabled 检测失败", e)
|
||||||
|
promise.resolve(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查悬浮窗权限(SYSTEM_ALERT_WINDOW)是否已授予。 */
|
||||||
|
@ReactMethod
|
||||||
|
fun canDrawOverlays(promise: Promise) {
|
||||||
|
try {
|
||||||
|
promise.resolve(android.provider.Settings.canDrawOverlays(reactContext))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.resolve(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下发浮层 UI 配置(颜色 + 文案)。
|
||||||
|
* JS 侧从 theme tokens + i18n 构建 config,
|
||||||
|
* 原生存 SharedPreferences,三个浮层组件读取。
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun setFloatingUiConfig(config: ReadableMap, promise: Promise) {
|
||||||
|
try {
|
||||||
|
FloatingUiConfigStore.save(reactContext, config)
|
||||||
|
promise.resolve(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("CONFIG_SAVE_FAIL", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -192,7 +192,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
handler.post {
|
handler.post {
|
||||||
val shouldShow = floatingBallEnabled
|
val shouldShow = floatingBallEnabled
|
||||||
&& pkg != null
|
&& pkg != null
|
||||||
&& !filterPackage(pkg, "")
|
&& !filterPackage(pkg, topActivity ?: "")
|
||||||
&& !isLandscape()
|
&& !isLandscape()
|
||||||
|
|
||||||
if (shouldShow) {
|
if (shouldShow) {
|
||||||
@ -380,11 +380,12 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
val pkg = topPackage ?: return
|
val pkg = topPackage ?: return
|
||||||
val activity = topActivity ?: ""
|
val activity = topActivity ?: ""
|
||||||
val sig = "$pkg|$activity"
|
val sig = "$pkg|$activity"
|
||||||
|
val uiLabels = FloatingUiConfigStore.load(this).labels
|
||||||
if (pageSignatures.add(sig)) {
|
if (pageSignatures.add(sig)) {
|
||||||
savePageSignatures()
|
savePageSignatures()
|
||||||
Log.i(TAG, "已记住页面: $sig")
|
Log.i(TAG, "已记住页面: $sig")
|
||||||
handler.post {
|
handler.post {
|
||||||
android.widget.Toast.makeText(this, "已记住页面签名:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
android.widget.Toast.makeText(this, "${uiLabels.pageRemembered}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 抓取并提取屏幕所有无障碍文本
|
// 抓取并提取屏幕所有无障碍文本
|
||||||
@ -416,7 +417,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
handler.post {
|
handler.post {
|
||||||
android.widget.Toast.makeText(this, "该页面签名已存在:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
android.widget.Toast.makeText(this, "${uiLabels.pageSignatureExists}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -507,6 +508,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
val p = pkg.lowercase()
|
val p = pkg.lowercase()
|
||||||
// 自身 App:只放行 MainActivity,避免悬浮球容器触发自毁式关闭
|
// 自身 App:只放行 MainActivity,避免悬浮球容器触发自毁式关闭
|
||||||
if (pkg == packageName) {
|
if (pkg == packageName) {
|
||||||
|
if (className.isEmpty()) return false
|
||||||
return !className.endsWith(".MainActivity")
|
return !className.endsWith(".MainActivity")
|
||||||
}
|
}
|
||||||
// 桌面启动器(悬浮球在桌面无意义)
|
// 桌面启动器(悬浮球在桌面无意义)
|
||||||
|
|||||||
@ -17,9 +17,11 @@ import android.widget.LinearLayout
|
|||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import com.facebook.react.bridge.WritableNativeMap
|
import com.facebook.react.bridge.WritableNativeMap
|
||||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 浮窗账单提示(直接呈现高度优化、支持三方向切换的修改入账面板)。
|
* 浮窗账单提示(直接呈现高度优化、支持三方向切换的修改入账面板)。
|
||||||
|
* P6 重设计:颜色/文案走 FloatingUiConfigStore,新增币种 chip,金额校验改正则+BigDecimal。
|
||||||
*/
|
*/
|
||||||
class FloatingBillView(
|
class FloatingBillView(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
@ -30,12 +32,27 @@ class FloatingBillView(
|
|||||||
private val packageName: String,
|
private val packageName: String,
|
||||||
private val categories: List<Map<String, String>> = emptyList(),
|
private val categories: List<Map<String, String>> = emptyList(),
|
||||||
private val accounts: List<String> = emptyList(),
|
private val accounts: List<String> = emptyList(),
|
||||||
private val initialDirection: String = "expense"
|
private val initialDirection: String = "expense",
|
||||||
|
private val initialCurrency: String = "CNY"
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "FloatingBillView"
|
private const val TAG = "FloatingBillView"
|
||||||
|
private val DEFAULT_CURRENCY_LIST = listOf("CNY", "USD", "HKD", "JPY", "EUR", "GBP")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 JS 下发的配置读取颜色与文案(缺失字段回退默认值,保证旧 JS 行为不变)
|
||||||
|
private val config = FloatingUiConfigStore.load(context)
|
||||||
|
|
||||||
|
// 显示币种列表;若 initialCurrency 不在默认列表中则临时插入首位
|
||||||
|
private val currencyList: List<String> = run {
|
||||||
|
val list = DEFAULT_CURRENCY_LIST.toMutableList()
|
||||||
|
if (!list.contains(initialCurrency)) {
|
||||||
|
list.add(0, initialCurrency)
|
||||||
|
}
|
||||||
|
list
|
||||||
|
}
|
||||||
|
private var currentCurrency: String = initialCurrency
|
||||||
|
|
||||||
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||||
private var view: View? = null
|
private var view: View? = null
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
@ -49,6 +66,20 @@ class FloatingBillView(
|
|||||||
private var accountContainer: LinearLayout? = null
|
private var accountContainer: LinearLayout? = null
|
||||||
private var saveBtn: Button? = null
|
private var saveBtn: Button? = null
|
||||||
|
|
||||||
|
// 预解析常用颜色(parseColorOr 异常时回退默认值)
|
||||||
|
private val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
|
||||||
|
private val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
|
||||||
|
private val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
|
||||||
|
private val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
|
||||||
|
private val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
|
||||||
|
private val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
|
||||||
|
private val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
|
||||||
|
private val colorIncome = FloatingUiConfigStore.parseColorOr(config.colors.income, "#FF10B981")
|
||||||
|
private val colorExpense = FloatingUiConfigStore.parseColorOr(config.colors.expense, "#FFE11D48")
|
||||||
|
private val colorTransfer = FloatingUiConfigStore.parseColorOr(config.colors.transfer, "#FF10B981")
|
||||||
|
|
||||||
|
// 容器边框直接使用 config border(不再从 accent 派生 alpha)
|
||||||
|
|
||||||
private fun dp(value: Float): Int {
|
private fun dp(value: Float): Int {
|
||||||
val density = context.resources.displayMetrics.density
|
val density = context.resources.displayMetrics.density
|
||||||
return (value * density).toInt()
|
return (value * density).toInt()
|
||||||
@ -74,29 +105,34 @@ class FloatingBillView(
|
|||||||
currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense"
|
currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense"
|
||||||
|
|
||||||
// 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部
|
// 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部
|
||||||
|
val overlayType = if (context is android.accessibilityservice.AccessibilityService) {
|
||||||
|
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY
|
||||||
|
} else {
|
||||||
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||||
|
}
|
||||||
val windowParams = WindowManager.LayoutParams(
|
val windowParams = WindowManager.LayoutParams(
|
||||||
dp(310f),
|
dp(310f),
|
||||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
overlayType,
|
||||||
0, // 0 标志代表可获取焦点
|
0, // 0 标志代表可获取焦点
|
||||||
PixelFormat.TRANSLUCENT
|
PixelFormat.TRANSLUCENT
|
||||||
).apply {
|
).apply {
|
||||||
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
|
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
|
||||||
y = dp(12f) // 尽量靠下以留出上方账单对照区
|
y = dp(48f) // 留出底部导航栏空间 + 按钮/键盘安全区
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 使用 55% 透明度 OLED 磨砂效果背景,发光靛蓝细边框
|
// 2. 实色卡片背景(不再半透明磨砂),圆角 16dp,1dp 描边
|
||||||
val containerBg = GradientDrawable().apply {
|
val containerBg = GradientDrawable().apply {
|
||||||
shape = GradientDrawable.RECTANGLE
|
shape = GradientDrawable.RECTANGLE
|
||||||
cornerRadius = dp(14f).toFloat()
|
cornerRadius = dp(16f).toFloat()
|
||||||
setColor(0x8C050506.toInt()) // 55% 透明度 OLED 黑色
|
setColor(colorCardBg)
|
||||||
setStroke(dp(1.2f), 0x995E6AD2.toInt()) // 60% 透明度靛蓝发光边框
|
setStroke(dp(1f), colorBorder) // 使用 config border 颜色
|
||||||
}
|
}
|
||||||
|
|
||||||
val container = LinearLayout(context).apply {
|
val container = LinearLayout(context).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
background = containerBg
|
background = containerBg
|
||||||
setPadding(dp(12f), dp(8f), dp(12f), dp(8f))
|
setPadding(dp(16f), dp(16f), dp(16f), dp(16f))
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
@ -114,9 +150,9 @@ class FloatingBillView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val titleText = TextView(context).apply {
|
val titleText = TextView(context).apply {
|
||||||
text = "调整交易草稿"
|
text = config.labels.billTitle
|
||||||
textSize = 12f
|
textSize = 13f
|
||||||
setTextColor(0xFF98A2FF.toInt())
|
setTextColor(colorFgPrimary)
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
}
|
}
|
||||||
@ -126,12 +162,12 @@ class FloatingBillView(
|
|||||||
val segmentContainer = LinearLayout(context).apply {
|
val segmentContainer = LinearLayout(context).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
background = GradientDrawable().apply {
|
background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(4f).toFloat()
|
cornerRadius = dp(6f).toFloat()
|
||||||
setColor(0x20FFFFFF.toInt()) // 12% white opacity
|
setColor((colorFgPrimary and 0x00FFFFFF) or 0x12000000) // 7% fg overlay
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val tabTexts = listOf("支出", "收入", "转账")
|
val tabTexts = listOf(config.labels.dirExpense, config.labels.dirIncome, config.labels.dirTransfer)
|
||||||
val tabDirections = listOf("expense", "income", "transfer")
|
val tabDirections = listOf("expense", "income", "transfer")
|
||||||
val tabViews = mutableListOf<TextView>()
|
val tabViews = mutableListOf<TextView>()
|
||||||
|
|
||||||
@ -139,11 +175,11 @@ class FloatingBillView(
|
|||||||
for (i in tabViews.indices) {
|
for (i in tabViews.indices) {
|
||||||
val active = tabDirections[i] == currentDirection
|
val active = tabDirections[i] == currentDirection
|
||||||
tabViews[i].apply {
|
tabViews[i].apply {
|
||||||
setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
background = if (active) {
|
background = if (active) {
|
||||||
GradientDrawable().apply {
|
GradientDrawable().apply {
|
||||||
cornerRadius = dp(4f).toFloat()
|
cornerRadius = dp(6f).toFloat()
|
||||||
setColor(0xFF5E6AD2.toInt()) // Indigo highlight
|
setColor(colorAccent)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@ -173,40 +209,75 @@ class FloatingBillView(
|
|||||||
headerLayout.addView(segmentContainer)
|
headerLayout.addView(segmentContainer)
|
||||||
container.addView(headerLayout)
|
container.addView(headerLayout)
|
||||||
|
|
||||||
// 金额编辑
|
// 金额行:币种 chip(左)+ 金额输入框(右)
|
||||||
val amountLabel = TextView(context).apply {
|
val amountLabel = TextView(context).apply {
|
||||||
text = "金额"
|
text = config.labels.amountLabel
|
||||||
textSize = 9f
|
textSize = 10f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
setTextColor(0xFF98A2FF.toInt())
|
setTextColor(colorFgSecondary)
|
||||||
setPadding(0, dp(4f), 0, dp(1f))
|
setPadding(0, dp(8f), 0, dp(1f))
|
||||||
}
|
}
|
||||||
container.addView(amountLabel)
|
container.addView(amountLabel)
|
||||||
|
|
||||||
val amountInput = EditText(context).apply {
|
val amountRow = LinearLayout(context).apply {
|
||||||
textSize = 13f
|
orientation = LinearLayout.HORIZONTAL
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
background = GradientDrawable().apply {
|
|
||||||
setColor(0x4012131A.toInt()) // 25% 半透底色
|
|
||||||
cornerRadius = dp(6f).toFloat()
|
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
|
||||||
}
|
|
||||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
|
||||||
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
container.addView(amountInput)
|
|
||||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
|
// 币种 chip
|
||||||
|
val currencyChip = TextView(context).apply {
|
||||||
|
text = currentCurrency
|
||||||
|
textSize = 11f
|
||||||
|
setTextColor(colorFgSecondary)
|
||||||
|
setPadding(dp(8f), dp(4f), dp(8f), dp(4f))
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.RECTANGLE
|
||||||
|
cornerRadius = dp(12f).toFloat()
|
||||||
|
setColor(colorInputBg)
|
||||||
|
setStroke(dp(1f), colorBorder)
|
||||||
|
}
|
||||||
|
setOnClickListener {
|
||||||
|
val idx = currencyList.indexOf(currentCurrency)
|
||||||
|
currentCurrency = currencyList[(idx + 1) % currencyList.size]
|
||||||
|
text = currentCurrency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
amountRow.addView(currencyChip)
|
||||||
|
|
||||||
|
val amountInput = EditText(context).apply {
|
||||||
|
textSize = 13f
|
||||||
|
setTextColor(colorFgPrimary)
|
||||||
|
setHintTextColor(colorFgSecondary)
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
setColor(colorInputBg)
|
||||||
|
cornerRadius = dp(10f).toFloat()
|
||||||
|
setStroke(dp(1f), colorBorder)
|
||||||
|
}
|
||||||
|
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
|
||||||
|
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
0,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
1f
|
||||||
|
).apply {
|
||||||
|
leftMargin = dp(8f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
amountRow.addView(amountInput)
|
||||||
|
container.addView(amountRow)
|
||||||
|
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
||||||
|
|
||||||
// 商户编辑
|
// 商户编辑
|
||||||
val merchantLabel = TextView(context).apply {
|
val merchantLabel = TextView(context).apply {
|
||||||
text = "交易对手"
|
text = config.labels.payeeLabel
|
||||||
textSize = 9f
|
textSize = 10f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
setTextColor(0xFF98A2FF.toInt())
|
setTextColor(colorFgSecondary)
|
||||||
setPadding(0, 0, 0, dp(1f))
|
setPadding(0, 0, 0, dp(1f))
|
||||||
}
|
}
|
||||||
container.addView(merchantLabel)
|
container.addView(merchantLabel)
|
||||||
@ -214,56 +285,57 @@ class FloatingBillView(
|
|||||||
val merchantInput = EditText(context).apply {
|
val merchantInput = EditText(context).apply {
|
||||||
setText(merchant)
|
setText(merchant)
|
||||||
textSize = 12f
|
textSize = 12f
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
setTextColor(colorFgPrimary)
|
||||||
|
setHintTextColor(colorFgSecondary)
|
||||||
background = GradientDrawable().apply {
|
background = GradientDrawable().apply {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(10f).toFloat()
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
container.addView(merchantInput)
|
container.addView(merchantInput)
|
||||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
|
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
||||||
|
|
||||||
// 叙述备注编辑
|
// 叙述备注编辑
|
||||||
val narrationLabel = TextView(context).apply {
|
val narrationLabel = TextView(context).apply {
|
||||||
text = "描述/备注"
|
text = config.labels.narrationLabel
|
||||||
textSize = 9f
|
textSize = 10f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
setTextColor(0xFF98A2FF.toInt())
|
setTextColor(colorFgSecondary)
|
||||||
setPadding(0, 0, 0, dp(1f))
|
setPadding(0, 0, 0, dp(1f))
|
||||||
}
|
}
|
||||||
container.addView(narrationLabel)
|
container.addView(narrationLabel)
|
||||||
|
|
||||||
val narrationInput = EditText(context).apply {
|
val narrationInput = EditText(context).apply {
|
||||||
hint = "输入交易叙述"
|
hint = config.labels.narrationHint
|
||||||
setHintTextColor(0xFF6B7280.toInt())
|
setHintTextColor(colorFgSecondary)
|
||||||
textSize = 12f
|
textSize = 12f
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
setTextColor(colorFgPrimary)
|
||||||
background = GradientDrawable().apply {
|
background = GradientDrawable().apply {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(10f).toFloat()
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
container.addView(narrationInput)
|
container.addView(narrationInput)
|
||||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
|
||||||
|
|
||||||
// Row 1 分类/转入选择
|
// Row 1 分类/转入选择
|
||||||
categoryLabel = TextView(context).apply {
|
categoryLabel = TextView(context).apply {
|
||||||
text = "交易分类"
|
text = config.labels.categoryExpense
|
||||||
textSize = 9f
|
textSize = 10f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
setTextColor(0xFF98A2FF.toInt())
|
setTextColor(colorFgSecondary)
|
||||||
setPadding(0, 0, 0, dp(2f))
|
setPadding(0, 0, 0, dp(2f))
|
||||||
}
|
}
|
||||||
container.addView(categoryLabel)
|
container.addView(categoryLabel)
|
||||||
@ -281,14 +353,14 @@ class FloatingBillView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
container.addView(categoryScroll)
|
container.addView(categoryScroll)
|
||||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
|
||||||
|
|
||||||
// Row 2 资金出入账户选择
|
// Row 2 资金出入账户选择
|
||||||
accountLabel = TextView(context).apply {
|
accountLabel = TextView(context).apply {
|
||||||
text = "资金来源账户"
|
text = config.labels.accountExpense
|
||||||
textSize = 9f
|
textSize = 10f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
setTextColor(0xFF98A2FF.toInt())
|
setTextColor(colorFgSecondary)
|
||||||
setPadding(0, 0, 0, dp(2f))
|
setPadding(0, 0, 0, dp(2f))
|
||||||
}
|
}
|
||||||
container.addView(accountLabel)
|
container.addView(accountLabel)
|
||||||
@ -306,7 +378,7 @@ class FloatingBillView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
container.addView(accountScroll)
|
container.addView(accountScroll)
|
||||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(8f)) })
|
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(10f)) })
|
||||||
|
|
||||||
// 底部操作栏
|
// 底部操作栏
|
||||||
val btnContainer = LinearLayout(context).apply {
|
val btnContainer = LinearLayout(context).apply {
|
||||||
@ -320,16 +392,16 @@ class FloatingBillView(
|
|||||||
|
|
||||||
// 1) 打开应用按钮
|
// 1) 打开应用按钮
|
||||||
val openAppBtn = Button(context).apply {
|
val openAppBtn = Button(context).apply {
|
||||||
text = "打开应用"
|
text = config.labels.openApp
|
||||||
setTextColor(0xFFD1D5DB.toInt())
|
setTextColor(colorFgPrimary)
|
||||||
background = GradientDrawable().apply {
|
background = GradientDrawable().apply {
|
||||||
shape = GradientDrawable.RECTANGLE
|
shape = GradientDrawable.RECTANGLE
|
||||||
cornerRadius = dp(8f).toFloat()
|
cornerRadius = dp(12f).toFloat()
|
||||||
setColor(0xFF1F2937.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0xFF374151.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
textSize = 11f
|
textSize = 11f
|
||||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
|
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
val newAmount = amountInput.text.toString().trim()
|
val newAmount = amountInput.text.toString().trim()
|
||||||
val newPayee = merchantInput.text.toString().trim()
|
val newPayee = merchantInput.text.toString().trim()
|
||||||
@ -342,18 +414,17 @@ class FloatingBillView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2) 忽略按钮
|
// 2) 忽略按钮
|
||||||
val cancelBg = GradientDrawable().apply {
|
|
||||||
shape = GradientDrawable.RECTANGLE
|
|
||||||
cornerRadius = dp(8f).toFloat()
|
|
||||||
setColor(0xFF1F2937.toInt())
|
|
||||||
setStroke(dp(1f), 0xFF374151.toInt())
|
|
||||||
}
|
|
||||||
val cancelBtn = Button(context).apply {
|
val cancelBtn = Button(context).apply {
|
||||||
text = "忽略"
|
text = config.labels.dismiss
|
||||||
setTextColor(0xFFD1D5DB.toInt())
|
setTextColor(colorFgPrimary)
|
||||||
background = cancelBg
|
background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.RECTANGLE
|
||||||
|
cornerRadius = dp(12f).toFloat()
|
||||||
|
setColor(colorInputBg)
|
||||||
|
setStroke(dp(1f), colorBorder)
|
||||||
|
}
|
||||||
textSize = 11f
|
textSize = 11f
|
||||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
|
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
sendCancelEvent()
|
sendCancelEvent()
|
||||||
dismiss()
|
dismiss()
|
||||||
@ -361,18 +432,17 @@ class FloatingBillView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3) 确认入账按钮
|
// 3) 确认入账按钮
|
||||||
val saveBg = GradientDrawable().apply {
|
|
||||||
shape = GradientDrawable.RECTANGLE
|
|
||||||
cornerRadius = dp(8f).toFloat()
|
|
||||||
setColor(0xFF5E6AD2.toInt())
|
|
||||||
}
|
|
||||||
saveBtn = Button(context).apply {
|
saveBtn = Button(context).apply {
|
||||||
text = "确认入账"
|
text = config.labels.confirm
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
setTextColor(colorAccentFg)
|
||||||
background = saveBg
|
background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.RECTANGLE
|
||||||
|
cornerRadius = dp(12f).toFloat()
|
||||||
|
setColor(colorAccent)
|
||||||
|
}
|
||||||
textSize = 11f
|
textSize = 11f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1.3f)
|
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1.3f)
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
val newAmount = amountInput.text.toString().trim()
|
val newAmount = amountInput.text.toString().trim()
|
||||||
val newPayee = merchantInput.text.toString().trim()
|
val newPayee = merchantInput.text.toString().trim()
|
||||||
@ -388,21 +458,23 @@ class FloatingBillView(
|
|||||||
btnContainer.addView(saveBtn)
|
btnContainer.addView(saveBtn)
|
||||||
container.addView(btnContainer)
|
container.addView(btnContainer)
|
||||||
|
|
||||||
// 构建金额安全校验
|
// 金额安全校验(BigDecimal + 正则)
|
||||||
val amountWatcher = object : android.text.TextWatcher {
|
val amountWatcher = object : android.text.TextWatcher {
|
||||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
override fun afterTextChanged(s: android.text.Editable?) {
|
override fun afterTextChanged(s: android.text.Editable?) {
|
||||||
try {
|
try {
|
||||||
val text = s?.toString()?.trim() ?: ""
|
val text = s?.toString()?.trim() ?: ""
|
||||||
val value = text.toDoubleOrNull()
|
val pattern = Regex("^\\d+(\\.\\d{1,2})?$")
|
||||||
val isValid = value != null && !value.isNaN() && !value.isInfinite() && value > 0.0
|
val value = text.toBigDecimalOrNull()
|
||||||
|
val isValid = pattern.matches(text) && value != null && value > BigDecimal.ZERO
|
||||||
saveBtn?.isEnabled = isValid
|
saveBtn?.isEnabled = isValid
|
||||||
saveBtn?.background = GradientDrawable().apply {
|
saveBtn?.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(8f).toFloat()
|
shape = GradientDrawable.RECTANGLE
|
||||||
setColor(if (isValid) 0xFF5E6AD2.toInt() else 0xFF374151.toInt())
|
cornerRadius = dp(12f).toFloat()
|
||||||
|
setColor(if (isValid) colorAccent else colorInputBg)
|
||||||
}
|
}
|
||||||
saveBtn?.setTextColor(if (isValid) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
saveBtn?.setTextColor(if (isValid) colorAccentFg else colorFgSecondary)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "金额校验异常", e)
|
Log.e(TAG, "金额校验异常", e)
|
||||||
saveBtn?.isEnabled = false
|
saveBtn?.isEnabled = false
|
||||||
@ -419,7 +491,7 @@ class FloatingBillView(
|
|||||||
|
|
||||||
view = container
|
view = container
|
||||||
windowManager.addView(view, windowParams)
|
windowManager.addView(view, windowParams)
|
||||||
Log.i(TAG, "悬浮修改记账面板已显示: ¥$amount")
|
Log.i(TAG, "悬浮修改记账面板已显示: $currentCurrency $amount")
|
||||||
|
|
||||||
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
|
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
|
||||||
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
|
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
|
||||||
@ -438,11 +510,11 @@ class FloatingBillView(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 15 秒无操作自动消失(如果用户没有交互的话)
|
// 30 秒无操作自动消失(如果用户没有交互的话)
|
||||||
handler.postDelayed({
|
handler.postDelayed({
|
||||||
sendCancelEvent()
|
sendCancelEvent()
|
||||||
dismiss()
|
dismiss()
|
||||||
}, 15000L)
|
}, 30000L)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
|
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
|
||||||
@ -459,7 +531,7 @@ class FloatingBillView(
|
|||||||
|
|
||||||
// === 1. 绘制第一行 (分类 / 转入) ===
|
// === 1. 绘制第一行 (分类 / 转入) ===
|
||||||
if (currentDirection == "expense") {
|
if (currentDirection == "expense") {
|
||||||
categoryLabel?.text = "交易分类"
|
categoryLabel?.text = config.labels.categoryExpense
|
||||||
val filteredCats = categories.filter { it["type"] == "expense" }
|
val filteredCats = categories.filter { it["type"] == "expense" }
|
||||||
for (cat in filteredCats) {
|
for (cat in filteredCats) {
|
||||||
val catAccount = cat["account"] ?: ""
|
val catAccount = cat["account"] ?: ""
|
||||||
@ -470,14 +542,14 @@ class FloatingBillView(
|
|||||||
for (pair in categoryChips) {
|
for (pair in categoryChips) {
|
||||||
val active = pair.first == selected
|
val active = pair.first == selected
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) setColor(0xFF5E6AD2.toInt())
|
if (active) setColor(colorAccent)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -492,18 +564,18 @@ class FloatingBillView(
|
|||||||
categoryChips.forEach { pair ->
|
categoryChips.forEach { pair ->
|
||||||
val active = pair.first == selectedCategoryAccount
|
val active = pair.first == selectedCategoryAccount
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) setColor(0xFF5E6AD2.toInt())
|
if (active) setColor(colorAccent)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (currentDirection == "income") {
|
} else if (currentDirection == "income") {
|
||||||
categoryLabel?.text = "收入分类"
|
categoryLabel?.text = config.labels.categoryIncome
|
||||||
val filteredCats = categories.filter { it["type"] == "income" }
|
val filteredCats = categories.filter { it["type"] == "income" }
|
||||||
for (cat in filteredCats) {
|
for (cat in filteredCats) {
|
||||||
val catAccount = cat["account"] ?: ""
|
val catAccount = cat["account"] ?: ""
|
||||||
@ -514,14 +586,14 @@ class FloatingBillView(
|
|||||||
for (pair in categoryChips) {
|
for (pair in categoryChips) {
|
||||||
val active = pair.first == selected
|
val active = pair.first == selected
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) setColor(0xFF5E6AD2.toInt())
|
if (active) setColor(colorAccent)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -536,19 +608,19 @@ class FloatingBillView(
|
|||||||
categoryChips.forEach { pair ->
|
categoryChips.forEach { pair ->
|
||||||
val active = pair.first == selectedCategoryAccount
|
val active = pair.first == selectedCategoryAccount
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) setColor(0xFF5E6AD2.toInt())
|
if (active) setColor(colorAccent)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// transfer
|
// transfer — 第一行 = 转入账户,选中色 = transfer
|
||||||
categoryLabel?.text = "转入账户"
|
categoryLabel?.text = config.labels.transferTarget
|
||||||
for (acct in accounts) {
|
for (acct in accounts) {
|
||||||
val shortName = acct.split(":").lastOrNull() ?: acct
|
val shortName = acct.split(":").lastOrNull() ?: acct
|
||||||
val chip = createChipView(shortName)
|
val chip = createChipView(shortName)
|
||||||
@ -557,14 +629,14 @@ class FloatingBillView(
|
|||||||
for (pair in categoryChips) {
|
for (pair in categoryChips) {
|
||||||
val active = pair.first == selected
|
val active = pair.first == selected
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) setColor(0xFF10B981.toInt())
|
if (active) setColor(colorTransfer)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -579,26 +651,29 @@ class FloatingBillView(
|
|||||||
categoryChips.forEach { pair ->
|
categoryChips.forEach { pair ->
|
||||||
val active = pair.first == selectedCategoryAccount
|
val active = pair.first == selectedCategoryAccount
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) setColor(0xFF10B981.toInt())
|
if (active) setColor(colorTransfer)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 2. 绘制第二行 (资金账户) ===
|
// === 2. 绘制第二行 (资金账户) ===
|
||||||
if (currentDirection == "expense") {
|
if (currentDirection == "expense") {
|
||||||
accountLabel?.text = "资金来源"
|
accountLabel?.text = config.labels.accountExpense
|
||||||
} else if (currentDirection == "income") {
|
} else if (currentDirection == "income") {
|
||||||
accountLabel?.text = "存入账户"
|
accountLabel?.text = config.labels.accountIncome
|
||||||
} else {
|
} else {
|
||||||
accountLabel?.text = "转出账户"
|
accountLabel?.text = config.labels.accountTransfer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 账户行选中色按方向:expense→expense色,income→income色,transfer→expense色
|
||||||
|
val accountSelectedColor = if (currentDirection == "income") colorIncome else colorExpense
|
||||||
|
|
||||||
for (acct in accounts) {
|
for (acct in accounts) {
|
||||||
val shortName = acct.split(":").lastOrNull() ?: acct
|
val shortName = acct.split(":").lastOrNull() ?: acct
|
||||||
val chip = createChipView(shortName)
|
val chip = createChipView(shortName)
|
||||||
@ -607,15 +682,15 @@ class FloatingBillView(
|
|||||||
for (pair in accountChips) {
|
for (pair in accountChips) {
|
||||||
val active = pair.first == selected
|
val active = pair.first == selected
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) {
|
if (active) {
|
||||||
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
|
setColor(accountSelectedColor)
|
||||||
} else {
|
} else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -631,14 +706,14 @@ class FloatingBillView(
|
|||||||
for (p in categoryChips) {
|
for (p in categoryChips) {
|
||||||
val act = p.first == selectedCategoryAccount
|
val act = p.first == selectedCategoryAccount
|
||||||
p.second.background = GradientDrawable().apply {
|
p.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (act) setColor(0xFF10B981.toInt())
|
if (act) setColor(colorTransfer)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.second.setTextColor(if (act) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
p.second.setTextColor(if (act) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -651,15 +726,15 @@ class FloatingBillView(
|
|||||||
accountChips.forEach { pair ->
|
accountChips.forEach { pair ->
|
||||||
val active = pair.first == selectedSourceAccount
|
val active = pair.first == selectedSourceAccount
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) {
|
if (active) {
|
||||||
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
|
setColor(accountSelectedColor)
|
||||||
} else {
|
} else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -677,6 +752,7 @@ class FloatingBillView(
|
|||||||
putString("time", time)
|
putString("time", time)
|
||||||
putString("direction", currentDirection)
|
putString("direction", currentDirection)
|
||||||
putString("packageName", packageName)
|
putString("packageName", packageName)
|
||||||
|
putString("currency", currentCurrency)
|
||||||
putBoolean("confirmed", true)
|
putBoolean("confirmed", true)
|
||||||
putBoolean("editRequested", false)
|
putBoolean("editRequested", false)
|
||||||
putBoolean("isManualEdit", true)
|
putBoolean("isManualEdit", true)
|
||||||
@ -703,6 +779,7 @@ class FloatingBillView(
|
|||||||
putString("time", time)
|
putString("time", time)
|
||||||
putString("direction", currentDirection)
|
putString("direction", currentDirection)
|
||||||
putString("packageName", packageName)
|
putString("packageName", packageName)
|
||||||
|
putString("currency", currentCurrency)
|
||||||
}
|
}
|
||||||
reactContext
|
reactContext
|
||||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||||
@ -736,4 +813,13 @@ class FloatingBillView(
|
|||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
view = null
|
view = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kotlin 中缺失的 toBigDecimalOrNull 扩展。 */
|
||||||
|
private fun String.toBigDecimalOrNull(): BigDecimal? {
|
||||||
|
return try {
|
||||||
|
BigDecimal(this)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,9 @@ class FloatingHelper(
|
|||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "FloatingHelper"
|
private const val TAG = "FloatingHelper"
|
||||||
|
private const val PREFS_NAME = "billing_accessibility_prefs"
|
||||||
|
private const val KEY_X = "floating_ball_x"
|
||||||
|
private const val KEY_Y = "floating_ball_y"
|
||||||
private var lastX = 0
|
private var lastX = 0
|
||||||
private var lastY = 400
|
private var lastY = 400
|
||||||
}
|
}
|
||||||
@ -41,7 +44,7 @@ class FloatingHelper(
|
|||||||
private val params = WindowManager.LayoutParams(
|
private val params = WindowManager.LayoutParams(
|
||||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
|
||||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
|
||||||
PixelFormat.TRANSLUCENT
|
PixelFormat.TRANSLUCENT
|
||||||
).apply {
|
).apply {
|
||||||
@ -57,6 +60,25 @@ class FloatingHelper(
|
|||||||
try {
|
try {
|
||||||
val context = service
|
val context = service
|
||||||
|
|
||||||
|
// 加载浮层 UI 配置(颜色与文案均从 JS 下发的 config 读取,未下发时回退默认值)
|
||||||
|
val config = FloatingUiConfigStore.load(service)
|
||||||
|
|
||||||
|
// 预解析颜色
|
||||||
|
val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
|
||||||
|
val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
|
||||||
|
val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
|
||||||
|
val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
|
||||||
|
val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
|
||||||
|
val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
|
||||||
|
val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
|
||||||
|
|
||||||
|
// 位置持久化:从 SharedPreferences 读取,回退到 companion 缓存
|
||||||
|
val prefs = service.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
params.x = prefs.getInt(KEY_X, lastX)
|
||||||
|
params.y = prefs.getInt(KEY_Y, lastY)
|
||||||
|
lastX = params.x
|
||||||
|
lastY = params.y
|
||||||
|
|
||||||
val density = service.resources.displayMetrics.density
|
val density = service.resources.displayMetrics.density
|
||||||
fun dp(value: Float) = (value * density).toInt()
|
fun dp(value: Float) = (value * density).toInt()
|
||||||
|
|
||||||
@ -79,25 +101,31 @@ class FloatingHelper(
|
|||||||
layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f))
|
layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 边缘指示竖线:accent 色取 RGB + 固定 0xB0 alpha(保持 70% 透明)
|
||||||
|
val indicatorColor = (colorAccent and 0x00FFFFFF) or 0xB0000000.toInt()
|
||||||
val indicatorView = View(context).apply {
|
val indicatorView = View(context).apply {
|
||||||
background = GradientDrawable().apply {
|
background = GradientDrawable().apply {
|
||||||
shape = GradientDrawable.RECTANGLE
|
shape = GradientDrawable.RECTANGLE
|
||||||
cornerRadius = dp(1.5f).toFloat() // 高度圆润
|
cornerRadius = dp(1.5f).toFloat()
|
||||||
setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框
|
setColor(indicatorColor)
|
||||||
}
|
}
|
||||||
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
|
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
|
||||||
gravity = Gravity.CENTER_VERTICAL or Gravity.START // 贴左边缘
|
gravity = Gravity.CENTER_VERTICAL or Gravity.START
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bubbleLayout.addView(indicatorView)
|
bubbleLayout.addView(indicatorView)
|
||||||
bubbleView = bubbleLayout
|
bubbleView = bubbleLayout
|
||||||
|
|
||||||
// 3. 创建展开菜单 (垂直布局,高紧凑度设计)
|
// 3. 创建展开菜单 (垂直布局,高紧凑度设计)
|
||||||
|
// 菜单背景:实色 cardBg(per plan §T4)
|
||||||
|
val menuBgColor = colorCardBg
|
||||||
|
// 菜单描边:border 色 + 半透明 alpha
|
||||||
|
val menuBorderColor = (colorBorder and 0x00FFFFFF) or 0x22000000.toInt()
|
||||||
val menuBg = GradientDrawable().apply {
|
val menuBg = GradientDrawable().apply {
|
||||||
shape = GradientDrawable.RECTANGLE
|
shape = GradientDrawable.RECTANGLE
|
||||||
cornerRadius = dp(10f).toFloat()
|
cornerRadius = dp(10f).toFloat()
|
||||||
setColor(0xA611131E.toInt()) // 65% OLED high-transparency black
|
setColor(menuBgColor)
|
||||||
setStroke(dp(0.8f), 0x22FFFFFF.toInt()) // subtle 13% white border
|
setStroke(dp(0.8f), menuBorderColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
menuView = LinearLayout(context).apply {
|
menuView = LinearLayout(context).apply {
|
||||||
@ -107,30 +135,37 @@ class FloatingHelper(
|
|||||||
setPadding(dp(4f), dp(4f), dp(4f), dp(4f))
|
setPadding(dp(4f), dp(4f), dp(4f), dp(4f))
|
||||||
visibility = View.GONE
|
visibility = View.GONE
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
dp(96f), // ultra-compact width: 96dp
|
dp(96f),
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
).apply {
|
).apply {
|
||||||
leftMargin = dp(4f) // spacing with indicator line
|
leftMargin = dp(4f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vector icons
|
// Vector icons
|
||||||
val sizePx = dp(14f)
|
val sizePx = dp(14f)
|
||||||
val strokePx = dp(1.4f).toFloat()
|
val strokePx = dp(1.4f).toFloat()
|
||||||
val ocrIcon = ScanIconDrawable(0xFF818CF8.toInt(), strokePx, 0xFFF87171.toInt(), sizePx)
|
// ScanIconDrawable:accentFg 色(在 accent 底按钮上可见),laserColor 保留红色语义
|
||||||
val pinIcon = PinIconDrawable(0xFF34D399.toInt(), strokePx, sizePx)
|
val ocrIcon = ScanIconDrawable(colorAccentFg, strokePx, 0xFFF87171.toInt(), sizePx)
|
||||||
|
// PinIconDrawable:fgSecondary 色
|
||||||
|
val pinIcon = PinIconDrawable(colorFgSecondary, strokePx, sizePx)
|
||||||
|
|
||||||
|
// Button 1: 识别账单(主操作:accent 底 accentFg 字)
|
||||||
|
// 背景常时:accent 色取 RGB + 固定 0x1F alpha(约 12%)
|
||||||
|
val btnOcrBgNormal = (colorAccent and 0x00FFFFFF) or 0x1F000000.toInt()
|
||||||
|
// 按下加深:accent 色取 RGB + 固定 0x40 alpha(约 25%)
|
||||||
|
val btnOcrBgPressed = (colorAccent and 0x00FFFFFF) or 0x40000000.toInt()
|
||||||
|
|
||||||
// Button 1: "识别账单"
|
|
||||||
val btnOcr = TextView(context).apply {
|
val btnOcr = TextView(context).apply {
|
||||||
text = "识别账单"
|
text = config.labels.ballOcr
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
setTextColor(colorAccentFg)
|
||||||
textSize = 11f
|
textSize = 11f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
gravity = Gravity.CENTER_VERTICAL
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
setPadding(dp(6f), 0, dp(6f), 0)
|
setPadding(dp(6f), 0, dp(6f), 0)
|
||||||
background = GradientDrawable().apply {
|
background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(7f).toFloat()
|
cornerRadius = dp(7f).toFloat()
|
||||||
setColor(0x1F5E6AD2.toInt()) // translucent indigo background
|
setColor(btnOcrBgNormal)
|
||||||
}
|
}
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
@ -145,13 +180,13 @@ class FloatingHelper(
|
|||||||
MotionEvent.ACTION_DOWN -> {
|
MotionEvent.ACTION_DOWN -> {
|
||||||
view.background = GradientDrawable().apply {
|
view.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(7f).toFloat()
|
cornerRadius = dp(7f).toFloat()
|
||||||
setColor(0x405E6AD2.toInt())
|
setColor(btnOcrBgPressed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
view.background = GradientDrawable().apply {
|
view.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(7f).toFloat()
|
cornerRadius = dp(7f).toFloat()
|
||||||
setColor(0x1F5E6AD2.toInt())
|
setColor(btnOcrBgNormal)
|
||||||
}
|
}
|
||||||
if (event.action == MotionEvent.ACTION_UP) {
|
if (event.action == MotionEvent.ACTION_UP) {
|
||||||
view.performClick()
|
view.performClick()
|
||||||
@ -165,17 +200,22 @@ class FloatingHelper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Button 2: "记住此页"
|
// Button 2: 记住此页(次要操作:inputBg 底 fgPrimary 字,per plan §T4)
|
||||||
|
// 背景常时:inputBg 色取 RGB + 固定 0x15 alpha
|
||||||
|
val btnRememberBgNormal = (colorInputBg and 0x00FFFFFF) or 0x15000000.toInt()
|
||||||
|
// 按下加深:inputBg 色取 RGB + 固定 0x30 alpha
|
||||||
|
val btnRememberBgPressed = (colorInputBg and 0x00FFFFFF) or 0x30000000.toInt()
|
||||||
|
|
||||||
val btnRemember = TextView(context).apply {
|
val btnRemember = TextView(context).apply {
|
||||||
text = "记住此页"
|
text = config.labels.ballRemember
|
||||||
setTextColor(0xFFE5E7EB.toInt())
|
setTextColor(colorFgPrimary)
|
||||||
textSize = 11f
|
textSize = 11f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
gravity = Gravity.CENTER_VERTICAL
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
setPadding(dp(6f), 0, dp(6f), 0)
|
setPadding(dp(6f), 0, dp(6f), 0)
|
||||||
background = GradientDrawable().apply {
|
background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(7f).toFloat()
|
cornerRadius = dp(7f).toFloat()
|
||||||
setColor(0x15FFFFFF.toInt()) // subtle translucent gray
|
setColor(btnRememberBgNormal)
|
||||||
}
|
}
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
@ -188,13 +228,13 @@ class FloatingHelper(
|
|||||||
MotionEvent.ACTION_DOWN -> {
|
MotionEvent.ACTION_DOWN -> {
|
||||||
view.background = GradientDrawable().apply {
|
view.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(7f).toFloat()
|
cornerRadius = dp(7f).toFloat()
|
||||||
setColor(0x30FFFFFF.toInt())
|
setColor(btnRememberBgPressed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
view.background = GradientDrawable().apply {
|
view.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(7f).toFloat()
|
cornerRadius = dp(7f).toFloat()
|
||||||
setColor(0x15FFFFFF.toInt())
|
setColor(btnRememberBgNormal)
|
||||||
}
|
}
|
||||||
if (event.action == MotionEvent.ACTION_UP) {
|
if (event.action == MotionEvent.ACTION_UP) {
|
||||||
view.performClick()
|
view.performClick()
|
||||||
@ -206,9 +246,9 @@ class FloatingHelper(
|
|||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
try {
|
try {
|
||||||
service.rememberCurrentPage()
|
service.rememberCurrentPage()
|
||||||
FloatingTip(service, "📌 已将当前页面加入识别白名单!", FloatingTip.TipPosition.TOP, 2500L).show()
|
FloatingTip(service, config.labels.rememberSuccess, FloatingTip.TipPosition.TOP, 2500L).show()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
FloatingTip(service, "记录失败: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show()
|
FloatingTip(service, "${config.labels.rememberFail}: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show()
|
||||||
}
|
}
|
||||||
collapse()
|
collapse()
|
||||||
}
|
}
|
||||||
@ -246,7 +286,7 @@ class FloatingHelper(
|
|||||||
params.x = initialX + dx
|
params.x = initialX + dx
|
||||||
params.y = initialY + dy
|
params.y = initialY + dy
|
||||||
containerView?.let { windowManager.updateViewLayout(it, params) }
|
containerView?.let { windowManager.updateViewLayout(it, params) }
|
||||||
|
|
||||||
lastX = params.x
|
lastX = params.x
|
||||||
lastY = params.y
|
lastY = params.y
|
||||||
true
|
true
|
||||||
@ -257,14 +297,20 @@ class FloatingHelper(
|
|||||||
} else {
|
} else {
|
||||||
// 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘)
|
// 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘)
|
||||||
if (params.x < service.resources.displayMetrics.widthPixels / 2) {
|
if (params.x < service.resources.displayMetrics.widthPixels / 2) {
|
||||||
params.x = -dp(1.5f) // 指示器 3dp 宽,中心对齐边缘
|
params.x = -dp(1.5f)
|
||||||
} else {
|
} else {
|
||||||
params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f)
|
params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f)
|
||||||
}
|
}
|
||||||
containerView?.let { windowManager.updateViewLayout(it, params) }
|
containerView?.let { windowManager.updateViewLayout(it, params) }
|
||||||
|
|
||||||
lastX = params.x
|
lastX = params.x
|
||||||
lastY = params.y
|
lastY = params.y
|
||||||
|
|
||||||
|
// 位置持久化:吸附后写入 SharedPreferences
|
||||||
|
prefs.edit()
|
||||||
|
.putInt(KEY_X, params.x)
|
||||||
|
.putInt(KEY_Y, params.y)
|
||||||
|
.apply()
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,14 +6,10 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.ProgressBar
|
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 浮窗提示(plan.md「3.8 浮窗账单提示」)。
|
* 浮窗提示(plan.md「3.8 浮窗账单提示」)。
|
||||||
@ -47,6 +43,10 @@ class FloatingTip(
|
|||||||
/** 显示浮窗提示。 */
|
/** 显示浮窗提示。 */
|
||||||
fun show() {
|
fun show() {
|
||||||
try {
|
try {
|
||||||
|
val config = FloatingUiConfigStore.load(context)
|
||||||
|
val bgColor = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF000000") or 0xFF000000.toInt() // 强制不透明化
|
||||||
|
val textColor = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
|
||||||
|
|
||||||
val layoutParams = WindowManager.LayoutParams(
|
val layoutParams = WindowManager.LayoutParams(
|
||||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
@ -65,42 +65,20 @@ class FloatingTip(
|
|||||||
|
|
||||||
val container = LinearLayout(context).apply {
|
val container = LinearLayout(context).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
setBackgroundColor(0xF0333333.toInt())
|
setBackgroundColor(bgColor)
|
||||||
setPadding(32, 16, 32, 16)
|
setPadding(32, 16, 32, 16)
|
||||||
}
|
}
|
||||||
val text = TextView(context).apply {
|
val text = TextView(context).apply {
|
||||||
text = message
|
text = message
|
||||||
textSize = 13f
|
textSize = 13f
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
setTextColor(textColor)
|
||||||
}
|
|
||||||
// 倒计时进度条
|
|
||||||
val progress = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal)
|
|
||||||
progress.max = 100
|
|
||||||
progress.progress = 100
|
|
||||||
progress.layoutParams = LinearLayout.LayoutParams(200, 8).apply {
|
|
||||||
setMargins(16, 0, 0, 0)
|
|
||||||
}
|
}
|
||||||
container.addView(text)
|
container.addView(text)
|
||||||
container.addView(progress)
|
|
||||||
view = container
|
view = container
|
||||||
|
|
||||||
windowManager.addView(view, layoutParams)
|
windowManager.addView(view, layoutParams)
|
||||||
Log.d(TAG, "FloatingTip 显示: $message")
|
Log.d(TAG, "FloatingTip 显示: $message")
|
||||||
|
|
||||||
// 倒计时动画(300ms 内 progress 从 100 到 0)
|
|
||||||
val steps = 30
|
|
||||||
val stepMs = durationMs / steps
|
|
||||||
var currentStep = 0
|
|
||||||
handler.post(object : Runnable {
|
|
||||||
override fun run() {
|
|
||||||
currentStep++
|
|
||||||
progress.progress = 100 - (currentStep * 100 / steps)
|
|
||||||
if (currentStep < steps) {
|
|
||||||
handler.postDelayed(this, stepMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.postDelayed({ dismiss() }, durationMs)
|
handler.postDelayed({ dismiss() }, durationMs)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "FloatingTip 显示失败: ${e.message}")
|
Log.e(TAG, "FloatingTip 显示失败: ${e.message}")
|
||||||
@ -121,7 +99,7 @@ class FloatingTip(
|
|||||||
*/
|
*/
|
||||||
class RepeatToast(private val context: Context, private val message: String) {
|
class RepeatToast(private val context: Context, private val message: String) {
|
||||||
fun show() {
|
fun show() {
|
||||||
val tip = FloatingTip(context, "⚠ $message", FloatingTip.TipPosition.TOP, 2000L)
|
val tip = FloatingTip(context, message, FloatingTip.TipPosition.TOP, 2000L)
|
||||||
tip.show()
|
tip.show()
|
||||||
Log.d("RepeatToast", "重复提示: $message")
|
Log.d("RepeatToast", "重复提示: $message")
|
||||||
}
|
}
|
||||||
|
|||||||
211
plugins/accessibility/android/FloatingUiConfigStore.kt
Normal file
211
plugins/accessibility/android/FloatingUiConfigStore.kt
Normal 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.md「FloatingUiConfig 契约」)。
|
||||||
|
* 字段名(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析颜色字符串;失败/为空时解析 fallbackHex(fallback 也不合法则返回 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -121,12 +121,11 @@ function withAccessibilityService(config) {
|
|||||||
let content = modConfig.modResults.contents;
|
let content = modConfig.modResults.contents;
|
||||||
|
|
||||||
// 2a. 注入 import(AccessibilityBridgePackage)
|
// 2a. 注入 import(AccessibilityBridgePackage)
|
||||||
if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) {
|
content = content.replace(/^import\s+[\w.]+\.AccessibilityBridgePackage\s*$/gm, '');
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/^(package\s+[\w.]+;?\s*)$/m,
|
/^(package\s+[\w.]+;?\s*)$/m,
|
||||||
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
|
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
|
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
|
||||||
if (!content.includes('add(AccessibilityBridgePackage())')) {
|
if (!content.includes('add(AccessibilityBridgePackage())')) {
|
||||||
@ -166,7 +165,7 @@ function withAccessibilityService(config) {
|
|||||||
$: {
|
$: {
|
||||||
'android:name': `${PACKAGE}.BillingAccessibilityService`,
|
'android:name': `${PACKAGE}.BillingAccessibilityService`,
|
||||||
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
|
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
|
||||||
'android:label': '账单识别',
|
'android:label': '浮记-账单识别',
|
||||||
'android:exported': 'false',
|
'android:exported': 'false',
|
||||||
},
|
},
|
||||||
'intent-filter': [{
|
'intent-filter': [{
|
||||||
|
|||||||
@ -8,11 +8,11 @@
|
|||||||
|
|
||||||
**PP-OCRv6 small**(det 2.5M 参数 + rec 5.3M 参数,精度显著优于 v5 mobile)
|
**PP-OCRv6 small**(det 2.5M 参数 + rec 5.3M 参数,精度显著优于 v5 mobile)
|
||||||
|
|
||||||
| 指标 | PP-OCRv5 mobile | PP-OCRv6 small |
|
| 指标 | PP-OCRv5 mobile | PP-OCRv6 small |
|
||||||
|------|----------------|----------------|
|
| ------------- | --------------- | -------------- |
|
||||||
| det Hmean | 75.2% | 84.1% |
|
| det Hmean | 75.2% | 84.1% |
|
||||||
| rec W-Avg | 73.7% | 81.3% |
|
| rec W-Avg | 73.7% | 81.3% |
|
||||||
| rec ONNX 大小 | ~17 MB | ~21 MB |
|
| rec ONNX 大小 | ~17 MB | ~21 MB |
|
||||||
|
|
||||||
## 文件结构
|
## 文件结构
|
||||||
|
|
||||||
@ -55,14 +55,14 @@ curl -L -o ppocrv6_dict.txt \
|
|||||||
|
|
||||||
## 性能配置(参考 AutoAccounting OcrProcessor.kt)
|
## 性能配置(参考 AutoAccounting OcrProcessor.kt)
|
||||||
|
|
||||||
| 优化项 | 配置 |
|
| 优化项 | 配置 |
|
||||||
|--------|------|
|
| -------- | -------------------------------------- |
|
||||||
| 引擎 | ONNX Runtime Android |
|
| 引擎 | ONNX Runtime Android |
|
||||||
| 执行器 | CPU(兼容性最稳,部分设备 GPU 会崩溃) |
|
| 执行器 | CPU(兼容性最稳,部分设备 GPU 会崩溃) |
|
||||||
| 线程 | intraOp=2 / interOp=2 |
|
| 线程 | intraOp=2 / interOp=2 |
|
||||||
| det 图像 | 最大边 960px,短边压缩 720px |
|
| det 图像 | 最大边 960px,短边压缩 720px |
|
||||||
| rec 图像 | 固定高度 48px |
|
| rec 图像 | 固定高度 48px |
|
||||||
| ABI | arm64-v8a(主流设备) |
|
| ABI | arm64-v8a(主流设备) |
|
||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,9 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
@Volatile private var initialized = false
|
@Volatile private var initialized = false
|
||||||
@Volatile private var initFailed = false
|
@Volatile private var initFailed = false
|
||||||
|
|
||||||
|
/** 模型文件目录(filesystem 绝对路径)。非空时从该目录加载模型,否则回退到 assets。 */
|
||||||
|
@Volatile private var modelDir: String? = null
|
||||||
|
|
||||||
override fun getName(): String = OCR_MODULE_NAME
|
override fun getName(): String = OCR_MODULE_NAME
|
||||||
|
|
||||||
override fun initialize() {
|
override fun initialize() {
|
||||||
@ -70,7 +73,7 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
scope.launch { initEngine() }
|
scope.launch { initEngine() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 assets 加载 det/rec ONNX 模型与字典。 */
|
/** 从 assets 或 filesystem 加载 det/rec ONNX 模型与字典。 */
|
||||||
private fun initEngine() {
|
private fun initEngine() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
try {
|
try {
|
||||||
@ -83,11 +86,29 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
// 移动端关闭内存优化里的图优化级别过高(部分模型会崩)
|
// 移动端关闭内存优化里的图优化级别过高(部分模型会崩)
|
||||||
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT)
|
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT)
|
||||||
}
|
}
|
||||||
val detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() }
|
val dir = modelDir
|
||||||
val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() }
|
val (det, rec, dict) = if (dir != null) {
|
||||||
val det = env.createSession(detBytes, opts)
|
// 从 filesystem 加载(P8:模型按需下载到本地目录)
|
||||||
val rec = env.createSession(recBytes, opts)
|
val detPath = dir + java.io.File.separator + ASSET_DET_MODEL
|
||||||
val dict = loadDictionary()
|
val recPath = dir + java.io.File.separator + ASSET_REC_MODEL
|
||||||
|
val dictPath = dir + java.io.File.separator + ASSET_DICT
|
||||||
|
Log.i(OCR_MODULE_NAME, "从 filesystem 加载模型: det=$detPath, rec=$recPath")
|
||||||
|
Triple(
|
||||||
|
env.createSession(detPath, opts),
|
||||||
|
env.createSession(recPath, opts),
|
||||||
|
loadDictionaryFromFile(dictPath)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 回退:从 assets 加载(兼容未迁移的老用户)
|
||||||
|
val detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() }
|
||||||
|
val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() }
|
||||||
|
Log.i(OCR_MODULE_NAME, "从 assets 加载模型(兼容模式)")
|
||||||
|
Triple(
|
||||||
|
env.createSession(detBytes, opts),
|
||||||
|
env.createSession(recBytes, opts),
|
||||||
|
loadDictionaryFromAssets()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
detSession = det
|
detSession = det
|
||||||
recSession = rec
|
recSession = rec
|
||||||
@ -98,7 +119,12 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
initFailed = true
|
initFailed = true
|
||||||
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
|
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
|
||||||
Log.e(OCR_MODULE_NAME, "请确认 assets 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
|
val dir = modelDir
|
||||||
|
if (dir != null) {
|
||||||
|
Log.e(OCR_MODULE_NAME, "请确认 $dir 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
|
||||||
|
} else {
|
||||||
|
Log.e(OCR_MODULE_NAME, "请确认 assets 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
lock.unlock()
|
lock.unlock()
|
||||||
}
|
}
|
||||||
@ -108,13 +134,13 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
private val recEnv: OrtEnvironment? get() = ortEnv
|
private val recEnv: OrtEnvironment? get() = ortEnv
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载 ppocrv6_dict.txt 字典。
|
* 从 assets 加载 ppocrv6_dict.txt 字典。
|
||||||
*
|
*
|
||||||
* PaddleOCR CTC 约定:模型输出 logits 的 index 0 是 blank,字符从 index 1 开始;
|
* PaddleOCR CTC 约定:模型输出 logits 的 index 0 是 blank,字符从 index 1 开始;
|
||||||
* 字典条目 dictionary[i] 对应模型输出 index i+1。解码时 dictIdx = argmaxIdx - 1。
|
* 字典条目 dictionary[i] 对应模型输出 index i+1。解码时 dictIdx = argmaxIdx - 1。
|
||||||
* 字典本身不含 blank,运行时固定用 index 0 作 blank(见 ctcGreedyDecode)。
|
* 字典本身不含 blank,运行时固定用 index 0 作 blank(见 ctcGreedyDecode)。
|
||||||
*/
|
*/
|
||||||
private fun loadDictionary(): List<String> {
|
private fun loadDictionaryFromAssets(): List<String> {
|
||||||
val words = mutableListOf<String>()
|
val words = mutableListOf<String>()
|
||||||
context.assets.open(ASSET_DICT).use { stream ->
|
context.assets.open(ASSET_DICT).use { stream ->
|
||||||
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines ->
|
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines ->
|
||||||
@ -127,6 +153,17 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
return words
|
return words
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从 filesystem 路径加载字典文件。 */
|
||||||
|
private fun loadDictionaryFromFile(path: String): List<String> {
|
||||||
|
val words = mutableListOf<String>()
|
||||||
|
java.io.File(path).bufferedReader(Charsets.UTF_8).useLines { lines ->
|
||||||
|
lines.forEach { line ->
|
||||||
|
words.add(line.trimEnd('\r', '\n'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return words
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 识别图片文本。
|
* 识别图片文本。
|
||||||
* @param imageBase64 base64 编码的图片(JPEG/PNG)
|
* @param imageBase64 base64 编码的图片(JPEG/PNG)
|
||||||
@ -209,6 +246,18 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
promise.resolve(initialized)
|
promise.resolve(initialized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置模型文件目录(绝对路径)。若引擎已初始化则释放并重新加载。 */
|
||||||
|
@ReactMethod
|
||||||
|
fun setModelDir(dir: String, promise: Promise) {
|
||||||
|
modelDir = dir
|
||||||
|
if (initialized) {
|
||||||
|
release()
|
||||||
|
initFailed = false
|
||||||
|
scope.launch { initEngine() }
|
||||||
|
}
|
||||||
|
promise.resolve(true)
|
||||||
|
}
|
||||||
|
|
||||||
// ============== 推理流水线 ==============
|
// ============== 推理流水线 ==============
|
||||||
|
|
||||||
private fun ensureReady() {
|
private fun ensureReady() {
|
||||||
|
|||||||
@ -96,12 +96,11 @@ function withPpOcr(config) {
|
|||||||
let content = modConfig.modResults.contents;
|
let content = modConfig.modResults.contents;
|
||||||
|
|
||||||
// 3a. 注入 import(在 package 声明行后插入)
|
// 3a. 注入 import(在 package 声明行后插入)
|
||||||
if (!content.includes(`import ${PACKAGE}.OcrPackage`)) {
|
content = content.replace(/^import\s+[\w.]+\.OcrPackage\s*$/gm, '');
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/^(package\s+[\w.]+;?\s*)$/m,
|
/^(package\s+[\w.]+;?\s*)$/m,
|
||||||
`$1\nimport ${PACKAGE}.OcrPackage`,
|
`$1\nimport ${PACKAGE}.OcrPackage`,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 3b. 在 getPackages() 的 .apply {} 块里注入 add(OcrPackage())
|
// 3b. 在 getPackages() 的 .apply {} 块里注入 add(OcrPackage())
|
||||||
if (!content.includes('add(OcrPackage())')) {
|
if (!content.includes('add(OcrPackage())')) {
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@ -46,12 +46,11 @@ function withScreenshotMonitor(config) {
|
|||||||
let content = modConfig.modResults.contents;
|
let content = modConfig.modResults.contents;
|
||||||
|
|
||||||
// 2a. 注入 import
|
// 2a. 注入 import
|
||||||
if (!content.includes(`import ${PACKAGE}.ScreenshotPackage`)) {
|
content = content.replace(/^import\s+[\w.]+\.ScreenshotPackage\s*$/gm, '');
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/^(package\s+[\w.]+;?\s*)$/m,
|
/^(package\s+[\w.]+;?\s*)$/m,
|
||||||
`$1\nimport ${PACKAGE}.ScreenshotPackage`,
|
`$1\nimport ${PACKAGE}.ScreenshotPackage`,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 2b. 在 getPackages() 的 .apply {} 块里注入 add(ScreenshotPackage())
|
// 2b. 在 getPackages() 的 .apply {} 块里注入 add(ScreenshotPackage())
|
||||||
if (!content.includes('add(ScreenshotPackage())')) {
|
if (!content.includes('add(ScreenshotPackage())')) {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
function withSizeOptimization(config) {
|
function withSizeOptimization(config) {
|
||||||
// 1. 在 prebuild 时修改 gradle.properties
|
// 1. 在 prebuild 时修改 gradle.properties (开启 R8/ProGuard 代码混淆 + 资源裁剪 + .so 库压缩)
|
||||||
config = withDangerousMod(config, [
|
config = withDangerousMod(config, [
|
||||||
'android',
|
'android',
|
||||||
async (modConfig) => {
|
async (modConfig) => {
|
||||||
@ -11,18 +11,42 @@ function withSizeOptimization(config) {
|
|||||||
const propertiesPath = path.join(projectRoot, 'gradle.properties');
|
const propertiesPath = path.join(projectRoot, 'gradle.properties');
|
||||||
if (fs.existsSync(propertiesPath)) {
|
if (fs.existsSync(propertiesPath)) {
|
||||||
let content = fs.readFileSync(propertiesPath, 'utf8');
|
let content = fs.readFileSync(propertiesPath, 'utf8');
|
||||||
|
|
||||||
|
// 限制打包架构为 arm64-v8a
|
||||||
if (content.includes('reactNativeArchitectures=')) {
|
if (content.includes('reactNativeArchitectures=')) {
|
||||||
content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a');
|
content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a');
|
||||||
} else {
|
} else {
|
||||||
content += '\nreactNativeArchitectures=arm64-v8a\n';
|
content += '\nreactNativeArchitectures=arm64-v8a\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. 开启 R8 代码混淆与摇树裁剪
|
||||||
|
if (!content.includes('android.enableMinifyInReleaseBuilds=')) {
|
||||||
|
content += 'android.enableMinifyInReleaseBuilds=true\n';
|
||||||
|
} else {
|
||||||
|
content = content.replace(/android\.enableMinifyInReleaseBuilds=.*/, 'android.enableMinifyInReleaseBuilds=true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 开启无用资源裁剪
|
||||||
|
if (!content.includes('android.enableShrinkResourcesInReleaseBuilds=')) {
|
||||||
|
content += 'android.enableShrinkResourcesInReleaseBuilds=true\n';
|
||||||
|
} else {
|
||||||
|
content = content.replace(/android\.enableShrinkResourcesInReleaseBuilds=.*/, 'android.enableShrinkResourcesInReleaseBuilds=true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 开启 .so 动态库在 APK 内的高倍率压缩 (useLegacyPackaging=true)
|
||||||
|
if (!content.includes('expo.useLegacyPackaging=')) {
|
||||||
|
content += 'expo.useLegacyPackaging=true\n';
|
||||||
|
} else {
|
||||||
|
content = content.replace(/expo\.useLegacyPackaging=.*/, 'expo.useLegacyPackaging=true');
|
||||||
|
}
|
||||||
|
|
||||||
fs.writeFileSync(propertiesPath, content, 'utf8');
|
fs.writeFileSync(propertiesPath, content, 'utf8');
|
||||||
}
|
}
|
||||||
return modConfig;
|
return modConfig;
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包
|
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包 + 字体裁剪
|
||||||
config = withDangerousMod(config, [
|
config = withDangerousMod(config, [
|
||||||
'android',
|
'android',
|
||||||
async (modConfig) => {
|
async (modConfig) => {
|
||||||
@ -30,19 +54,71 @@ function withSizeOptimization(config) {
|
|||||||
const buildGradlePath = path.join(projectRoot, 'app/build.gradle');
|
const buildGradlePath = path.join(projectRoot, 'app/build.gradle');
|
||||||
if (fs.existsSync(buildGradlePath)) {
|
if (fs.existsSync(buildGradlePath)) {
|
||||||
let content = fs.readFileSync(buildGradlePath, 'utf8');
|
let content = fs.readFileSync(buildGradlePath, 'utf8');
|
||||||
|
|
||||||
|
// ABI Splits 分包配置
|
||||||
if (!content.includes('splits {')) {
|
if (!content.includes('splits {')) {
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/android\s*\{/,
|
/android\s*\{/,
|
||||||
`android {\n splits {\n abi {\n enable true\n reset()\n include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"\n universalApk true\n }\n }`
|
`android {\n splits {\n abi {\n enable true\n reset()\n include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"\n universalApk true\n }\n }`
|
||||||
);
|
);
|
||||||
fs.writeFileSync(buildGradlePath, content, 'utf8');
|
}
|
||||||
|
|
||||||
|
// 3. 裁剪未使用的 @expo/vector-icons 矢量字体
|
||||||
|
if (!content.includes('variant.mergeAssetsProvider.configure')) {
|
||||||
|
content += `
|
||||||
|
android.applicationVariants.all { variant ->
|
||||||
|
if (variant.buildType.name == "release") {
|
||||||
|
variant.mergeAssetsProvider.configure {
|
||||||
|
doLast {
|
||||||
|
delete fileTree(dir: outputDir, includes: [
|
||||||
|
"**/fonts/AntDesign.ttf",
|
||||||
|
"**/fonts/Entypo.ttf",
|
||||||
|
"**/fonts/EvilIcons.ttf",
|
||||||
|
"**/fonts/Feather.ttf",
|
||||||
|
"**/fonts/FontAwesome*.ttf",
|
||||||
|
"**/fonts/Fontisto.ttf",
|
||||||
|
"**/fonts/Foundation.ttf",
|
||||||
|
"**/fonts/MaterialCommunityIcons.ttf",
|
||||||
|
"**/fonts/MaterialIcons.ttf",
|
||||||
|
"**/fonts/Octicons.ttf",
|
||||||
|
"**/fonts/SimpleLineIcons.ttf",
|
||||||
|
"**/fonts/Zocial.ttf"
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(buildGradlePath, content, 'utf8');
|
||||||
|
}
|
||||||
|
return modConfig;
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. 在 prebuild 时修改 proguard-rules.pro 保护原生插件与 ONNX 模块
|
||||||
|
config = withDangerousMod(config, [
|
||||||
|
'android',
|
||||||
|
async (modConfig) => {
|
||||||
|
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
||||||
|
const proguardPath = path.join(projectRoot, 'app/proguard-rules.pro');
|
||||||
|
if (fs.existsSync(proguardPath)) {
|
||||||
|
let content = fs.readFileSync(proguardPath, 'utf8');
|
||||||
|
if (!content.includes('com.beancount.mobile')) {
|
||||||
|
content += `
|
||||||
|
# Size optimization: keep custom native packages & ONNX
|
||||||
|
-keep class com.beancount.mobile.** { *; }
|
||||||
|
-keep class com.example.driftledger.** { *; }
|
||||||
|
-keep class ai.onnxruntime.** { *; }
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(proguardPath, content, 'utf8');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return modConfig;
|
return modConfig;
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 3. 在 prebuild 时修改 root build.gradle 强制指定所有模块的 NDK 版本以匹配 27.1.12297006
|
// 4. 在 prebuild 时修改 root build.gradle 强制指定所有模块的 NDK 版本以匹配 27.1.12297006
|
||||||
config = withDangerousMod(config, [
|
config = withDangerousMod(config, [
|
||||||
'android',
|
'android',
|
||||||
async (modConfig) => {
|
async (modConfig) => {
|
||||||
|
|||||||
@ -39,6 +39,8 @@ export function isTransactionIntent(input: string): boolean {
|
|||||||
return hasAmount && hasKeyword;
|
return hasAmount && hasKeyword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理用户消息:判定意图 → 记账或自由对话。
|
* 处理用户消息:判定意图 → 记账或自由对话。
|
||||||
*/
|
*/
|
||||||
@ -47,11 +49,14 @@ export async function processChatMessage(
|
|||||||
provider: AiProvider,
|
provider: AiProvider,
|
||||||
conversation?: ChatConversation,
|
conversation?: ChatConversation,
|
||||||
): Promise<ChatResponse> {
|
): Promise<ChatResponse> {
|
||||||
|
logger.info('aiChat', `收到 AI 助手对话请求 [文本: "${input}"]`);
|
||||||
try {
|
try {
|
||||||
// 意图判定
|
// 意图判定
|
||||||
if (isTransactionIntent(input)) {
|
if (isTransactionIntent(input)) {
|
||||||
|
logger.info('aiChat', '检测到记账意图,触发自然语言识别流水线');
|
||||||
const result = await processNaturalLanguage(input, provider);
|
const result = await processNaturalLanguage(input, provider);
|
||||||
if (result.type === 'draft') {
|
if (result.type === 'draft') {
|
||||||
|
logger.info('aiChat', `AI 识别生成交易草稿: [时间: ${result.draft.time}, 金额: ${result.draft.amount} ${result.draft.currency}, 叙述: "${result.draft.narration}"]`);
|
||||||
return { type: 'bill_card', billCards: [result.draft] };
|
return { type: 'bill_card', billCards: [result.draft] };
|
||||||
}
|
}
|
||||||
// 非 draft 但有意图 → 返回 AI 文本
|
// 非 draft 但有意图 → 返回 AI 文本
|
||||||
@ -59,10 +64,13 @@ export async function processChatMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 自由对话
|
// 自由对话
|
||||||
|
logger.info('aiChat', '未命中记账模式,发起 AI 自由问答');
|
||||||
const messages = buildChatContext(input, conversation);
|
const messages = buildChatContext(input, conversation);
|
||||||
const response = await provider.chat(messages);
|
const response = await provider.chat(messages);
|
||||||
|
logger.info('aiChat', 'AI 自由问答回复成功');
|
||||||
return { type: 'text', text: removeThink(response) };
|
return { type: 'text', text: removeThink(response) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error('aiChat', 'AI 对话助手响应失败', e);
|
||||||
return { type: 'error', error: e instanceof Error ? e.message : String(e) };
|
return { type: 'error', error: e instanceof Error ? e.message : String(e) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,8 @@ export function calculateMonthlyStats(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成 AI 月度总结。
|
* 生成 AI 月度总结。
|
||||||
*/
|
*/
|
||||||
@ -59,20 +61,27 @@ export async function generateMonthlySummary(
|
|||||||
month: number,
|
month: number,
|
||||||
provider: AiProvider,
|
provider: AiProvider,
|
||||||
): Promise<MonthlySummaryResult> {
|
): Promise<MonthlySummaryResult> {
|
||||||
const stats = calculateMonthlyStats(transactions, year, month);
|
logger.info('aiSummary', `开始生成 AI 月度财务总结 [${year}年${month}月]`);
|
||||||
|
try {
|
||||||
|
const stats = calculateMonthlyStats(transactions, year, month);
|
||||||
|
|
||||||
const messages = buildMonthlySummaryPrompt({
|
const messages = buildMonthlySummaryPrompt({
|
||||||
totalExpense: stats.totalExpense,
|
totalExpense: stats.totalExpense,
|
||||||
totalIncome: stats.totalIncome,
|
totalIncome: stats.totalIncome,
|
||||||
transactionCount: stats.transactionCount,
|
transactionCount: stats.transactionCount,
|
||||||
topCategories: stats.topCategories,
|
topCategories: stats.topCategories,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await provider.chat(messages);
|
const response = await provider.chat(messages);
|
||||||
return {
|
logger.info('aiSummary', `AI 月度财务总结生成成功 [${year}年${month}月]`);
|
||||||
stats,
|
return {
|
||||||
summary: removeThink(response),
|
stats,
|
||||||
};
|
summary: removeThink(response),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('aiSummary', `生成 AI 月度总结失败 [${year}年${month}月]`, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 无 AI 的纯文本总结(降级,不调 AI)。 */
|
/** 无 AI 的纯文本总结(降级,不调 AI)。 */
|
||||||
|
|||||||
@ -141,6 +141,13 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
<TodoStrip />
|
<TodoStrip />
|
||||||
|
|
||||||
|
{/* 月度趋势 */}
|
||||||
|
{monthlyData.length > 0 && (
|
||||||
|
<Card title={t('home.monthlyTrend')}>
|
||||||
|
<TrendLine transactions={transactions} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 最近 5 条交易 */}
|
{/* 最近 5 条交易 */}
|
||||||
{recentTxs.length > 0 && (
|
{recentTxs.length > 0 && (
|
||||||
<Card title={t('home.recentTransactions')}>
|
<Card title={t('home.recentTransactions')}>
|
||||||
@ -152,13 +159,6 @@ export default function HomeScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 月度趋势 */}
|
|
||||||
{monthlyData.length > 0 && (
|
|
||||||
<Card title={t('home.monthlyTrend')}>
|
|
||||||
<TrendLine transactions={transactions} />
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter, type Href } from 'expo-router';
|
import { useRouter, type Href } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/Card';
|
||||||
|
import { Touchable } from '../../components/Touchable';
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@ -13,15 +14,14 @@ export default function SettingsScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const renderNavLink = (icon: keyof typeof Ionicons.glyphMap, label: string, path: Href) => (
|
const renderNavLink = (icon: keyof typeof Ionicons.glyphMap, label: string, path: Href) => (
|
||||||
<Pressable
|
<Touchable
|
||||||
onPress={() => router.push(path)}
|
onPress={() => router.push(path)}
|
||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
style={({ pressed }) => [
|
style={[
|
||||||
styles.navRow,
|
styles.navRow,
|
||||||
{
|
{
|
||||||
borderBottomColor: theme.colors.divider,
|
borderBottomColor: theme.colors.divider,
|
||||||
opacity: pressed ? 0.6 : 1,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@ -30,7 +30,7 @@ export default function SettingsScreen() {
|
|||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
|
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
|
||||||
</Pressable>
|
</Touchable>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -67,6 +67,7 @@ export default function SettingsScreen() {
|
|||||||
<View style={styles.listGroup}>
|
<View style={styles.listGroup}>
|
||||||
{renderNavLink('cloud-upload-outline', t('settings.syncBackup'), '/settings/sync')}
|
{renderNavLink('cloud-upload-outline', t('settings.syncBackup'), '/settings/sync')}
|
||||||
{renderNavLink('pulse-outline', t('settings.diagnostics'), '/settings/diagnostics' as Href)}
|
{renderNavLink('pulse-outline', t('settings.diagnostics'), '/settings/diagnostics' as Href)}
|
||||||
|
{renderNavLink('document-text-outline', t('settings.appLogs'), '/settings/logs' as Href)}
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -74,7 +75,7 @@ export default function SettingsScreen() {
|
|||||||
<Card title={t('settings.groupPreferences')}>
|
<Card title={t('settings.groupPreferences')}>
|
||||||
<View style={styles.listGroup}>
|
<View style={styles.listGroup}>
|
||||||
{renderNavLink('phone-portrait-outline', t('settings.preferencesEntry'), '/settings/preferences')}
|
{renderNavLink('phone-portrait-outline', t('settings.preferencesEntry'), '/settings/preferences')}
|
||||||
{renderNavLink('cog-outline', t('settings.aiSettingsTitle'), '/settings/ai')}
|
{renderNavLink('cog-outline', t('settings.llmTitle'), '/settings/ai')}
|
||||||
{renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)}
|
{renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)}
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { useRouter } from 'expo-router';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
import { useMetadataStore } from '../../store/metadataStore';
|
import { useMetadataStore } from '../../store/metadataStore';
|
||||||
|
import { useNumpadUiStore } from '../../store/numpadUiStore';
|
||||||
import { useTheme, createCommonStyles } from '../../theme';
|
import { useTheme, createCommonStyles } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/Card';
|
||||||
@ -85,7 +86,7 @@ export default function TransactionsScreen() {
|
|||||||
// cost/price 不带:复制是新建交易,丢 cost 属可接受简化(含 cost 的交易可从编辑入口进高级模式)
|
// cost/price 不带:复制是新建交易,丢 cost 属可接受简化(含 cost 的交易可从编辑入口进高级模式)
|
||||||
postings: tx.postings.map(p => ({ account: p.account, amount: p.amount, currency: p.currency })),
|
postings: tx.postings.map(p => ({ account: p.account, amount: p.amount, currency: p.currency })),
|
||||||
});
|
});
|
||||||
router.push({ pathname: '/transaction/new', params: { draftJson } });
|
useNumpadUiStore.getState().open({ draftJson });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 左滑:删除(确认后从 main.bean 移除)
|
// 左滑:删除(确认后从 main.bean 移除)
|
||||||
@ -137,7 +138,7 @@ export default function TransactionsScreen() {
|
|||||||
|
|
||||||
<SectionList
|
<SectionList
|
||||||
sections={sections}
|
sections={sections}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item, index) => `${item.id}-${index}`}
|
||||||
renderItem={({ item: tx }) => (
|
renderItem={({ item: tx }) => (
|
||||||
<SwipeableTransactionCard
|
<SwipeableTransactionCard
|
||||||
transaction={tx}
|
transaction={tx}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { useMetadataStore } from '../store/metadataStore';
|
|||||||
import { parseDecimal } from '../domain/decimal';
|
import { parseDecimal } from '../domain/decimal';
|
||||||
import { useAutomationStore } from '../store/automationStore';
|
import { useAutomationStore } from '../store/automationStore';
|
||||||
import { FileSystemBackend } from '../services/fileSystemBackend';
|
import { FileSystemBackend } from '../services/fileSystemBackend';
|
||||||
|
import { useNumpadUiStore } from '../store/numpadUiStore';
|
||||||
import { useT } from '../i18n';
|
import { useT } from '../i18n';
|
||||||
import { LockScreen } from '../components/LockScreen';
|
import { LockScreen } from '../components/LockScreen';
|
||||||
import { NumpadSheetHost } from '../components/NumpadSheetHost';
|
import { NumpadSheetHost } from '../components/NumpadSheetHost';
|
||||||
@ -25,11 +26,31 @@ import { parseNotification } from '../services/notification';
|
|||||||
import { parseSms } from '../services/sms';
|
import { parseSms } from '../services/sms';
|
||||||
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts, loadProcessedTxKeys, pendingDrafts } from '../services/automationPipeline';
|
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts, loadProcessedTxKeys, pendingDrafts } from '../services/automationPipeline';
|
||||||
import { getAccessibilityBridge } from '../services/accessibilityBridge';
|
import { getAccessibilityBridge } from '../services/accessibilityBridge';
|
||||||
|
import { pushFloatingUiConfig } from '../services/floatingUiConfig';
|
||||||
|
import { ensureOcrModels } from '../services/modelDownloader';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import { expoLogBackend } from '../services/logBackend';
|
||||||
|
|
||||||
let lastSignature = '';
|
let lastSignature = '';
|
||||||
let lastProcessedTime = 0;
|
let lastProcessedTime = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原生浮层 UI 配置同步(P6 FloatingUiConfig 契约):
|
||||||
|
* 启动 / 主题切换 / 语言切换时向原生推送 colors + labels。
|
||||||
|
* 挂在 ThemeProvider 内部,渲染 null。
|
||||||
|
*/
|
||||||
|
function FloatingUiConfigSyncer() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const locale = useSettingsStore(s => s.locale);
|
||||||
|
const t = useT();
|
||||||
|
useEffect(() => {
|
||||||
|
// useT() 每次渲染返回新 bind,故依赖用 locale 而非 t;
|
||||||
|
// locale 变化时 useT 内部已将 i18n.locale 指向新语言
|
||||||
|
void pushFloatingUiConfig(theme, t);
|
||||||
|
}, [theme, locale]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 原生悬浮窗跳转 App 事件类型 */
|
/** 原生悬浮窗跳转 App 事件类型 */
|
||||||
interface NativeOpenAppEvent {
|
interface NativeOpenAppEvent {
|
||||||
draftId?: string;
|
draftId?: string;
|
||||||
@ -41,6 +62,15 @@ interface NativeOpenAppEvent {
|
|||||||
direction?: string;
|
direction?: string;
|
||||||
merchant?: string;
|
merchant?: string;
|
||||||
narration?: string;
|
narration?: string;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeLogText(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
// 脱敏验证码 (4-6位) 及长卡号/身份证 (8位以上数字)
|
||||||
|
return text
|
||||||
|
.replace(/\b(\d{4,6})\b(?=.*(?:验证码|动态码|校验码|code|Code))/gi, '***')
|
||||||
|
.replace(/\b(\d{4})\d{8,11}(\d{4})\b/g, '$1****$2');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */
|
/** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */
|
||||||
@ -109,9 +139,19 @@ function AppShell() {
|
|||||||
persistenceInitRef.current = true;
|
persistenceInitRef.current = true;
|
||||||
// 1. 初始化持久化并还原数据
|
// 1. 初始化持久化并还原数据
|
||||||
initPersistence().then(async () => {
|
initPersistence().then(async () => {
|
||||||
|
// 初始化磁盘日志持久化系统
|
||||||
|
logger.initFileBackend(expoLogBackend).catch(e => {
|
||||||
|
logger.warn('layout', '磁盘日志初始化失败', e);
|
||||||
|
});
|
||||||
|
|
||||||
// 加载已处理的交易键(跨会话去重)
|
// 加载已处理的交易键(跨会话去重)
|
||||||
await loadProcessedTxKeys();
|
await loadProcessedTxKeys();
|
||||||
|
|
||||||
|
// P8:确保 OCR 模型文件可用(首次启动从 APK assets 拷贝到 filesDir)
|
||||||
|
ensureOcrModels().catch(e => {
|
||||||
|
logger.warn('layout', 'OCR 模型初始化失败(非关键路径,截图 OCR 将降级)', e);
|
||||||
|
});
|
||||||
|
|
||||||
const mainPath = FileSystem.documentDirectory + 'main.bean';
|
const mainPath = FileSystem.documentDirectory + 'main.bean';
|
||||||
|
|
||||||
// 架构说明:main.bean 作为主账本文件,包含所有 Beancount 指令(open/close/option/include 等),
|
// 架构说明:main.bean 作为主账本文件,包含所有 Beancount 指令(open/close/option/include 等),
|
||||||
@ -133,6 +173,14 @@ function AppShell() {
|
|||||||
|
|
||||||
const completed = useSettingsStore.getState().onboardingCompleted;
|
const completed = useSettingsStore.getState().onboardingCompleted;
|
||||||
const locked = useSettingsStore.getState().appLockEnabled;
|
const locked = useSettingsStore.getState().appLockEnabled;
|
||||||
|
const floatingEnabled = useSettingsStore.getState().floatingBallEnabled;
|
||||||
|
|
||||||
|
// 开启/初始化时同步悬浮球开关至原生无障碍服务
|
||||||
|
const bridge = getAccessibilityBridge();
|
||||||
|
if (bridge) {
|
||||||
|
bridge.setFloatingBallEnabled(floatingEnabled).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
if (!completed) {
|
if (!completed) {
|
||||||
setPhase('onboarding');
|
setPhase('onboarding');
|
||||||
} else if (locked) {
|
} else if (locked) {
|
||||||
@ -154,7 +202,7 @@ function AppShell() {
|
|||||||
if (phase === 'ready') {
|
if (phase === 'ready') {
|
||||||
const cleanup = setupDeepLinking((action) => {
|
const cleanup = setupDeepLinking((action) => {
|
||||||
if (action.type === 'add-transaction') {
|
if (action.type === 'add-transaction') {
|
||||||
router.push('/transaction/new');
|
useNumpadUiStore.getState().open();
|
||||||
} else if (action.type === 'open-tab') {
|
} else if (action.type === 'open-tab') {
|
||||||
if (action.tab === 'home') router.push('/(tabs)');
|
if (action.tab === 'home') router.push('/(tabs)');
|
||||||
else if (action.tab === 'transactions') router.push('/(tabs)/transactions');
|
else if (action.tab === 'transactions') router.push('/(tabs)/transactions');
|
||||||
@ -164,7 +212,7 @@ function AppShell() {
|
|||||||
} else if (action.type === 'import-csv') {
|
} else if (action.type === 'import-csv') {
|
||||||
router.push('/import');
|
router.push('/import');
|
||||||
} else if (action.type === 'ocr-camera' || action.type === 'ocr-image') {
|
} else if (action.type === 'ocr-camera' || action.type === 'ocr-image') {
|
||||||
router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } });
|
useNumpadUiStore.getState().open({ autoOcr: true });
|
||||||
} else if (action.type === 'voice-input') {
|
} else if (action.type === 'voice-input') {
|
||||||
router.push('/(tabs)/settings');
|
router.push('/(tabs)/settings');
|
||||||
}
|
}
|
||||||
@ -182,13 +230,14 @@ function AppShell() {
|
|||||||
const subscriptions = [
|
const subscriptions = [
|
||||||
DeviceEventEmitter.addListener('billingNotification', (event) => {
|
DeviceEventEmitter.addListener('billingNotification', (event) => {
|
||||||
try {
|
try {
|
||||||
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}, 内容: ${event.text}`);
|
const safeText = sanitizeLogText(event.text || '');
|
||||||
|
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}, 内容: ${safeText}`);
|
||||||
const bill = parseNotification(event);
|
const bill = parseNotification(event);
|
||||||
if (bill) {
|
if (bill) {
|
||||||
logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${event.text}`);
|
logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${safeText}`);
|
||||||
handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`);
|
handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`);
|
||||||
} else {
|
} else {
|
||||||
logger.debug('layout', `通知未匹配为账单: [${event.title}] ${event.text}`);
|
logger.debug('layout', `通知未匹配为账单: [${event.title}] ${safeText}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('layout', '通知解析失败', e);
|
logger.error('layout', '通知解析失败', e);
|
||||||
@ -196,7 +245,8 @@ function AppShell() {
|
|||||||
}),
|
}),
|
||||||
DeviceEventEmitter.addListener('billingSms', (event) => {
|
DeviceEventEmitter.addListener('billingSms', (event) => {
|
||||||
try {
|
try {
|
||||||
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}, 内容: ${event.body}`);
|
const safeBody = sanitizeLogText(event.body || '');
|
||||||
|
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}, 内容: ${safeBody}`);
|
||||||
const smsEvent = {
|
const smsEvent = {
|
||||||
sender: event.sender || event.address || '',
|
sender: event.sender || event.address || '',
|
||||||
body: event.body,
|
body: event.body,
|
||||||
@ -204,73 +254,74 @@ function AppShell() {
|
|||||||
};
|
};
|
||||||
const bill = parseSms(smsEvent);
|
const bill = parseSms(smsEvent);
|
||||||
if (bill) {
|
if (bill) {
|
||||||
logger.debug('layout', `短信解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始短信: ${event.body}`);
|
logger.debug('layout', `短信解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始短信: ${safeBody}`);
|
||||||
handleIncomingBillEvent('sms', bill, event.body);
|
handleIncomingBillEvent('sms', bill, event.body);
|
||||||
} else {
|
} else {
|
||||||
logger.debug('layout', `短信未匹配为账单: ${event.body}`);
|
logger.debug('layout', `短信未匹配为账单: ${safeBody}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('layout', '短信解析失败', e);
|
logger.error('layout', '短信解析失败', e);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
DeviceEventEmitter.addListener('billingScreenshot', (event) => {
|
DeviceEventEmitter.addListener('billingScreenshot', async (event) => {
|
||||||
logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
|
|
||||||
processScreenshotEvent(event).catch((e: unknown) => {
|
|
||||||
logger.error('layout', '截图 OCR 处理失败', e);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
DeviceEventEmitter.addListener('billingOpenApp', (res: NativeOpenAppEvent) => {
|
|
||||||
try {
|
try {
|
||||||
|
logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
|
||||||
|
await processScreenshotEvent(event);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('layout', `截图 OCR 处理失败 (包名: ${event.packageName}, 是否有Base64: ${Boolean(event.imageBase64)})`, e instanceof Error ? { message: e.message, stack: e.stack } : String(e));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
DeviceEventEmitter.addListener('billingOpenApp', async (event) => {
|
||||||
|
try {
|
||||||
|
const res = event as NativeOpenAppEvent;
|
||||||
logger.info('layout', `收到原生悬浮窗跳转 App 请求: ${JSON.stringify(res)}`);
|
logger.info('layout', `收到原生悬浮窗跳转 App 请求: ${JSON.stringify(res)}`);
|
||||||
if (res.draftId) {
|
if (res.confirmed) {
|
||||||
try {
|
// 已由原生悬浮窗确认入账,前端仅记录日志或提示
|
||||||
pendingDrafts.delete(res.draftId);
|
return;
|
||||||
} catch (err) {
|
|
||||||
// 忽略
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const amt = res.amount || '0';
|
if (!res.amount && !res.merchant) {
|
||||||
if (!res.account || !res.category || !amt || amt === '0') {
|
|
||||||
logger.warn('layout', 'billingOpenApp 数据不完整,跳过', JSON.stringify(res));
|
logger.warn('layout', 'billingOpenApp 数据不完整,跳过', JSON.stringify(res));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
const amt = res.amount ?? '0';
|
||||||
const parsed = parseDecimal(amt);
|
const validAmount = parseDecimal(amt);
|
||||||
if (parsed.coefficient <= 0n) {
|
if (!validAmount) {
|
||||||
|
if (amt.length > 0) {
|
||||||
|
logger.warn('layout', 'billingOpenApp 金额格式错误', amt);
|
||||||
|
} else {
|
||||||
logger.warn('layout', 'billingOpenApp 金额无效', amt);
|
logger.warn('layout', 'billingOpenApp 金额无效', amt);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
logger.warn('layout', 'billingOpenApp 金额格式错误', amt);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currency = 'CNY';
|
const direction = res.direction ?? 'expense';
|
||||||
const dir = (res.direction || 'expense') as 'income' | 'expense' | 'transfer';
|
const categoryAccount = res.category ?? 'Expenses:未分类';
|
||||||
const postings = buildPostings(dir, res.account, res.category, amt, currency);
|
const sourceAccount = res.account ?? 'Assets:未知';
|
||||||
|
const currency = res.currency ?? 'CNY';
|
||||||
|
const postings = [
|
||||||
|
{ account: sourceAccount, amount: direction === 'expense' ? `-${validAmount}` : validAmount, currency },
|
||||||
|
{ account: categoryAccount, amount: direction === 'expense' ? validAmount : `-${validAmount}`, currency }
|
||||||
|
];
|
||||||
const draft = {
|
const draft = {
|
||||||
date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0],
|
date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0],
|
||||||
payee: res.merchant || undefined,
|
payee: res.merchant || undefined,
|
||||||
narration: res.narration || '',
|
narration: res.narration || '',
|
||||||
postings
|
postings
|
||||||
};
|
};
|
||||||
router.push({
|
useNumpadUiStore.getState().open({ draftJson: JSON.stringify(draft) });
|
||||||
pathname: '/transaction/new',
|
|
||||||
params: { draftJson: JSON.stringify(draft) }
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('layout', '处理悬浮窗跳转 App 失败', e);
|
logger.error('layout', '处理悬浮窗跳转 App 失败', e);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
DeviceEventEmitter.addListener('billingPageRemembered', (event) => {
|
DeviceEventEmitter.addListener('billingPageRemembered', (event) => {
|
||||||
logger.info('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`);
|
logger.debug('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`);
|
||||||
logger.info('layout', `[无障碍调试] 记住该页面时捕获的所有文本内容: ${JSON.stringify(event.texts)}`);
|
logger.debug('layout', `[无障碍调试] 记住该页面时捕获的所有文本内容: ${JSON.stringify(event.texts)}`);
|
||||||
}),
|
}),
|
||||||
DeviceEventEmitter.addListener('billingDebugNodes', async (event) => {
|
DeviceEventEmitter.addListener('billingDebugNodes', async (event) => {
|
||||||
const { package: pkg, activity, texts, isManual } = event;
|
const { package: pkg, activity, texts, isManual } = event;
|
||||||
const sigKey = `${pkg}|${activity}`;
|
const sigKey = `${pkg}|${activity}`;
|
||||||
|
|
||||||
logger.info('layout', `[无障碍监听] 页面特征信号: ${sigKey}`);
|
logger.debug('layout', `[无障碍监听] 页面特征信号: ${sigKey}`);
|
||||||
logger.info('layout', `[无障碍监听] 页面提取到的文本内容: ${JSON.stringify(texts)}`);
|
logger.debug('layout', `[无障碍监听] 页面提取到的文本内容: ${JSON.stringify(texts?.slice(0, 5))}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bridge = getAccessibilityBridge();
|
const bridge = getAccessibilityBridge();
|
||||||
@ -279,7 +330,7 @@ function AppShell() {
|
|||||||
const signatures = await bridge.getPageSignatures();
|
const signatures = await bridge.getPageSignatures();
|
||||||
const isWhitelisted = signatures.some(s => s.signature === sigKey);
|
const isWhitelisted = signatures.some(s => s.signature === sigKey);
|
||||||
if (!isWhitelisted) {
|
if (!isWhitelisted) {
|
||||||
logger.info('layout', `[无障碍监听] 页面 ${sigKey} 未在自动记账白名单中,拒绝弹窗`);
|
logger.debug('layout', `[无障碍监听] 页面 ${sigKey} 未在自动记账白名单中,拒绝弹窗`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -298,11 +349,12 @@ function AppShell() {
|
|||||||
if (isDetail) {
|
if (isDetail) {
|
||||||
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
|
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
logger.info('layout', '[无障碍] 微信详情页直接文本解析未成功,降级触发 OCR 识别');
|
const textSnippet = texts.slice(0, 5).join(' | ');
|
||||||
|
logger.info('layout', `[无障碍] 微信详情页直接文本解析未成功 (截获文本前5句: [${textSnippet}]),降级触发 OCR 识别`);
|
||||||
bridge?.triggerManualOcr();
|
bridge?.triggerManualOcr();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info('layout', '[无障碍] 微信非详情页面,不触发记账与截图');
|
logger.debug('layout', '[无障碍] 微信非详情页面,不触发记账与截图');
|
||||||
}
|
}
|
||||||
} else if (pkg === 'com.eg.android.AlipayGphone') {
|
} else if (pkg === 'com.eg.android.AlipayGphone') {
|
||||||
// 支付宝:识别是否是详情页
|
// 支付宝:识别是否是详情页
|
||||||
@ -310,11 +362,12 @@ function AppShell() {
|
|||||||
if (isDetail) {
|
if (isDetail) {
|
||||||
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
|
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
logger.info('layout', '[无障碍] 支付宝详情页直接文本解析未成功,降级触发 OCR 识别');
|
const textSnippet = texts.slice(0, 5).join(' | ');
|
||||||
|
logger.info('layout', `[无障碍] 支付宝详情页直接文本解析未成功 (截获文本前5句: [${textSnippet}]),降级触发 OCR 识别`);
|
||||||
bridge?.triggerManualOcr();
|
bridge?.triggerManualOcr();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info('layout', '[无障碍] 支付宝非详情页面,不触发记账与截图');
|
logger.debug('layout', '[无障碍] 支付宝非详情页面,不触发记账与截图');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 其他白名单应用(如各大银行、QQ、TIM等),直接通过截图 + OCR 识别
|
// 其他白名单应用(如各大银行、QQ、TIM等),直接通过截图 + OCR 识别
|
||||||
@ -377,7 +430,6 @@ function AppShell() {
|
|||||||
<StatusBar style={isDark ? 'light' : 'dark'} />
|
<StatusBar style={isDark ? 'light' : 'dark'} />
|
||||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}>
|
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}>
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="transaction/new" options={{ headerShown: false }} />
|
|
||||||
<Stack.Screen name="transaction/[id]" options={{ headerShown: false }} />
|
<Stack.Screen name="transaction/[id]" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="category/index" options={{ headerShown: false }} />
|
<Stack.Screen name="category/index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="tag/index" options={{ headerShown: false }} />
|
<Stack.Screen name="tag/index" options={{ headerShown: false }} />
|
||||||
@ -413,6 +465,7 @@ export default function RootLayout() {
|
|||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<FloatingUiConfigSyncer />
|
||||||
<AppShell />
|
<AppShell />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
|
|||||||
@ -12,14 +12,16 @@
|
|||||||
* 完成后标记 onboarding 已完成(持久化),后续启动不再显示。
|
* 完成后标记 onboarding 已完成(持久化),后续启动不再显示。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { AppState, Platform, PermissionsAndroid, Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../theme';
|
import { useTheme } from '../theme';
|
||||||
import { useT } from '../i18n';
|
import { useT } from '../i18n';
|
||||||
import { useSettingsStore, type Locale, type ThemeMode } from '../store/settingsStore';
|
import { useSettingsStore, type Locale, type ThemeMode } from '../store/settingsStore';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/Button';
|
||||||
|
import { getAccessibilityBridge } from '../services/accessibilityBridge';
|
||||||
|
import { Card } from '../components/Card';
|
||||||
|
|
||||||
export interface OnboardingStep {
|
export interface OnboardingStep {
|
||||||
key: string;
|
key: string;
|
||||||
@ -52,6 +54,68 @@ export function OnboardingScreen({ onComplete }: OnboardingProps) {
|
|||||||
const current = steps[step];
|
const current = steps[step];
|
||||||
const isLast = step === steps.length - 1;
|
const isLast = step === steps.length - 1;
|
||||||
|
|
||||||
|
// ── 权限状态管理(P7 Task 2) ──
|
||||||
|
const [permStates, setPermStates] = useState<Record<string, boolean | null>>({
|
||||||
|
accessibility: null, notification: null, sms: null, storage: null, overlay: null,
|
||||||
|
});
|
||||||
|
const permsChecked = !Object.values(permStates).some(v => v === null);
|
||||||
|
const allGranted = permsChecked && Object.values(permStates).every(Boolean);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS !== 'android') return;
|
||||||
|
|
||||||
|
const checkAllPermissions = async () => {
|
||||||
|
if (current.key !== 'permissions') return;
|
||||||
|
const bridge = getAccessibilityBridge();
|
||||||
|
let a = false, n = false, o = false, s = false, st = false;
|
||||||
|
if (bridge) {
|
||||||
|
try { a = await bridge.isServiceRunning(); } catch { /* false */ }
|
||||||
|
try { n = await bridge.isNotificationListenerEnabled(); } catch { /* false */ }
|
||||||
|
try { o = await bridge.canDrawOverlays(); } catch { /* false */ }
|
||||||
|
}
|
||||||
|
try { s = await PermissionsAndroid.check('android.permission.RECEIVE_SMS') as boolean; } catch { /* false */ }
|
||||||
|
const storagePerm = parseInt(String(Platform.Version), 10) >= 33
|
||||||
|
? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
|
||||||
|
try { st = await PermissionsAndroid.check(storagePerm) as boolean; } catch { /* false */ }
|
||||||
|
setPermStates({ accessibility: a, notification: n, overlay: o, sms: s, storage: st });
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAllPermissions();
|
||||||
|
|
||||||
|
// 从系统设置返回后重新检查
|
||||||
|
const sub = AppState.addEventListener('change', (state) => {
|
||||||
|
if (state === 'active') checkAllPermissions();
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [current.key]);
|
||||||
|
|
||||||
|
const handlePermAction = async (key: string) => {
|
||||||
|
const storagePerm = parseInt(String(Platform.Version), 10) >= 33
|
||||||
|
? 'android.permission.READ_MEDIA_IMAGES'
|
||||||
|
: 'android.permission.READ_EXTERNAL_STORAGE';
|
||||||
|
switch (key) {
|
||||||
|
case 'accessibility':
|
||||||
|
await Linking.sendIntent('android.settings.ACCESSIBILITY_SETTINGS');
|
||||||
|
break;
|
||||||
|
case 'notification':
|
||||||
|
await Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS');
|
||||||
|
break;
|
||||||
|
case 'sms': {
|
||||||
|
const result = await PermissionsAndroid.request('android.permission.RECEIVE_SMS');
|
||||||
|
setPermStates(p => ({ ...p, sms: result === PermissionsAndroid.RESULTS.GRANTED }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'storage': {
|
||||||
|
const result = await PermissionsAndroid.request(storagePerm);
|
||||||
|
setPermStates(p => ({ ...p, storage: result === PermissionsAndroid.RESULTS.GRANTED }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'overlay':
|
||||||
|
await Linking.sendIntent('android.settings.action.MANAGE_OVERLAY_PERMISSION');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
@ -97,6 +161,55 @@ export function OnboardingScreen({ onComplete }: OnboardingProps) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{current.key === 'permissions' && Platform.OS === 'android' && (
|
||||||
|
<View style={{ width: '100%', marginTop: 24 }}>
|
||||||
|
<Card>
|
||||||
|
{[
|
||||||
|
{ key: 'accessibility', icon: 'accessibility-outline' as const, label: t('onboarding.permAccessibility') },
|
||||||
|
{ key: 'notification', icon: 'notifications-outline' as const, label: t('onboarding.permNotification') },
|
||||||
|
{ key: 'sms', icon: 'chatbubble-outline' as const, label: t('onboarding.permSms') },
|
||||||
|
{ key: 'storage', icon: 'images-outline' as const, label: t('onboarding.permStorage') },
|
||||||
|
{ key: 'overlay', icon: 'tablet-landscape-outline' as const, label: t('onboarding.permOverlay') },
|
||||||
|
].map(item => {
|
||||||
|
const status = permStates[item.key];
|
||||||
|
const granted = status === true;
|
||||||
|
const statusColor = granted ? theme.colors.success : theme.colors.fgSecondary;
|
||||||
|
const statusText = status === null ? '...' : granted ? t('onboarding.permGranted') : t('onboarding.permNotGranted');
|
||||||
|
return (
|
||||||
|
<View key={item.key} style={styles.permRow}>
|
||||||
|
<View style={styles.permLeft}>
|
||||||
|
<Ionicons name={item.icon} size={20} color={granted ? theme.colors.success : theme.colors.fgSecondary} />
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, marginLeft: 12 }]}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.permRight}>
|
||||||
|
<Text style={[theme.typography.caption, { color: statusColor, marginRight: 8 }]}>
|
||||||
|
{statusText}
|
||||||
|
</Text>
|
||||||
|
{!granted && !allGranted && (
|
||||||
|
<Button
|
||||||
|
label={item.key === 'sms' || item.key === 'storage' ? t('onboarding.permRequest') : t('onboarding.permOpenSettings')}
|
||||||
|
onPress={() => handlePermAction(item.key)}
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Card>
|
||||||
|
{allGranted && (
|
||||||
|
<View style={styles.allGrantedWrap}>
|
||||||
|
<Ionicons name="checkmark-circle" size={48} color={theme.colors.success} />
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.success, marginTop: 8 }]}>
|
||||||
|
{t('onboarding.permAllDone')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{current.key === 'security' && (
|
{current.key === 'security' && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setAppLockEnabled(!appLockEnabled)}
|
onPress={() => setAppLockEnabled(!appLockEnabled)}
|
||||||
@ -146,4 +259,9 @@ const styles = StyleSheet.create({
|
|||||||
dots: { flexDirection: 'row', justifyContent: 'center', gap: 6, marginBottom: 12 },
|
dots: { flexDirection: 'row', justifyContent: 'center', gap: 6, marginBottom: 12 },
|
||||||
dot: { width: 8, height: 8, borderRadius: 4 },
|
dot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
actions: { flexDirection: 'row', gap: 12, justifyContent: 'flex-end' },
|
actions: { flexDirection: 'row', gap: 12, justifyContent: 'flex-end' },
|
||||||
|
// 权限清单(P7 Task 2)
|
||||||
|
permRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
|
||||||
|
permLeft: { flexDirection: 'row', alignItems: 'center', flex: 1 },
|
||||||
|
permRight: { flexDirection: 'row', alignItems: 'center' },
|
||||||
|
allGrantedWrap: { alignItems: 'center', marginTop: 24 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useTheme } from '../../theme';
|
|||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/Card';
|
||||||
|
import { AccountCreateModal } from '../../components/AccountCreateModal';
|
||||||
import { FormModal, type FormField } from '../../components/FormModal';
|
import { FormModal, type FormField } from '../../components/FormModal';
|
||||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||||
import { toDateString } from '../../domain/decimal';
|
import { toDateString } from '../../domain/decimal';
|
||||||
@ -133,18 +134,29 @@ export default function AccountScreen() {
|
|||||||
{ key: 'income', label: t('account.tabIncome') },
|
{ key: 'income', label: t('account.tabIncome') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const rootTypeOptions = [
|
||||||
|
{ label: t('account.rootTypes.Assets'), value: 'Assets', subLabel: 'Assets' },
|
||||||
|
{ label: t('account.rootTypes.Liabilities'), value: 'Liabilities', subLabel: 'Liabilities' },
|
||||||
|
{ label: t('account.rootTypes.Expenses'), value: 'Expenses', subLabel: 'Expenses' },
|
||||||
|
{ label: t('account.rootTypes.Income'), value: 'Income', subLabel: 'Income' },
|
||||||
|
{ label: t('account.rootTypes.Equity'), value: 'Equity', subLabel: 'Equity' },
|
||||||
|
];
|
||||||
|
|
||||||
const fields: FormField[] = [
|
const fields: FormField[] = [
|
||||||
{
|
{
|
||||||
key: 'type',
|
key: 'type',
|
||||||
label: t('account.fieldType'),
|
label: t('account.fieldType'),
|
||||||
placeholder: 'Assets / Liabilities / Expenses / Income',
|
type: 'dropdown',
|
||||||
|
options: rootTypeOptions,
|
||||||
defaultValue: tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income',
|
defaultValue: tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income',
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
label: t('account.fieldName'),
|
label: t('account.fieldName'),
|
||||||
placeholder: t('account.fieldNamePlaceholder'),
|
placeholder: t('account.fieldNamePlaceholder'),
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
|
flex: 1.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'currency',
|
key: 'currency',
|
||||||
@ -198,6 +210,8 @@ export default function AccountScreen() {
|
|||||||
|
|
||||||
{filteredAccounts.map(account => {
|
{filteredAccounts.map(account => {
|
||||||
const shortName = account.slice(prefix.length);
|
const shortName = account.slice(prefix.length);
|
||||||
|
const rootType = account.split(':')[0] as 'Assets' | 'Liabilities' | 'Expenses' | 'Income' | 'Equity';
|
||||||
|
const localizedRoot = t(`account.rootLabels.${rootType}`);
|
||||||
const balance = balances.get(account) ?? '0.00';
|
const balance = balances.get(account) ?? '0.00';
|
||||||
const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY';
|
const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY';
|
||||||
|
|
||||||
@ -208,7 +222,7 @@ export default function AccountScreen() {
|
|||||||
{t('account.fullName')}
|
{t('account.fullName')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
||||||
{account}
|
{account} {localizedRoot ? `(${localizedRoot})` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.infoRow}>
|
<View style={styles.infoRow}>
|
||||||
@ -257,10 +271,9 @@ export default function AccountScreen() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* 新开户对话框 */}
|
{/* 新开户对话框 */}
|
||||||
<FormModal
|
<AccountCreateModal
|
||||||
visible={isAdding}
|
visible={isAdding}
|
||||||
title={t('account.addModalTitle')}
|
defaultType={tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income'}
|
||||||
fields={fields}
|
|
||||||
onConfirm={handleAddAccount}
|
onConfirm={handleAddAccount}
|
||||||
onCancel={() => setIsAdding(false)}
|
onCancel={() => setIsAdding(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { useRouter } from 'expo-router';
|
|||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { useSettingsStore } from '../../store/settingsStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { useNumpadUiStore } from '../../store/numpadUiStore';
|
||||||
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai';
|
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai';
|
||||||
import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant';
|
import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant';
|
||||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||||
@ -123,7 +124,7 @@ export default function AIChatScreen() {
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={i}
|
key={i}
|
||||||
onPress={() => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } })}
|
onPress={() => useNumpadUiStore.getState().open({ autoOcr: true })}
|
||||||
style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent }]}
|
style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent }]}
|
||||||
>
|
>
|
||||||
<Text style={[theme.typography.bodySmall, { color: dirColor, fontWeight: '700' }]}>
|
<Text style={[theme.typography.bodySmall, { color: dirColor, fontWeight: '700' }]}>
|
||||||
|
|||||||
@ -15,51 +15,55 @@
|
|||||||
* - 已记住页面列表(可删除)
|
* - 已记住页面列表(可删除)
|
||||||
* - 支付 App 白名单展示
|
* - 支付 App 白名单展示
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View, Switch } from 'react-native';
|
import { ActivityIndicator, Alert, AppState, Linking, NativeModules, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, Switch } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
|
import { createCommonStyles } from '../../theme/commonStyles';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { useAutomationStore } from '../../store/automationStore';
|
import { useRouter } from 'expo-router';
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
|
||||||
import { useMetadataStore } from '../../store/metadataStore';
|
|
||||||
import { useSettingsStore } from '../../store/settingsStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/Card';
|
||||||
import { Button } from '../../components/Button';
|
import { Button } from '../../components/Button';
|
||||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||||
import type { AutomationSource } from '../../store/automationStore';
|
|
||||||
import {
|
import {
|
||||||
getAccessibilityBridge,
|
getAccessibilityBridge,
|
||||||
getPackageLabel,
|
getPackageLabel,
|
||||||
type PageSignature,
|
type PageSignature,
|
||||||
} from '../../services/accessibilityBridge';
|
} from '../../services/accessibilityBridge';
|
||||||
|
import { ensureOcrModels, downloadOcrModels } from '../../services/modelDownloader';
|
||||||
const SOURCE_LABELS: Record<AutomationSource, string> = {
|
|
||||||
notification: 'automation.sourceNotification',
|
|
||||||
sms: 'automation.sourceSms',
|
|
||||||
screenshot: 'automation.sourceScreenshot',
|
|
||||||
ocr: 'automation.sourceOcr',
|
|
||||||
manual: 'automation.sourceManual',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AutomationScreen() {
|
export default function AutomationScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const commonStyles = createCommonStyles(theme);
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const router = useRouter();
|
||||||
const detected = useAutomationStore(s => s.detected);
|
|
||||||
const drafts = useAutomationStore(s => s.drafts);
|
|
||||||
const stats = useAutomationStore(s => s.stats);
|
|
||||||
const processAll = useAutomationStore(s => s.processAll);
|
|
||||||
const confirmDraft = useAutomationStore(s => s.confirmDraft);
|
|
||||||
const rejectDraft = useAutomationStore(s => s.rejectDraft);
|
|
||||||
|
|
||||||
const ledger = useLedgerStore(s => s.ledger);
|
|
||||||
const addTransaction = useLedgerStore(s => s.addTransaction);
|
|
||||||
|
|
||||||
const floatingBallEnabled = useSettingsStore(s => s.floatingBallEnabled);
|
const floatingBallEnabled = useSettingsStore(s => s.floatingBallEnabled);
|
||||||
const setFloatingBallEnabled = useSettingsStore(s => s.setFloatingBallEnabled);
|
const setFloatingBallEnabled = useSettingsStore(s => s.setFloatingBallEnabled);
|
||||||
|
|
||||||
|
// 自动记账层级
|
||||||
|
const layer1RuleEnabled = useSettingsStore(s => s.layer1RuleEnabled);
|
||||||
|
const setLayer1RuleEnabled = useSettingsStore(s => s.setLayer1RuleEnabled);
|
||||||
|
const layer2OcrEnabled = useSettingsStore(s => s.layer2OcrEnabled);
|
||||||
|
const setLayer2OcrEnabled = useSettingsStore(s => s.setLayer2OcrEnabled);
|
||||||
|
const layer3AiEnabled = useSettingsStore(s => s.layer3AiEnabled);
|
||||||
|
const setLayer3AiEnabled = useSettingsStore(s => s.setLayer3AiEnabled);
|
||||||
|
const ocrModelVersion = useSettingsStore(s => s.ocrModelVersion);
|
||||||
|
const setOcrModelVersion = useSettingsStore(s => s.setOcrModelVersion);
|
||||||
|
const setOcrModelDir = useSettingsStore(s => s.setOcrModelDir);
|
||||||
|
const aiEnabled = useSettingsStore(s => s.aiEnabled);
|
||||||
|
const aiProviderId = useSettingsStore(s => s.aiProviderId);
|
||||||
|
const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
|
||||||
|
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || '';
|
||||||
|
const aiModel = useSettingsStore(s => s.aiModel) || '';
|
||||||
|
const updateAiConfig = useSettingsStore(s => s.updateAiConfig);
|
||||||
|
const dedupEnabled = useSettingsStore(s => s.dedupEnabled);
|
||||||
|
const setDedupEnabled = useSettingsStore(s => s.setDedupEnabled);
|
||||||
|
const transferRecognitionEnabled = useSettingsStore(s => s.transferRecognitionEnabled);
|
||||||
|
const setTransferRecognitionEnabled = useSettingsStore(s => s.setTransferRecognitionEnabled);
|
||||||
|
|
||||||
// 无障碍服务状态
|
// 无障碍服务状态
|
||||||
const [serviceRunning, setServiceRunning] = useState(false);
|
const [serviceRunning, setServiceRunning] = useState(false);
|
||||||
const [pageSignatures, setPageSignatures] = useState<PageSignature[]>([]);
|
const [pageSignatures, setPageSignatures] = useState<PageSignature[]>([]);
|
||||||
@ -67,7 +71,74 @@ export default function AutomationScreen() {
|
|||||||
const [screenshotActive, setScreenshotActive] = useState(false);
|
const [screenshotActive, setScreenshotActive] = useState(false);
|
||||||
const [topApp, setTopApp] = useState<{ package: string; activity: string } | null>(null);
|
const [topApp, setTopApp] = useState<{ package: string; activity: string } | null>(null);
|
||||||
|
|
||||||
const sourceEntries = useMemo(() => Object.entries(stats) as [AutomationSource, number][], [stats]);
|
// 权限状态
|
||||||
|
const [notifEnabled, setNotifEnabled] = useState<boolean | null>(null);
|
||||||
|
const [smsGranted, setSmsGranted] = useState<boolean | null>(null);
|
||||||
|
const [storageGranted, setStorageGranted] = useState<boolean | null>(null);
|
||||||
|
const [overlayGranted, setOverlayGranted] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
// OCR 模型安装/下载状态
|
||||||
|
const [modelStatus, setModelStatus] = useState<'idle' | 'installing' | 'verifying' | 'done' | 'error'>('idle');
|
||||||
|
const [modelProgress, setModelProgress] = useState(0);
|
||||||
|
|
||||||
|
const handleInstallModel = async () => {
|
||||||
|
setModelStatus('installing');
|
||||||
|
try {
|
||||||
|
await ensureOcrModels((p) => setModelProgress(p));
|
||||||
|
setModelStatus('done');
|
||||||
|
} catch (e) {
|
||||||
|
setModelStatus('error');
|
||||||
|
Alert.alert(t('common.error'), String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRedownloadModel = async () => {
|
||||||
|
setModelStatus('installing');
|
||||||
|
setModelProgress(0);
|
||||||
|
try {
|
||||||
|
await downloadOcrModels(undefined, (p) => setModelProgress(p));
|
||||||
|
setModelStatus('done');
|
||||||
|
} catch (e) {
|
||||||
|
setModelStatus('error');
|
||||||
|
Alert.alert(t('automation.ocrModelDownloadFail'), String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleL2Toggle = (val: boolean) => {
|
||||||
|
if (val && !ocrModelVersion) {
|
||||||
|
Alert.alert(t('automation.ocrModelTitle'), t('automation.ocrModelNotInstalled'), [
|
||||||
|
{ text: t('common.cancel'), onPress: () => setLayer2OcrEnabled(false) },
|
||||||
|
{ text: t('automation.ocrModelInstall'), onPress: handleInstallModel },
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLayer2OcrEnabled(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
|
const handleL3Toggle = (val: boolean) => {
|
||||||
|
if (val && (!aiEnabled || !aiApiKey)) {
|
||||||
|
Alert.alert(t('automation.layer3Ai'), t('automation.layer3AiDisabled'), [
|
||||||
|
{ text: t('common.cancel'), style: 'cancel' },
|
||||||
|
{ text: t('common.confirm'), onPress: () => router.push('/settings/ai') },
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLayer3AiEnabled(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerDefaultUrls: Record<string, string> = {
|
||||||
|
openai: 'https://api.openai.com/v1',
|
||||||
|
gemini: 'https://generativelanguage.googleapis.com/v1beta',
|
||||||
|
deepseek: 'https://api.deepseek.com/v1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerDefaultModels: Record<string, string> = {
|
||||||
|
openai: 'gpt-4o-mini',
|
||||||
|
gemini: 'gemini-2.0-flash',
|
||||||
|
deepseek: 'deepseek-chat',
|
||||||
|
};
|
||||||
|
|
||||||
// 刷新无障碍状态
|
// 刷新无障碍状态
|
||||||
const refreshAccessibilityState = useCallback(async () => {
|
const refreshAccessibilityState = useCallback(async () => {
|
||||||
@ -103,27 +174,56 @@ export default function AutomationScreen() {
|
|||||||
};
|
};
|
||||||
}, [refreshAccessibilityState]);
|
}, [refreshAccessibilityState]);
|
||||||
|
|
||||||
const handleProcess = async () => {
|
// 检查权限状态(挂载 + 从系统设置返回前台时重新检查)
|
||||||
if (!ledger) return;
|
useEffect(() => {
|
||||||
const rules = useMetadataStore.getState().rules;
|
if (Platform.OS !== 'android') return;
|
||||||
const categories = useMetadataStore.getState().categories;
|
|
||||||
const history = ledger.transactions;
|
const checkPermissions = () => {
|
||||||
try {
|
const bridge = getAccessibilityBridge();
|
||||||
await processAll(ledger, rules, categories, history);
|
if (bridge && typeof bridge.isNotificationListenerEnabled === 'function') {
|
||||||
} catch (e) {
|
bridge.isNotificationListenerEnabled().then(setNotifEnabled).catch(() => setNotifEnabled(false));
|
||||||
Alert.alert(String(e));
|
}
|
||||||
}
|
if (bridge && typeof bridge.canDrawOverlays === 'function') {
|
||||||
|
bridge.canDrawOverlays().then(setOverlayGranted).catch(() => setOverlayGranted(false));
|
||||||
|
}
|
||||||
|
const smsPerm = 'android.permission.RECEIVE_SMS';
|
||||||
|
PermissionsAndroid.check(smsPerm).then(async (granted) => {
|
||||||
|
if (granted) { setSmsGranted(true); return; }
|
||||||
|
try {
|
||||||
|
const result = await PermissionsAndroid.request(smsPerm);
|
||||||
|
setSmsGranted(result === PermissionsAndroid.RESULTS.GRANTED);
|
||||||
|
} catch { setSmsGranted(false); }
|
||||||
|
}).catch(() => setSmsGranted(false));
|
||||||
|
const storagePerm = Number(Platform.Version) >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
|
||||||
|
PermissionsAndroid.check(storagePerm).then(g => setStorageGranted(g as boolean)).catch(() => setStorageGranted(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
checkPermissions();
|
||||||
|
|
||||||
|
const sub = AppState.addEventListener('change', (state) => {
|
||||||
|
if (state === 'active') checkPermissions();
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenNotificationSettings = () => {
|
||||||
|
if (Platform.OS !== 'android') return;
|
||||||
|
Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS').catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = async (index: number) => {
|
const handleRequestSms = async () => {
|
||||||
const draft = confirmDraft(index);
|
const result = await PermissionsAndroid.request('android.permission.RECEIVE_SMS');
|
||||||
if (!draft) return;
|
setSmsGranted(result === PermissionsAndroid.RESULTS.GRANTED);
|
||||||
try {
|
};
|
||||||
await addTransaction(draft.draft);
|
|
||||||
Alert.alert(t('automation.confirmed'));
|
const handleOpenOverlaySettings = () => {
|
||||||
} catch (e) {
|
Linking.sendIntent('android.settings.action.MANAGE_OVERLAY_PERMISSION').catch(() => {});
|
||||||
Alert.alert(String(e));
|
};
|
||||||
}
|
|
||||||
|
const handleRequestStorage = async () => {
|
||||||
|
const perm = Number(Platform.Version) >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
|
||||||
|
const result = await PermissionsAndroid.request(perm);
|
||||||
|
setStorageGranted(result === PermissionsAndroid.RESULTS.GRANTED);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScreenshotToggle = () => {
|
const handleScreenshotToggle = () => {
|
||||||
@ -251,37 +351,8 @@ export default function AutomationScreen() {
|
|||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||||
<ScreenHeader title={t('automation.title')} />
|
<ScreenHeader title={t('automation.title')} />
|
||||||
|
|
||||||
<FlatList
|
<ScrollView ref={scrollRef} contentContainerStyle={styles.content}>
|
||||||
data={drafts}
|
|
||||||
keyExtractor={(item, index) => item.draft.sourceEventIds?.[0] ?? `${item.draft.date}-${index}`}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<Card title={`${item.draft.date} · ${item.draft.narration}`}>
|
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
|
|
||||||
{item.draft.postings.map(p => `${p.account} ${p.amount} ${p.currency ?? ''}`).join('\n')}
|
|
||||||
</Text>
|
|
||||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 8 }}>
|
|
||||||
<Button label={t('automation.confirmDraft')} onPress={() => handleConfirm(index)} />
|
|
||||||
<Button label={t('automation.rejectDraft')} onPress={() => rejectDraft(index)} variant="secondary" />
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
ListHeaderComponent={
|
|
||||||
<View style={{ gap: 12, marginBottom: 12 }}>
|
<View style={{ gap: 12, marginBottom: 12 }}>
|
||||||
{/* 通道统计 */}
|
|
||||||
<Card title={t('automation.channelStats')}>
|
|
||||||
<View style={styles.statsRow}>
|
|
||||||
{sourceEntries.map(([source, count]) => (
|
|
||||||
<View key={source} style={styles.statItem}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
|
||||||
{t(SOURCE_LABELS[source])}
|
|
||||||
</Text>
|
|
||||||
<Text style={[theme.typography.h3, { color: count > 0 ? theme.colors.accent : theme.colors.fgSecondary }]}>
|
|
||||||
{count}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 无障碍服务状态与控制 */}
|
{/* 无障碍服务状态与控制 */}
|
||||||
{Platform.OS === 'android' && (
|
{Platform.OS === 'android' && (
|
||||||
@ -397,39 +468,186 @@ export default function AutomationScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 检测到的事件 */}
|
{/* --- Card A: 自动记账层级 --- */}
|
||||||
<Card title={t('automation.detectedEvents', { count: detected.length })}>
|
<Card title={t('automation.autoBookkeeping')}>
|
||||||
{detected.length === 0 ? (
|
{/* L1 规则匹配 */}
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
<View style={[styles.layerRow, { marginBottom: 8 }]}>
|
||||||
{t('automation.noEvents')}
|
<Ionicons name="flash-outline" size={20} color={layer1RuleEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
|
||||||
|
<View style={{ flex: 1, marginLeft: 10 }}>
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
||||||
|
{t('automation.layer1Rule')}
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{t('automation.layer1RuleDesc')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={layer1RuleEnabled}
|
||||||
|
onValueChange={setLayer1RuleEnabled}
|
||||||
|
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
||||||
|
thumbColor={theme.colors.fgInverse}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* L2 OCR 识别 */}
|
||||||
|
<View style={[styles.layerRow, { marginBottom: 8 }]}>
|
||||||
|
<Ionicons name="scan-outline" size={20} color={layer2OcrEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
|
||||||
|
<View style={{ flex: 1, marginLeft: 10 }}>
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
||||||
|
{t('automation.layer2Ocr')}
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{t('automation.layer2OcrDesc')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={layer2OcrEnabled}
|
||||||
|
onValueChange={handleL2Toggle}
|
||||||
|
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
||||||
|
thumbColor={theme.colors.fgInverse}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* L3 AI 视觉 */}
|
||||||
|
<View style={styles.layerRow}>
|
||||||
|
<Ionicons name="sparkles-outline" size={20} color={layer3AiEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
|
||||||
|
<View style={{ flex: 1, marginLeft: 10 }}>
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
||||||
|
{t('automation.layer3Ai')}
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{t('automation.layer3AiDesc')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={layer3AiEnabled}
|
||||||
|
onValueChange={handleL3Toggle}
|
||||||
|
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
||||||
|
thumbColor={theme.colors.fgInverse}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* --- Card B: OCR 模型 --- */}
|
||||||
|
<Card title={t('automation.ocrModelTitle')}>
|
||||||
|
{/* 状态行 */}
|
||||||
|
<View style={styles.layerRow}>
|
||||||
|
<Ionicons
|
||||||
|
name="hardware-chip-outline"
|
||||||
|
size={20}
|
||||||
|
color={ocrModelVersion ? theme.colors.success : theme.colors.fgSecondary}
|
||||||
|
/>
|
||||||
|
<Text style={[theme.typography.body, { flex: 1, marginLeft: 10, color: ocrModelVersion ? theme.colors.success : theme.colors.fgSecondary }]}>
|
||||||
|
{ocrModelVersion
|
||||||
|
? t('automation.ocrModelInstalled', { version: ocrModelVersion })
|
||||||
|
: t('automation.ocrModelNotInstalled')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
</View>
|
||||||
<View style={{ gap: 4 }}>
|
|
||||||
{detected.map(ev => (
|
{/* 下载进度条 */}
|
||||||
<Text key={ev.id} style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
{modelStatus === 'installing' && (
|
||||||
[{t(SOURCE_LABELS[ev.source])}] {ev.event.counterparty} · {ev.event.amount} {ev.event.currency}
|
<View style={{ marginTop: 8 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.accent} />
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, flex: 1 }]}>
|
||||||
|
{modelProgress > 0
|
||||||
|
? t('automation.ocrModelDownloading', { progress: String(modelProgress) })
|
||||||
|
: t('automation.ocrModelCopying')}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
|
||||||
<View style={{ marginTop: 8 }}>
|
|
||||||
<Button label={t('automation.process')} onPress={handleProcess} />
|
|
||||||
</View>
|
</View>
|
||||||
|
{modelProgress > 0 && (
|
||||||
|
<View style={[styles.progressBar, { backgroundColor: theme.colors.progressBg, marginTop: 6 }]}>
|
||||||
|
<View style={[styles.progressFill, { backgroundColor: theme.colors.accent, width: `${modelProgress}%` }]} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮行 */}
|
||||||
|
{modelStatus !== 'installing' && (
|
||||||
|
<View style={[styles.layerRow, { marginTop: 8 }]}>
|
||||||
|
{!ocrModelVersion || modelStatus === 'error' ? (
|
||||||
|
<Button label={t('automation.ocrModelDownload')} onPress={handleInstallModel} variant="secondary" />
|
||||||
|
) : (
|
||||||
|
<Button label={t('automation.ocrModelRedownload')} onPress={handleRedownloadModel} variant="secondary" />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 草稿标题 */}
|
{/* --- Card C: 管道设置 --- */}
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
|
<Card title={t('settings.algorithmConfig')}>
|
||||||
{t('automation.drafts', { count: drafts.length })}
|
<View style={[styles.switchRow, { marginBottom: 8 }]}>
|
||||||
</Text>
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
||||||
|
{t('settings.dedupLabel')}
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
value={dedupEnabled}
|
||||||
|
onValueChange={setDedupEnabled}
|
||||||
|
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
||||||
|
thumbColor={theme.colors.fgInverse}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.switchRow}>
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
||||||
|
{t('settings.transferLabel')}
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
value={transferRecognitionEnabled}
|
||||||
|
onValueChange={setTransferRecognitionEnabled}
|
||||||
|
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
||||||
|
thumbColor={theme.colors.fgInverse}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 其他权限 */}
|
||||||
|
{Platform.OS === 'android' && (
|
||||||
|
<Card title={t('automation.otherPermissions')}>
|
||||||
|
{/* 通知监听 */}
|
||||||
|
<View style={styles.permRow}>
|
||||||
|
<Ionicons name="notifications-outline" size={20} color={notifEnabled ? theme.colors.success : theme.colors.fgSecondary} />
|
||||||
|
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.notificationTitle')}</Text>
|
||||||
|
<Text style={{ color: notifEnabled ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
|
||||||
|
{notifEnabled ? t('automation.notificationEnabled') : t('automation.notificationDisabled')}
|
||||||
|
</Text>
|
||||||
|
{!notifEnabled && <Button label={t('automation.notificationOpenSettings')} onPress={handleOpenNotificationSettings} variant="secondary" />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 短信 */}
|
||||||
|
<View style={[styles.permRow, { marginTop: 8 }]}>
|
||||||
|
<Ionicons name="chatbubble-outline" size={20} color={smsGranted ? theme.colors.success : theme.colors.fgSecondary} />
|
||||||
|
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.smsPermissionTitle')}</Text>
|
||||||
|
<Text style={{ color: smsGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
|
||||||
|
{t('automation.smsPermGranted')}
|
||||||
|
</Text>
|
||||||
|
{!smsGranted && <Button label={t('automation.smsPermRequest')} onPress={handleRequestSms} variant="secondary" />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 存储 */}
|
||||||
|
<View style={[styles.permRow, { marginTop: 8 }]}>
|
||||||
|
<Ionicons name="images-outline" size={20} color={storageGranted ? theme.colors.success : theme.colors.fgSecondary} />
|
||||||
|
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.storagePermTitle')}</Text>
|
||||||
|
<Text style={{ color: storageGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
|
||||||
|
{t('automation.storagePermGranted')}
|
||||||
|
</Text>
|
||||||
|
{!storageGranted && <Button label={t('automation.storagePermRequest')} onPress={handleRequestStorage} variant="secondary" />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 悬浮窗 */}
|
||||||
|
<View style={[styles.permRow, { marginTop: 8 }]}>
|
||||||
|
<Ionicons name="tablet-landscape-outline" size={20} color={overlayGranted ? theme.colors.success : theme.colors.fgSecondary} />
|
||||||
|
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.overlayPermTitle')}</Text>
|
||||||
|
<Text style={{ color: overlayGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
|
||||||
|
{overlayGranted ? t('automation.overlayPermGranted') : t('automation.overlayPermNotGranted')}
|
||||||
|
</Text>
|
||||||
|
{!overlayGranted && <Button label={t('automation.overlayPermOpenSettings')} onPress={handleOpenOverlaySettings} variant="secondary" />}
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
}
|
</ScrollView>
|
||||||
ListEmptyComponent={
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
|
||||||
{t('automation.noDrafts')}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
contentContainerStyle={styles.content}
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -442,6 +660,10 @@ const styles = StyleSheet.create({
|
|||||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
statusDot: { width: 8, height: 8, borderRadius: 4 },
|
statusDot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
|
layerRow: { flexDirection: 'row', alignItems: 'center' },
|
||||||
chip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 },
|
chip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 },
|
||||||
|
progressBar: { height: 4, borderRadius: 2, overflow: 'hidden' },
|
||||||
|
progressFill: { height: '100%', borderRadius: 2 },
|
||||||
sigRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 4 },
|
sigRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 4 },
|
||||||
|
permRow: { flexDirection: 'row', alignItems: 'center' },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,12 +6,13 @@
|
|||||||
* P5:套 ManagementScreen 模板,headerContent 放支出/收入 chips。
|
* P5:套 ManagementScreen 模板,headerContent 放支出/收入 chips。
|
||||||
*/
|
*/
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { useTheme, createCommonStyles } from '../../theme';
|
import { useTheme, createCommonStyles } from '../../theme';
|
||||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/Card';
|
||||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||||
|
import { Touchable } from '../../components/Touchable';
|
||||||
import type { Category } from '../../domain/categories';
|
import type { Category } from '../../domain/categories';
|
||||||
|
|
||||||
export default function CategoryScreen() {
|
export default function CategoryScreen() {
|
||||||
@ -36,7 +37,7 @@ export default function CategoryScreen() {
|
|||||||
headerContent={
|
headerContent={
|
||||||
<View style={styles.chipRow}>
|
<View style={styles.chipRow}>
|
||||||
{(['expense', 'income'] as const).map(tp => (
|
{(['expense', 'income'] as const).map(tp => (
|
||||||
<Pressable
|
<Touchable
|
||||||
key={tp}
|
key={tp}
|
||||||
onPress={() => setCatType(tp)}
|
onPress={() => setCatType(tp)}
|
||||||
style={[commonStyles.chip, catType === tp && commonStyles.chipActive]}
|
style={[commonStyles.chip, catType === tp && commonStyles.chipActive]}
|
||||||
@ -44,25 +45,19 @@ export default function CategoryScreen() {
|
|||||||
<Text style={[commonStyles.chipText, catType === tp && commonStyles.chipTextActive]}>
|
<Text style={[commonStyles.chipText, catType === tp && commonStyles.chipTextActive]}>
|
||||||
{tp === 'expense' ? t('category.expense') : t('category.income')}
|
{tp === 'expense' ? t('category.expense') : t('category.income')}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Touchable>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
renderItem={(cat, { openEdit, confirmDelete }) => (
|
renderItem={(cat, { openEdit, confirmDelete }) => (
|
||||||
<Pressable
|
<Card title={cat.name} onPress={openEdit} onLongPress={confirmDelete}>
|
||||||
onPress={openEdit}
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
|
||||||
onLongPress={confirmDelete}
|
{cat.keywords.length > 0 && (
|
||||||
style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
|
||||||
>
|
{t('category.keywordsLabel')}: {cat.keywords.join(', ')}
|
||||||
<Card title={cat.name}>
|
</Text>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
|
)}
|
||||||
{cat.keywords.length > 0 && (
|
</Card>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
|
|
||||||
{t('category.keywordsLabel')}: {cat.keywords.join(', ')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
)}
|
||||||
formTitle={editing => (editing
|
formTitle={editing => (editing
|
||||||
? t('category.editTitle')
|
? t('category.editTitle')
|
||||||
|
|||||||
@ -5,14 +5,16 @@
|
|||||||
* linkedAccount 映射到 Beancount 的 Liabilities 账户。
|
* linkedAccount 映射到 Beancount 的 Liabilities 账户。
|
||||||
* P5:套 ManagementScreen 模板,富 renderItem 直套。
|
* P5:套 ManagementScreen 模板,富 renderItem 直套。
|
||||||
*/
|
*/
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/Card';
|
||||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||||
|
import { AccountCreateModal } from '../../components/AccountCreateModal';
|
||||||
|
import { Touchable } from '../../components/Touchable';
|
||||||
import type { CreditCard } from '../../domain/creditCards';
|
import type { CreditCard } from '../../domain/creditCards';
|
||||||
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards';
|
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards';
|
||||||
|
|
||||||
@ -26,8 +28,25 @@ export default function CreditCardScreen() {
|
|||||||
const removeCreditCard = useMetadataStore(s => s.removeCreditCard);
|
const removeCreditCard = useMetadataStore(s => s.removeCreditCard);
|
||||||
|
|
||||||
const ledger = useLedgerStore(s => s.ledger);
|
const ledger = useLedgerStore(s => s.ledger);
|
||||||
|
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
|
||||||
const transactions = ledger?.transactions ?? [];
|
const transactions = ledger?.transactions ?? [];
|
||||||
|
|
||||||
|
const [isQuickAddingAccount, setIsQuickAddingAccount] = useState(false);
|
||||||
|
|
||||||
|
/** 过滤出所有以 Liabilities 开头的信用负债账户。 */
|
||||||
|
const liabilityAccounts = useMemo(() => {
|
||||||
|
if (!ledger?.accounts) return [];
|
||||||
|
const accountNames = Array.from(ledger.accounts.keys());
|
||||||
|
return accountNames.filter((name: string) => name.startsWith('Liabilities'));
|
||||||
|
}, [ledger?.accounts]);
|
||||||
|
|
||||||
|
const liabilityOptions = useMemo(() => {
|
||||||
|
return liabilityAccounts.map((name: string) => ({
|
||||||
|
label: name.replace(/^Liabilities:/, ''),
|
||||||
|
value: name,
|
||||||
|
}));
|
||||||
|
}, [liabilityAccounts]);
|
||||||
|
|
||||||
/** 预计算所有账户余额 Map(避免在渲染循环中重复遍历)。 */
|
/** 预计算所有账户余额 Map(避免在渲染循环中重复遍历)。 */
|
||||||
const accountBalances = useMemo(() => {
|
const accountBalances = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@ -44,21 +63,29 @@ export default function CreditCardScreen() {
|
|||||||
|
|
||||||
const getAccountBalance = (account: string): string => accountBalances.get(account) ?? '0.00';
|
const getAccountBalance = (account: string): string => accountBalances.get(account) ?? '0.00';
|
||||||
|
|
||||||
|
const handleQuickAddAccount = (values: Record<string, string>) => {
|
||||||
|
const type = values.type || 'Liabilities';
|
||||||
|
const name = values.name?.trim() || 'CreditCard';
|
||||||
|
const fullAccountName = `${type}:${name}`;
|
||||||
|
autoOpenAccounts([fullAccountName]);
|
||||||
|
setIsQuickAddingAccount(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ManagementScreen<CreditCard>
|
<>
|
||||||
title={t('creditCard.title')}
|
<ManagementScreen<CreditCard>
|
||||||
items={creditCards}
|
title={t('creditCard.title')}
|
||||||
keyExtractor={card => card.id}
|
items={creditCards}
|
||||||
addLabel={t('creditCard.add')}
|
keyExtractor={card => card.id}
|
||||||
emptyText={t('creditCard.empty')}
|
addLabel={t('creditCard.add')}
|
||||||
renderItem={(card, { openEdit, confirmDelete }) => {
|
emptyText={t('creditCard.empty')}
|
||||||
const period = calculateBillingPeriod(card, new Date());
|
renderItem={(card, { openEdit, confirmDelete }) => {
|
||||||
const statementAmount = calculateStatementAmount(transactions, card, period);
|
const period = calculateBillingPeriod(card, new Date());
|
||||||
const currentBalance = getAccountBalance(card.linkedAccount);
|
const statementAmount = calculateStatementAmount(transactions, card, period);
|
||||||
const availableCredit = calculateAvailableCredit(card, currentBalance);
|
const currentBalance = getAccountBalance(card.linkedAccount);
|
||||||
return (
|
const availableCredit = calculateAvailableCredit(card, currentBalance);
|
||||||
<Pressable onPress={openEdit} onLongPress={confirmDelete}>
|
return (
|
||||||
<Card title={card.name}>
|
<Card title={card.name} onPress={openEdit} onLongPress={confirmDelete}>
|
||||||
<View style={styles.infoRow}>
|
<View style={styles.infoRow}>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
|
||||||
@ -97,51 +124,112 @@ export default function CreditCardScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
</Pressable>
|
);
|
||||||
);
|
}}
|
||||||
}}
|
formTitle={editing => (editing ? t('creditCard.editTitle') : t('creditCard.add'))}
|
||||||
formTitle={editing => (editing ? t('creditCard.editTitle') : t('creditCard.add'))}
|
onValuesChange={(changedKey: string, newValue: string, currentValues: Record<string, string>) => {
|
||||||
formFields={editing => [
|
if (changedKey === 'linkedAccount' && newValue) {
|
||||||
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: editing?.name },
|
const shortName = newValue.includes(':') ? newValue.slice(newValue.lastIndexOf(':') + 1) : newValue;
|
||||||
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: 'CMB', defaultValue: editing?.bankName },
|
const updates: Record<string, string> = {};
|
||||||
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: editing?.lastFour, keyboardType: 'numeric' },
|
if (shortName) {
|
||||||
{ key: 'billingDay', label: t('creditCard.fieldBillingDay'), placeholder: '5', defaultValue: editing ? String(editing.billingDay) : '', keyboardType: 'numeric' },
|
updates.name = shortName;
|
||||||
{ key: 'paymentDay', label: t('creditCard.fieldPaymentDay'), placeholder: '25', defaultValue: editing ? String(editing.paymentDay) : '', keyboardType: 'numeric' },
|
if (shortName.includes('招商') || shortName.toUpperCase().includes('CMB')) updates.bankName = '招商银行';
|
||||||
{ key: 'creditLimit', label: t('creditCard.fieldLimit'), placeholder: '10000', defaultValue: editing?.creditLimit, keyboardType: 'decimal-pad' },
|
else if (shortName.includes('支付宝') || shortName.includes('花呗')) updates.bankName = '支付宝';
|
||||||
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: editing?.currency },
|
else if (shortName.includes('微信') || shortName.includes('微粒贷')) updates.bankName = '微信';
|
||||||
{ key: 'linkedAccount', label: t('creditCard.fieldLinkedAccount'), placeholder: 'Liabilities:CreditCard:CMB', defaultValue: editing?.linkedAccount },
|
else if (shortName.includes('建设') || shortName.toUpperCase().includes('CCB')) updates.bankName = '建设银行';
|
||||||
]}
|
else if (shortName.includes('工商') || shortName.toUpperCase().includes('ICBC')) updates.bankName = '工商银行';
|
||||||
onSubmit={(values, editing) => {
|
else if (shortName.includes('中国银行') || shortName.toUpperCase().includes('BOC')) updates.bankName = '中国银行';
|
||||||
const card: Omit<CreditCard, 'id'> = {
|
else if (shortName.includes('农业') || shortName.toUpperCase().includes('ABC')) updates.bankName = '农业银行';
|
||||||
name: values.name?.trim() || t('creditCard.unnamed'),
|
else if (shortName.includes('交通') || shortName.toUpperCase().includes('BOCOM')) updates.bankName = '交通银行';
|
||||||
lastFour: values.lastFour?.trim() || '0000',
|
else if (!currentValues.bankName) updates.bankName = shortName;
|
||||||
bankName: values.bankName?.trim() || 'UNKNOWN',
|
}
|
||||||
billingDay: parseInt(values.billingDay, 10) || 1,
|
return updates;
|
||||||
paymentDay: parseInt(values.paymentDay, 10) || 1,
|
}
|
||||||
creditLimit: values.creditLimit?.trim() || '0',
|
}}
|
||||||
currency: values.currency?.trim() || 'CNY',
|
formFields={editing => [
|
||||||
linkedAccount: values.linkedAccount?.trim() || 'Liabilities:CreditCard',
|
// Row 1: linkedAccount (1.5) + name (1.0)
|
||||||
};
|
liabilityOptions.length > 0
|
||||||
if (editing) {
|
? {
|
||||||
updateCreditCard(editing.id, card);
|
key: 'linkedAccount',
|
||||||
} else {
|
label: t('creditCard.fieldLinkedAccount'),
|
||||||
addCreditCard({ ...card, id: generateId('cc') });
|
type: 'dropdown',
|
||||||
|
options: liabilityOptions,
|
||||||
|
placeholder: '请选择关联信用账户',
|
||||||
|
defaultValue: editing?.linkedAccount ?? '',
|
||||||
|
flex: 1.5,
|
||||||
|
row: 1,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
key: 'linkedAccount',
|
||||||
|
label: t('creditCard.fieldLinkedAccount'),
|
||||||
|
placeholder: 'Liabilities:CreditCard:CMB',
|
||||||
|
defaultValue: editing?.linkedAccount ?? '',
|
||||||
|
flex: 1.5,
|
||||||
|
row: 1,
|
||||||
|
},
|
||||||
|
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: editing?.name, flex: 1.0, row: 1 },
|
||||||
|
|
||||||
|
// Row 2: bankName (1.0) + lastFour (1.0) + currency (1.0)
|
||||||
|
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: '招商银行', defaultValue: editing?.bankName, flex: 1.0, row: 2 },
|
||||||
|
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: editing?.lastFour, keyboardType: 'numeric', flex: 1.0, row: 2 },
|
||||||
|
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: editing?.currency ?? 'CNY', flex: 1.0, row: 2 },
|
||||||
|
|
||||||
|
// Row 3: billingDay (1.0) + paymentDay (1.0) + creditLimit (1.2)
|
||||||
|
{ key: 'billingDay', label: t('creditCard.fieldBillingDay'), placeholder: '5', defaultValue: editing ? String(editing.billingDay) : '5', keyboardType: 'numeric', flex: 1.0, row: 3 },
|
||||||
|
{ key: 'paymentDay', label: t('creditCard.fieldPaymentDay'), placeholder: '25', defaultValue: editing ? String(editing.paymentDay) : '25', keyboardType: 'numeric', flex: 1.0, row: 3 },
|
||||||
|
{ key: 'creditLimit', label: t('creditCard.fieldLimit'), placeholder: '10000', defaultValue: editing?.creditLimit ?? '10000', keyboardType: 'decimal-pad', flex: 1.2, row: 3 },
|
||||||
|
]}
|
||||||
|
formExtra={() => (
|
||||||
|
<Touchable
|
||||||
|
onPress={() => setIsQuickAddingAccount(true)}
|
||||||
|
style={styles.quickAddBtn}
|
||||||
|
>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontWeight: '600' }]}>
|
||||||
|
+ 快捷新建负债账户
|
||||||
|
</Text>
|
||||||
|
</Touchable>
|
||||||
|
)}
|
||||||
|
onSubmit={(values, editing) => {
|
||||||
|
const card: Omit<CreditCard, 'id'> = {
|
||||||
|
name: values.name?.trim() || t('creditCard.unnamed'),
|
||||||
|
lastFour: values.lastFour?.trim() || '0000',
|
||||||
|
bankName: values.bankName?.trim() || 'UNKNOWN',
|
||||||
|
billingDay: parseInt(values.billingDay, 10) || 1,
|
||||||
|
paymentDay: parseInt(values.paymentDay, 10) || 1,
|
||||||
|
creditLimit: values.creditLimit?.trim() || '0',
|
||||||
|
currency: values.currency?.trim() || 'CNY',
|
||||||
|
linkedAccount: values.linkedAccount?.trim() || 'Liabilities:CreditCard',
|
||||||
|
};
|
||||||
|
if (editing) {
|
||||||
|
updateCreditCard(editing.id, card);
|
||||||
|
} else {
|
||||||
|
addCreditCard({ ...card, id: generateId('cc') });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
onDelete={card => removeCreditCard(card.id)}
|
||||||
|
deleteConfirmText={card => t('creditCard.deleteConfirm', { name: card.name })}
|
||||||
|
deleteConfirmTitle={t('creditCard.deleteTitle')}
|
||||||
|
footer={
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
||||||
|
{t('common.clickEditLongDelete')}
|
||||||
|
</Text>
|
||||||
}
|
}
|
||||||
return true;
|
/>
|
||||||
}}
|
|
||||||
onDelete={card => removeCreditCard(card.id)}
|
{/* 快捷新建负债账户弹窗 */}
|
||||||
deleteConfirmText={card => t('creditCard.deleteConfirm', { name: card.name })}
|
<AccountCreateModal
|
||||||
deleteConfirmTitle={t('creditCard.deleteTitle')}
|
visible={isQuickAddingAccount}
|
||||||
footer={
|
defaultType="Liabilities"
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
onConfirm={handleQuickAddAccount}
|
||||||
{t('common.clickEditLongDelete')}
|
onCancel={() => setIsQuickAddingAccount(false)}
|
||||||
</Text>
|
/>
|
||||||
}
|
</>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
|
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
|
||||||
billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
|
billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
|
||||||
|
quickAddBtn: { paddingVertical: 6, alignItems: 'center', marginTop: 4 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import type { DuplicateDetail } from '../../domain/dedup';
|
||||||
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
|
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@ -143,7 +144,7 @@ export default function ImportScreen() {
|
|||||||
// 6. 自动执行 Pipeline,用户无需手动查找运行按钮
|
// 6. 自动执行 Pipeline,用户无需手动查找运行按钮
|
||||||
if (ledger) {
|
if (ledger) {
|
||||||
setStatus(t('importFlow.pipelineRunning'));
|
setStatus(t('importFlow.pipelineRunning'));
|
||||||
processEvents(ledger, []).then(result => {
|
processEvents(ledger, ledger.transactions || []).then(result => {
|
||||||
setManualDuplicates(result.duplicates);
|
setManualDuplicates(result.duplicates);
|
||||||
setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length }));
|
setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length }));
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
@ -158,7 +159,7 @@ export default function ImportScreen() {
|
|||||||
|
|
||||||
const runPipeline = () => {
|
const runPipeline = () => {
|
||||||
if (!ledger) return;
|
if (!ledger) return;
|
||||||
processEvents(ledger, []).then(result => {
|
processEvents(ledger, ledger.transactions || []).then(result => {
|
||||||
setManualDuplicates(result.duplicates);
|
setManualDuplicates(result.duplicates);
|
||||||
setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount }));
|
setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount }));
|
||||||
}).catch(e => setStatus(String(e)));
|
}).catch(e => setStatus(String(e)));
|
||||||
@ -269,11 +270,59 @@ export default function ImportScreen() {
|
|||||||
const rules = useMetadataStore.getState().rules;
|
const rules = useMetadataStore.getState().rules;
|
||||||
const categories = useMetadataStore.getState().categories;
|
const categories = useMetadataStore.getState().categories;
|
||||||
const classification = classifyWithCategories(event, rules, categories, ledger);
|
const classification = classifyWithCategories(event, rules, categories, ledger);
|
||||||
await addTransaction(classification.draft);
|
await addTransaction(classification.draft, undefined, true);
|
||||||
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
|
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
|
||||||
setStatus(t('importFlow.forcedImport'));
|
setStatus(t('importFlow.forcedImport'));
|
||||||
|
Alert.alert('记账成功', `已成功将「${event.counterparty || event.memo || '交易'}」单独记入账本`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(String(err));
|
setStatus(String(err));
|
||||||
|
Alert.alert('记账失败', String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkDuplicate = async (event: ImportedEvent) => {
|
||||||
|
if (!ledger) return;
|
||||||
|
try {
|
||||||
|
const rules = useMetadataStore.getState().rules;
|
||||||
|
const categories = useMetadataStore.getState().categories;
|
||||||
|
const classification = classifyWithCategories(event, rules, categories, ledger);
|
||||||
|
|
||||||
|
// 寻找对应的历史匹配交易
|
||||||
|
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === event.id);
|
||||||
|
const matchedTx = detail?.matchedWith?.rawTransaction;
|
||||||
|
|
||||||
|
let linkTag = matchedTx?.links?.[0] || 'lnk-' + Math.random().toString(36).slice(2, 8);
|
||||||
|
|
||||||
|
// 为新草稿添加链接
|
||||||
|
const newDraft = {
|
||||||
|
...classification.draft,
|
||||||
|
links: Array.from(new Set([...(classification.draft.links || []), linkTag])),
|
||||||
|
};
|
||||||
|
|
||||||
|
await addTransaction(newDraft, undefined, true);
|
||||||
|
|
||||||
|
// 如果有历史匹配交易且它还没有此 linkTag,同步写回历史交易 raw
|
||||||
|
if (matchedTx && !matchedTx.links.includes(linkTag)) {
|
||||||
|
const currentRaw = matchedTx.raw;
|
||||||
|
const firstLineEnd = currentRaw.indexOf('\n');
|
||||||
|
const header = firstLineEnd !== -1 ? currentRaw.slice(0, firstLineEnd) : currentRaw;
|
||||||
|
const rest = firstLineEnd !== -1 ? currentRaw.slice(firstLineEnd) : '';
|
||||||
|
const updatedHeader = `${header} ^${linkTag}`;
|
||||||
|
const updatedRaw = `${updatedHeader}${rest}`;
|
||||||
|
|
||||||
|
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
|
||||||
|
if (mobileBean.includes(currentRaw)) {
|
||||||
|
const newContent = mobileBean.replace(currentRaw, updatedRaw);
|
||||||
|
await replaceMobileBean(newContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
|
||||||
|
setStatus(`已成功关联交易 (标签 ^${linkTag})`);
|
||||||
|
Alert.alert('关联成功', `已成功将「${event.counterparty || event.memo || '交易'}」与历史账单关联(关联标签 ^${linkTag})`);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(String(err));
|
||||||
|
Alert.alert('关联失败', String(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -344,23 +393,45 @@ export default function ImportScreen() {
|
|||||||
{showDuplicates && (
|
{showDuplicates && (
|
||||||
<View style={styles.duplicatesList}>
|
<View style={styles.duplicatesList}>
|
||||||
{manualDuplicates.map((item) => {
|
{manualDuplicates.map((item) => {
|
||||||
|
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === item.id);
|
||||||
|
const matchedTx = detail?.matchedWith?.rawTransaction;
|
||||||
|
const reason = detail?.reason || t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') });
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
isDuplicate: true,
|
isDuplicate: true,
|
||||||
confidence: 'medium' as const,
|
confidence: (detail?.confidence ?? 'medium') as any,
|
||||||
reason: t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') })
|
reason,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={item.id} title={`${item.occurredAt} · ${item.counterparty}`}>
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
title={`${item.occurredAt} · ${item.counterparty || '未知来源'}`}
|
||||||
|
onPress={() => handleLinkDuplicate(item)}
|
||||||
|
>
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
||||||
{item.memo || t('importFlow.noDesc')}
|
{item.memo || t('importFlow.noDesc')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
|
||||||
{t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })}
|
{t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })}
|
||||||
</Text>
|
</Text>
|
||||||
<DedupBanner
|
|
||||||
result={result}
|
{matchedTx && (
|
||||||
onAccept={() => handleAcceptDuplicate(item)}
|
<View style={{ backgroundColor: `${theme.colors.accent}12`, padding: 8, borderRadius: 8, marginVertical: 4 }}>
|
||||||
onReject={() => handleRejectDuplicate(item)}
|
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontWeight: '700' }]}>
|
||||||
|
已匹配到的账本历史交易(可关联):
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, marginTop: 2 }]}>
|
||||||
|
{matchedTx.date} {matchedTx.payee ? matchedTx.payee + ' - ' : ''}{matchedTx.narration} ({matchedTx.postings[0]?.amount} {matchedTx.postings[0]?.currency})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DedupBanner
|
||||||
|
result={result}
|
||||||
|
onAccept={() => handleAcceptDuplicate(item)}
|
||||||
|
onReject={() => handleRejectDuplicate(item)}
|
||||||
|
onLink={() => handleLinkDuplicate(item)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,146 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* LLM / AI 视觉配置页(P8)。
|
||||||
|
*
|
||||||
|
* 配置项:
|
||||||
|
* - AI 服务商(openai / gemini / deepseek)
|
||||||
|
* - API Key
|
||||||
|
* - Base URL
|
||||||
|
* - 模型名称
|
||||||
|
* - AI 总开关
|
||||||
|
*/
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View, Switch, Alert } from 'react-native';
|
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View, Switch } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
|
import { createCommonStyles } from '../../theme/commonStyles';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { useSettingsStore } from '../../store/settingsStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/Card';
|
||||||
import { Button } from '../../components/Button';
|
|
||||||
import { FormModal, type FormField } from '../../components/FormModal';
|
|
||||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||||
|
|
||||||
export default function AISettingsScreen() {
|
const PROVIDERS: { key: string; label: string }[] = [
|
||||||
const { theme } = useTheme();
|
{ key: 'openai', label: 'OpenAI' },
|
||||||
const t = useT();
|
{ key: 'gemini', label: 'Gemini' },
|
||||||
|
{ key: 'deepseek', label: 'DeepSeek' },
|
||||||
|
];
|
||||||
|
|
||||||
// Settings State
|
const PROVIDER_DEFAULT_URLS: Record<string, string> = {
|
||||||
const dedupEnabled = useSettingsStore(s => s.dedupEnabled);
|
openai: 'https://api.openai.com/v1',
|
||||||
const setDedupEnabled = useSettingsStore(s => s.setDedupEnabled);
|
gemini: 'https://generativelanguage.googleapis.com/v1beta',
|
||||||
const transferRecognitionEnabled = useSettingsStore(s => s.transferRecognitionEnabled);
|
deepseek: 'https://api.deepseek.com/v1',
|
||||||
const setTransferRecognitionEnabled = useSettingsStore(s => s.setTransferRecognitionEnabled);
|
};
|
||||||
|
|
||||||
|
const PROVIDER_DEFAULT_MODELS: Record<string, string> = {
|
||||||
|
openai: 'gpt-4o-mini',
|
||||||
|
gemini: 'gemini-2.0-flash',
|
||||||
|
deepseek: 'deepseek-chat',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AiSettingsScreen() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const commonStyles = createCommonStyles(theme);
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
const aiEnabled = useSettingsStore(s => s.aiEnabled);
|
const aiEnabled = useSettingsStore(s => s.aiEnabled);
|
||||||
const aiProviderId = useSettingsStore(s => s.aiProviderId);
|
const aiProviderId = useSettingsStore(s => s.aiProviderId);
|
||||||
const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
|
const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
|
||||||
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || 'https://api.openai.com/v1';
|
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || '';
|
||||||
const aiModel = useSettingsStore(s => s.aiModel) || 'gpt-4o-mini';
|
const aiModel = useSettingsStore(s => s.aiModel) || '';
|
||||||
const updateAiConfig = useSettingsStore(s => s.updateAiConfig);
|
const updateAiConfig = useSettingsStore(s => s.updateAiConfig);
|
||||||
|
|
||||||
const [aiModalVisible, setAiModalVisible] = useState(false);
|
const [localKey, setLocalKey] = useState(aiApiKey);
|
||||||
|
const [localUrl, setLocalUrl] = useState(aiBaseUrl);
|
||||||
|
const [localModel, setLocalModel] = useState(aiModel);
|
||||||
|
|
||||||
const aiFields: FormField[] = [
|
const inputStyle = {
|
||||||
{ key: 'apiKey', label: t('settings.aiFieldApiKey'), placeholder: 'sk-xxxxxx', defaultValue: aiApiKey },
|
backgroundColor: theme.colors.bgTertiary,
|
||||||
{ key: 'baseUrl', label: t('settings.aiFieldBaseUrl'), placeholder: 'https://api.openai.com/v1', defaultValue: aiBaseUrl },
|
color: theme.colors.fgPrimary,
|
||||||
{ key: 'model', label: t('settings.aiFieldModel'), placeholder: 'gpt-4o-mini', defaultValue: aiModel },
|
borderRadius: theme.radii.sm,
|
||||||
];
|
padding: 12,
|
||||||
|
fontSize: 14,
|
||||||
const saveAI = (values: Record<string, string>) => {
|
marginTop: 6,
|
||||||
updateAiConfig({
|
|
||||||
aiApiKey: values.apiKey,
|
|
||||||
aiBaseUrl: values.baseUrl,
|
|
||||||
aiModel: values.model,
|
|
||||||
});
|
|
||||||
setAiModalVisible(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||||
<ScreenHeader title={t('settings.aiSettingsTitle')} />
|
<ScreenHeader title={t('settings.llmTitle')} />
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
{/* 自动记账与算法开关 */}
|
{/* AI 总开关 */}
|
||||||
<Card title={t('settings.algorithmConfig')}>
|
<Card>
|
||||||
<View style={styles.switchRow}>
|
<View style={styles.row}>
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
|
||||||
{t('settings.dedupLabel')}
|
|
||||||
</Text>
|
|
||||||
<Switch
|
|
||||||
value={dedupEnabled}
|
|
||||||
onValueChange={setDedupEnabled}
|
|
||||||
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
|
||||||
thumbColor={theme.colors.fgInverse}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={[styles.switchRow, { marginTop: 12 }]}>
|
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
|
||||||
{t('settings.transferLabel')}
|
|
||||||
</Text>
|
|
||||||
<Switch
|
|
||||||
value={transferRecognitionEnabled}
|
|
||||||
onValueChange={setTransferRecognitionEnabled}
|
|
||||||
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
|
||||||
thumbColor={theme.colors.fgInverse}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* AI 智能助理配置 */}
|
|
||||||
<Card title={t('settings.aiAssistantTitle')}>
|
|
||||||
<View style={styles.switchRow}>
|
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
||||||
{t('settings.enableAiHint')}
|
{t('settings.enableAiHint')}
|
||||||
</Text>
|
</Text>
|
||||||
<Switch
|
<Switch
|
||||||
value={aiEnabled}
|
value={aiEnabled}
|
||||||
onValueChange={(val) => updateAiConfig({ aiEnabled: val })}
|
onValueChange={val => updateAiConfig({ aiEnabled: val })}
|
||||||
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
||||||
thumbColor={theme.colors.fgInverse}
|
thumbColor={theme.colors.fgInverse}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{aiEnabled && (
|
</Card>
|
||||||
<View style={{ marginTop: 12, gap: 8 }}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
{/* 服务商选择 */}
|
||||||
{t('settings.aiProvider')}
|
<Card title={t('automation.aiProviderLabel')}>
|
||||||
</Text>
|
<View style={styles.chipRow}>
|
||||||
<View style={styles.optionRow}>
|
{PROVIDERS.map(p => (
|
||||||
{['openai', 'gemini', 'deepseek'].map((provider) => {
|
<Pressable
|
||||||
const active = aiProviderId === provider;
|
key={p.key}
|
||||||
return (
|
onPress={() => updateAiConfig({ aiProviderId: p.key as 'openai' | 'gemini' | 'deepseek' })}
|
||||||
<Pressable
|
style={[
|
||||||
key={provider}
|
commonStyles.chip,
|
||||||
onPress={() => updateAiConfig({ aiProviderId: provider as any })}
|
aiProviderId === p.key && commonStyles.chipActive,
|
||||||
style={[
|
]}
|
||||||
styles.option,
|
>
|
||||||
{
|
<Text style={[commonStyles.chipText, aiProviderId === p.key && commonStyles.chipTextActive]}>
|
||||||
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
|
{p.label}
|
||||||
borderColor: active ? theme.colors.accent : theme.colors.border,
|
</Text>
|
||||||
},
|
</Pressable>
|
||||||
]}
|
))}
|
||||||
>
|
</View>
|
||||||
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontWeight: active ? '700' : '400' }}>
|
</Card>
|
||||||
{provider.toUpperCase()}
|
|
||||||
</Text>
|
{/* API Key */}
|
||||||
</Pressable>
|
<Card title={t('settings.aiFieldApiKey')}>
|
||||||
);
|
<TextInput
|
||||||
})}
|
style={inputStyle}
|
||||||
</View>
|
value={localKey}
|
||||||
<View style={{ marginTop: 4 }}>
|
onChangeText={setLocalKey}
|
||||||
<Button label={t('settings.aiConfigBtn')} onPress={() => setAiModalVisible(true)} variant="secondary" />
|
onBlur={() => updateAiConfig({ aiApiKey: localKey })}
|
||||||
</View>
|
placeholder="sk-..."
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
{t('settings.aiCurrentModel', { model: aiModel, url: aiBaseUrl })}
|
secureTextEntry
|
||||||
</Text>
|
autoCapitalize="none"
|
||||||
</View>
|
autoCorrect={false}
|
||||||
)}
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Base URL */}
|
||||||
|
<Card title={t('settings.aiFieldBaseUrl')}>
|
||||||
|
<TextInput
|
||||||
|
style={inputStyle}
|
||||||
|
value={localUrl}
|
||||||
|
onChangeText={setLocalUrl}
|
||||||
|
onBlur={() => updateAiConfig({ aiBaseUrl: localUrl })}
|
||||||
|
placeholder={PROVIDER_DEFAULT_URLS[aiProviderId] || PROVIDER_DEFAULT_URLS.openai}
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 模型 */}
|
||||||
|
<Card title={t('settings.aiFieldModel')}>
|
||||||
|
<TextInput
|
||||||
|
style={inputStyle}
|
||||||
|
value={localModel}
|
||||||
|
onChangeText={setLocalModel}
|
||||||
|
onBlur={() => updateAiConfig({ aiModel: localModel })}
|
||||||
|
placeholder={PROVIDER_DEFAULT_MODELS[aiProviderId] || PROVIDER_DEFAULT_MODELS.openai}
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* AI 配置模态框 */}
|
|
||||||
<FormModal
|
|
||||||
visible={aiModalVisible}
|
|
||||||
title={t('settings.aiConfigTitle')}
|
|
||||||
fields={aiFields}
|
|
||||||
onConfirm={saveAI}
|
|
||||||
onCancel={() => setAiModalVisible(false)}
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
content: { padding: 16, gap: 16 },
|
content: { padding: 16, paddingBottom: 64, gap: 12 },
|
||||||
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
optionRow: { flexDirection: 'row', gap: 8 },
|
chipRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' },
|
||||||
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
448
src/app/settings/logs.tsx
Normal file
448
src/app/settings/logs.tsx
Normal 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 },
|
||||||
|
});
|
||||||
@ -15,10 +15,12 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useTheme, createCommonStyles } from '../../theme';
|
import { useTheme, createCommonStyles } from '../../theme';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/Card';
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
|
import { useNumpadUiStore } from '../../store/numpadUiStore';
|
||||||
import { hash } from '../../domain/ledger';
|
import { hash } from '../../domain/ledger';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { TransactionCard } from '../../components/TransactionCard';
|
import { TransactionCard } from '../../components/TransactionCard';
|
||||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
export default function TransactionDetailScreen() {
|
export default function TransactionDetailScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@ -49,7 +51,7 @@ export default function TransactionDetailScreen() {
|
|||||||
// 筛选可以用来关联的其他账单
|
// 筛选可以用来关联的其他账单
|
||||||
const linkableTransactions = useMemo(() => {
|
const linkableTransactions = useMemo(() => {
|
||||||
if (!ledger) return [];
|
if (!ledger) return [];
|
||||||
return ledger.transactions
|
const list = ledger.transactions
|
||||||
.filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l)))
|
.filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l)))
|
||||||
.filter(t => {
|
.filter(t => {
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
@ -60,17 +62,19 @@ export default function TransactionDetailScreen() {
|
|||||||
t.postings.some(p => p.account.toLowerCase().includes(q))
|
t.postings.some(p => p.account.toLowerCase().includes(q))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.reverse() // 解决截断 Bug:将顺序反转,优先展示最新的交易
|
.sort((a, b) => b.date.localeCompare(a.date)); // 显式按交易日期【由新到旧 (最新 ➔ 最旧)】严格倒序排列
|
||||||
.slice(0, 30);
|
|
||||||
|
// 有搜索条件时展示最多 100 条匹配结果,无搜索词时默认展示最新 50 条
|
||||||
|
return searchQuery ? list.slice(0, 100) : list.slice(0, 50);
|
||||||
}, [ledger, id, tx?.links, searchQuery]);
|
}, [ledger, id, tx?.links, searchQuery]);
|
||||||
|
|
||||||
const handleLinkTransaction = async (targetTx: typeof tx) => {
|
const handleLinkTransaction = async (targetTx: typeof tx) => {
|
||||||
if (!tx || !targetTx) return;
|
if (!tx || !targetTx) return;
|
||||||
|
let linkTag = tx.links[0] || targetTx.links[0] || '';
|
||||||
try {
|
try {
|
||||||
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
|
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
|
||||||
|
|
||||||
// 取出已有链接标签,若没有则生成一个随机哈希标签
|
// 取出已有链接标签,若没有则生成一个随机哈希标签
|
||||||
let linkTag = tx.links[0] || targetTx.links[0];
|
|
||||||
if (!linkTag) {
|
if (!linkTag) {
|
||||||
linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8);
|
linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8);
|
||||||
}
|
}
|
||||||
@ -101,7 +105,30 @@ export default function TransactionDetailScreen() {
|
|||||||
newContent = newContent.replaceAll(tx.raw, newCurrentRaw);
|
newContent = newContent.replaceAll(tx.raw, newCurrentRaw);
|
||||||
newContent = newContent.replaceAll(targetTx.raw, newTargetRaw);
|
newContent = newContent.replaceAll(targetTx.raw, newTargetRaw);
|
||||||
|
|
||||||
|
const formatTxSummary = (t: typeof tx) => {
|
||||||
|
if (!t) return '';
|
||||||
|
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
|
||||||
|
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
|
||||||
|
};
|
||||||
|
|
||||||
await replaceMobileBean(newContent);
|
await replaceMobileBean(newContent);
|
||||||
|
logger.info('ledgerStore', `关联交易成功: 标签 ^${linkTag}\n【交易 1】: ${formatTxSummary(tx)}\n【交易 2】: ${formatTxSummary(targetTx)}`, {
|
||||||
|
linkTag,
|
||||||
|
transaction1: {
|
||||||
|
id: tx.id,
|
||||||
|
date: tx.date,
|
||||||
|
payee: tx.payee,
|
||||||
|
narration: tx.narration,
|
||||||
|
postings: tx.postings,
|
||||||
|
},
|
||||||
|
transaction2: {
|
||||||
|
id: targetTx.id,
|
||||||
|
date: targetTx.date,
|
||||||
|
payee: targetTx.payee,
|
||||||
|
narration: targetTx.narration,
|
||||||
|
postings: targetTx.postings,
|
||||||
|
},
|
||||||
|
});
|
||||||
setLinkModalVisible(false);
|
setLinkModalVisible(false);
|
||||||
|
|
||||||
// 解决 race condition:直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏
|
// 解决 race condition:直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏
|
||||||
@ -111,6 +138,7 @@ export default function TransactionDetailScreen() {
|
|||||||
}
|
}
|
||||||
Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag }));
|
Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error('ledgerStore', `关联交易失败 (Tag: ^${linkTag})`, e);
|
||||||
Alert.alert(t('transaction.linkFail'), String(e));
|
Alert.alert(t('transaction.linkFail'), String(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -139,7 +167,25 @@ export default function TransactionDetailScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatTxSummary = (t: typeof tx) => {
|
||||||
|
if (!t) return '';
|
||||||
|
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
|
||||||
|
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
|
||||||
|
};
|
||||||
|
|
||||||
await replaceMobileBean(newContent);
|
await replaceMobileBean(newContent);
|
||||||
|
const unlinkedListStr = companionTxs.map(c => formatTxSummary(c)).join('\n ');
|
||||||
|
logger.info('ledgerStore', `解除交易关联成功: 标签 ^${linkTag} (同步解绑 ${companionTxs.length} 笔相关交易)\n 解绑交易列表:\n ${unlinkedListStr}`, {
|
||||||
|
linkTag,
|
||||||
|
unlinkedCount: companionTxs.length,
|
||||||
|
unlinkedTransactions: companionTxs.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
date: c.date,
|
||||||
|
payee: c.payee,
|
||||||
|
narration: c.narration,
|
||||||
|
postings: c.postings,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
// 解决 race condition:直接使用 hash() 算法计算新 ID
|
// 解决 race condition:直接使用 hash() 算法计算新 ID
|
||||||
const newId = hash(`main.bean:${newCurrentRaw}`);
|
const newId = hash(`main.bean:${newCurrentRaw}`);
|
||||||
@ -148,6 +194,7 @@ export default function TransactionDetailScreen() {
|
|||||||
}
|
}
|
||||||
Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag }));
|
Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error('ledgerStore', `解除交易关联失败 (Tag: ^${linkTag})`, e);
|
||||||
Alert.alert(t('transaction.unlinkFail'), String(e));
|
Alert.alert(t('transaction.unlinkFail'), String(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -195,39 +242,111 @@ export default function TransactionDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Postings */}
|
{/* 资金流向 */}
|
||||||
<Card title={`${t('transaction.postings')} (${tx.postings.length})`}>
|
<Card title={`${t('transaction.postings')} (${tx.postings.length})`}>
|
||||||
{tx.postings.map((p, i) => (
|
<View style={{ gap: 8 }}>
|
||||||
<View key={i} style={[styles.postingRow, { borderTopColor: theme.colors.divider, borderTopWidth: i > 0 ? 1 : 0 }]}>
|
{tx.postings.map((p, i) => {
|
||||||
<View style={{ flex: 1 }}>
|
const parts = p.account.split(':');
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
const rootType = parts[0];
|
||||||
{p.account}
|
const accountShortName = parts.slice(1).join(':') || p.account;
|
||||||
</Text>
|
|
||||||
{p.metadata && Object.entries(p.metadata).map(([k, v]) => (
|
let categoryTag = t('account.title');
|
||||||
<Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
let iconName: keyof typeof Ionicons.glyphMap = 'swap-horizontal-outline';
|
||||||
{k}: {v}
|
let tagBg = `${theme.colors.accent}15`;
|
||||||
</Text>
|
let tagColor = theme.colors.accent;
|
||||||
))}
|
|
||||||
</View>
|
if (rootType === 'Assets') {
|
||||||
<View style={{ alignItems: 'flex-end' }}>
|
categoryTag = t('account.rootTypes.Assets');
|
||||||
{p.amount && (
|
iconName = 'wallet-outline';
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}>
|
tagBg = `${theme.colors.financial.income}15`;
|
||||||
{p.amount} {p.currency ?? ''}
|
tagColor = theme.colors.financial.income;
|
||||||
</Text>
|
} else if (rootType === 'Expenses') {
|
||||||
)}
|
categoryTag = t('account.rootTypes.Expenses');
|
||||||
{p.cost && (
|
iconName = 'cart-outline';
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
tagBg = `${theme.colors.financial.expense}15`;
|
||||||
{p.cost}
|
tagColor = theme.colors.financial.expense;
|
||||||
</Text>
|
} else if (rootType === 'Income') {
|
||||||
)}
|
categoryTag = t('account.rootTypes.Income');
|
||||||
{p.price && (
|
iconName = 'cash-outline';
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
tagBg = `${theme.colors.financial.income}15`;
|
||||||
@ {p.price}
|
tagColor = theme.colors.financial.income;
|
||||||
</Text>
|
} else if (rootType === 'Liabilities') {
|
||||||
)}
|
categoryTag = t('account.rootTypes.Liabilities');
|
||||||
</View>
|
iconName = 'card-outline';
|
||||||
</View>
|
tagBg = `${theme.colors.warning}15`;
|
||||||
))}
|
tagColor = theme.colors.warning;
|
||||||
|
} else if (rootType === 'Equity') {
|
||||||
|
categoryTag = t('account.rootTypes.Equity');
|
||||||
|
iconName = 'options-outline';
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountVal = parseFloat(p.amount ?? '0');
|
||||||
|
const isNegative = amountVal < 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={[
|
||||||
|
styles.flowCardItem,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.colors.bgTertiary,
|
||||||
|
borderRadius: theme.radii.lg,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.flowIconBox, { backgroundColor: tagBg }]}>
|
||||||
|
<Ionicons name={iconName} size={18} color={tagColor} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flex: 1, marginLeft: 10 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<View style={[styles.miniTypeTag, { backgroundColor: `${theme.colors.fgSecondary}15` }]}>
|
||||||
|
<Text style={{ fontSize: 10, color: theme.colors.fgSecondary, fontWeight: '600' }}>
|
||||||
|
{categoryTag}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700', marginTop: 2 }]}>
|
||||||
|
{accountShortName}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{p.metadata && Object.entries(p.metadata).map(([k, v]) => (
|
||||||
|
<Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
|
||||||
|
{k}: {v}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ alignItems: 'flex-end', justifyContent: 'center' }}>
|
||||||
|
{p.amount && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
theme.typography.body,
|
||||||
|
{
|
||||||
|
color: isNegative ? theme.colors.financial.expense : theme.colors.fgPrimary,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{p.amount} {p.currency ?? ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{p.cost && (
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
|
成本: {p.cost}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{p.price && (
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
|
单价: @ {p.price}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 标签 */}
|
{/* 标签 */}
|
||||||
@ -292,9 +411,13 @@ export default function TransactionDetailScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 元数据 */}
|
{/* 元数据 / 附加信息 */}
|
||||||
{tx.metadata && Object.keys(tx.metadata).length > 0 && (
|
{tx.metadata && Object.keys(tx.metadata).length > 0 && (
|
||||||
<Card title={t('transaction.metadata')}>
|
<Card
|
||||||
|
title={`${t('transaction.metadata')} (${Object.keys(tx.metadata).length})`}
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed
|
||||||
|
>
|
||||||
{Object.entries(tx.metadata).map(([k, v]) => (
|
{Object.entries(tx.metadata).map(([k, v]) => (
|
||||||
<View key={k} style={styles.metaRow}>
|
<View key={k} style={styles.metaRow}>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{k}</Text>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{k}</Text>
|
||||||
@ -305,7 +428,11 @@ export default function TransactionDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 原始 .bean */}
|
{/* 原始 .bean */}
|
||||||
<Card title={t('transaction.rawBean')}>
|
<Card
|
||||||
|
title={t('transaction.rawBean')}
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed
|
||||||
|
>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'], lineHeight: 20 }]}>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'], lineHeight: 20 }]}>
|
||||||
{tx.raw}
|
{tx.raw}
|
||||||
</Text>
|
</Text>
|
||||||
@ -318,7 +445,7 @@ export default function TransactionDetailScreen() {
|
|||||||
{isEditable && (
|
{isEditable && (
|
||||||
<View style={styles.editActions}>
|
<View style={styles.editActions}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.push({ pathname: '/transaction/new', params: { editId: id } })}
|
onPress={() => useNumpadUiStore.getState().open({ editId: id })}
|
||||||
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
|
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
|
||||||
>
|
>
|
||||||
<Ionicons name="create-outline" size={16} color={theme.colors.accent} />
|
<Ionicons name="create-outline" size={16} color={theme.colors.accent} />
|
||||||
@ -421,4 +548,7 @@ const styles = StyleSheet.create({
|
|||||||
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
|
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
|
||||||
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
|
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
|
||||||
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 },
|
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 },
|
||||||
|
flowCardItem: { flexDirection: 'row', alignItems: 'center', padding: 10 },
|
||||||
|
flowIconBox: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
miniTypeTag: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* 记一笔:NumpadSheet 整页宿主(P3)。
|
|
||||||
* 编辑(editId)/ 草稿(draftJson)/ OCR(mode=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 },
|
|
||||||
});
|
|
||||||
60
src/components/AccountCreateModal.tsx
Normal file
60
src/components/AccountCreateModal.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { useTheme } from '../theme';
|
import { useTheme } from '../theme';
|
||||||
|
import { useT } from '../i18n';
|
||||||
import { calculateAccountTotalBalance, type AccountNode } from '../domain/accountTree';
|
import { calculateAccountTotalBalance, type AccountNode } from '../domain/accountTree';
|
||||||
|
|
||||||
interface AccountTreeProps {
|
interface AccountTreeProps {
|
||||||
@ -11,7 +12,6 @@ interface AccountTreeProps {
|
|||||||
|
|
||||||
/** 账户树展示(主题化,缩进显示层级 + 余额)。 */
|
/** 账户树展示(主题化,缩进显示层级 + 余额)。 */
|
||||||
export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
|
export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
|
||||||
const { theme } = useTheme();
|
|
||||||
return (
|
return (
|
||||||
<View style={{ gap: 4 }}>
|
<View style={{ gap: 4 }}>
|
||||||
{nodes.map(node => (
|
{nodes.map(node => (
|
||||||
@ -23,13 +23,23 @@ export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
|
|||||||
|
|
||||||
const AccountTreeNode = React.memo(function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) {
|
const AccountTreeNode = React.memo(function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const t = useT();
|
||||||
const total = useMemo(() => calculateAccountTotalBalance(node), [node]);
|
const total = useMemo(() => calculateAccountTotalBalance(node), [node]);
|
||||||
const isLeaf = node.children.length === 0;
|
const isLeaf = node.children.length === 0;
|
||||||
|
|
||||||
|
let displayName = node.name;
|
||||||
|
if (depth === 0) {
|
||||||
|
const localized = t(`account.rootTypes.${node.name as 'Assets' | 'Liabilities' | 'Income' | 'Expenses' | 'Equity'}`);
|
||||||
|
if (localized && !localized.startsWith('account.')) {
|
||||||
|
displayName = localized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<View style={[styles.row, { paddingLeft: depth * 16 }]}>
|
<View style={[styles.row, { paddingLeft: depth * 16 }]}>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: depth === 0 ? '700' : '400' }]}>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: depth === 0 ? '700' : '400' }]}>
|
||||||
{isLeaf ? '• ' : ''}{node.name}
|
{isLeaf ? '• ' : ''}{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
{total}
|
{total}
|
||||||
|
|||||||
@ -64,7 +64,7 @@ export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps)
|
|||||||
{state.routes.slice(0, 2).map(renderTab)}
|
{state.routes.slice(0, 2).map(renderTab)}
|
||||||
<View style={styles.plusSlot}>
|
<View style={styles.plusSlot}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => { if (numpadGlobalEntry) openNumpad(); else router.navigate('/transaction/new'); }}
|
onPress={() => openNumpad()}
|
||||||
style={[styles.plus, {
|
style={[styles.plus, {
|
||||||
backgroundColor: theme.colors.accent,
|
backgroundColor: theme.colors.accent,
|
||||||
borderColor: theme.colors.bgPrimary,
|
borderColor: theme.colors.bgPrimary,
|
||||||
|
|||||||
@ -1,18 +1,25 @@
|
|||||||
import React, { useCallback, useRef } from 'react';
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
import { Animated, Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
import { Animated, Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../theme';
|
import { useTheme } from '../theme';
|
||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
onLongPress?: () => void;
|
||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
|
/** 是否开启折叠/展开功能。 */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** 默认是否呈收起状态(仅当 collapsible 为 true 时生效)。 */
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 卡片容器(主题化,并有触控微交互)。 */
|
/** 卡片容器(主题化,并有触控微交互与可选折叠展开功能)。 */
|
||||||
export function Card({ title, children, onPress, style }: CardProps) {
|
export function Card({ title, children, onPress, onLongPress, style, collapsible, defaultCollapsed }: CardProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(collapsible ? (defaultCollapsed ?? false) : false);
|
||||||
|
|
||||||
const handlePressIn = useCallback(() => {
|
const handlePressIn = useCallback(() => {
|
||||||
Animated.spring(scale, {
|
Animated.spring(scale, {
|
||||||
@ -41,10 +48,41 @@ export function Card({ title, children, onPress, style }: CardProps) {
|
|||||||
...theme.shadows.sm,
|
...theme.shadows.sm,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (onPress) {
|
const isInteractive = Boolean(onPress || onLongPress);
|
||||||
|
|
||||||
|
if (collapsible) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.card, cardStyle, style]}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setIsCollapsed(prev => !prev)}
|
||||||
|
style={styles.headerRow}
|
||||||
|
hitSlop={4}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.title,
|
||||||
|
{ color: theme.colors.fgPrimary, flex: 1, marginBottom: 0 },
|
||||||
|
theme.typography.h3,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={isCollapsed ? 'chevron-down' : 'chevron-up'}
|
||||||
|
size={18}
|
||||||
|
color={theme.colors.fgSecondary}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
{!isCollapsed && <View style={styles.collapsibleContent}>{children}</View>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInteractive) {
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPressOut={handlePressOut}
|
onPressOut={handlePressOut}
|
||||||
style={style}
|
style={style}
|
||||||
@ -100,4 +138,6 @@ export function Card({ title, children, onPress, style }: CardProps) {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: { gap: 8 },
|
card: { gap: 8 },
|
||||||
title: { marginBottom: 4 },
|
title: { marginBottom: 4 },
|
||||||
|
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 2 },
|
||||||
|
collapsibleContent: { marginTop: 4, gap: 8 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { useTheme } from '../theme';
|
import { useTheme } from '../theme';
|
||||||
import { getCategoryColor } from '../theme/palette';
|
import { getCategoryColor } from '../theme/palette';
|
||||||
import type { Category } from '../domain/categories';
|
import type { Category } from '../domain/categories';
|
||||||
import { CategoryIcon } from './CategoryIcon';
|
import { CategoryIcon } from './CategoryIcon';
|
||||||
|
import { Touchable } from './Touchable';
|
||||||
|
|
||||||
interface CategoryPickerProps {
|
interface CategoryPickerProps {
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
@ -22,15 +23,14 @@ export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPic
|
|||||||
const color = getCategoryColor(cat.id);
|
const color = getCategoryColor(cat.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Touchable
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
onPress={() => onSelect(cat)}
|
onPress={() => onSelect(cat)}
|
||||||
style={({ pressed }) => [
|
style={[
|
||||||
styles.item,
|
styles.item,
|
||||||
{
|
{
|
||||||
backgroundColor: active ? color + '15' : theme.colors.bgTertiary,
|
backgroundColor: active ? color + '15' : theme.colors.bgTertiary,
|
||||||
borderColor: active ? color : theme.colors.border,
|
borderColor: active ? color : theme.colors.border,
|
||||||
opacity: pressed ? 0.75 : 1,
|
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@ -47,7 +47,7 @@ export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPic
|
|||||||
>
|
>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Touchable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -7,12 +7,13 @@ import type { DedupResult } from '../domain/dedup';
|
|||||||
|
|
||||||
interface DedupBannerProps {
|
interface DedupBannerProps {
|
||||||
result: DedupResult;
|
result: DedupResult;
|
||||||
onAccept?: () => void; // 接受(仍要记账)
|
onAccept?: () => void; // 强行单独导入
|
||||||
onReject?: () => void; // 拒绝(不记)
|
onReject?: () => void; // 忽略跳过
|
||||||
|
onLink?: () => void; // 关联/合并已有交易
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 去重提示横幅(主题化)。展示去重原因 + 操作按钮。 */
|
/** 去重提示横幅(主题化)。展示去重原因 + 操作按钮。 */
|
||||||
export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) {
|
export function DedupBanner({ result, onAccept, onReject, onLink }: DedupBannerProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
if (!result.isDuplicate) return null;
|
if (!result.isDuplicate) return null;
|
||||||
@ -43,6 +44,11 @@ export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) {
|
|||||||
<Pressable onPress={onReject} style={[styles.btn, { borderColor: theme.colors.border }]}>
|
<Pressable onPress={onReject} style={[styles.btn, { borderColor: theme.colors.border }]}>
|
||||||
<Text style={{ color: theme.colors.fgSecondary }}>{t('importFlow.skip')}</Text>
|
<Text style={{ color: theme.colors.fgSecondary }}>{t('importFlow.skip')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
{onLink && (
|
||||||
|
<Pressable onPress={onLink} style={[styles.btn, { borderColor: theme.colors.accent, backgroundColor: `${theme.colors.accent}15` }]}>
|
||||||
|
<Text style={{ color: theme.colors.accent, fontWeight: '700' }}>关联交易</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
<Pressable onPress={onAccept} style={[styles.btn, { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent }]}>
|
<Pressable onPress={onAccept} style={[styles.btn, { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent }]}>
|
||||||
<Text style={{ color: theme.colors.fgInverse }}>{t('importFlow.forceImport')}</Text>
|
<Text style={{ color: theme.colors.fgInverse }}>{t('importFlow.forceImport')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@ -54,6 +60,6 @@ export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
banner: { borderLeftWidth: 3, borderRadius: 6, padding: 12, gap: 6, marginVertical: 8 },
|
banner: { borderLeftWidth: 3, borderRadius: 6, padding: 12, gap: 6, marginVertical: 8 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
header: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||||
actions: { flexDirection: 'row', gap: 8, marginTop: 4 },
|
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 4 },
|
||||||
btn: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 6, borderWidth: 1 },
|
btn: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 12, borderRadius: 6, borderWidth: 1 },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* 通用模态表单(plan.md 管理页 CRUD)。
|
* 通用模态表单(plan.md 管理页 CRUD)。
|
||||||
*
|
*
|
||||||
* 底部弹出的模态对话框,包含多个文本输入字段 + 确认/取消按钮。
|
* 底部弹出的模态对话框,包含多个文本输入字段 + 确认/取消按钮。
|
||||||
@ -24,6 +24,13 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useTheme, createCommonStyles } from '../theme';
|
import { useTheme, createCommonStyles } from '../theme';
|
||||||
import { useT } from '../i18n';
|
import { useT } from '../i18n';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
|
import { Touchable } from './Touchable';
|
||||||
|
|
||||||
|
export interface FormOption {
|
||||||
|
label: string; // 中文名称
|
||||||
|
value: string; // 英文前缀 (如 Assets)
|
||||||
|
subLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FormField {
|
export interface FormField {
|
||||||
/** 字段 key,对应 values 对象的属性名。 */
|
/** 字段 key,对应 values 对象的属性名。 */
|
||||||
@ -40,6 +47,14 @@ export interface FormField {
|
|||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
/** 密码输入(遮蔽文本)。 */
|
/** 密码输入(遮蔽文本)。 */
|
||||||
secureTextEntry?: boolean;
|
secureTextEntry?: boolean;
|
||||||
|
/** 控件类型:'text' (默认) | 'select' 单选卡片组 | 'dropdown' 下拉选择框 */
|
||||||
|
type?: 'text' | 'select' | 'dropdown';
|
||||||
|
/** select / dropdown 类型的可选项。 */
|
||||||
|
options?: FormOption[];
|
||||||
|
/** 在同一行内布局时的占比弹性系数。 */
|
||||||
|
flex?: number;
|
||||||
|
/** 行号标识,相同 row 的字段组合在同一行中。 */
|
||||||
|
row?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormModalProps {
|
interface FormModalProps {
|
||||||
@ -50,15 +65,30 @@ interface FormModalProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
/** 确认按钮文本(默认「确定」)。 */
|
/** 确认按钮文本(默认「确定」)。 */
|
||||||
confirmLabel?: string;
|
confirmLabel?: string;
|
||||||
|
/** 字段值变更时的联动回调,返回新值覆盖。 */
|
||||||
|
onValuesChange?: (changedKey: string, newValue: string, prevValues: Record<string, string>) => Record<string, string> | void;
|
||||||
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
|
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel, children }: FormModalProps) {
|
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel, onValuesChange, children }: FormModalProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||||
const [values, setValues] = useState<Record<string, string>>({});
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
const [activeDropdownKey, setActiveDropdownKey] = useState<string | null>(null);
|
||||||
|
const [focusedKey, setFocusedKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const updateFieldValue = (key: string, val: string) => {
|
||||||
|
setValues(prev => {
|
||||||
|
const next = { ...prev, [key]: val };
|
||||||
|
if (onValuesChange) {
|
||||||
|
const overridden = onValuesChange(key, val, next);
|
||||||
|
return overridden ? { ...next, ...overridden } : next;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 每次 visible 变为 true 时,用 fields 的 defaultValue 初始化
|
// 每次 visible 变为 true 时,用 fields 的 defaultValue 初始化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -68,46 +98,206 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
|
|||||||
init[f.key] = f.defaultValue ?? '';
|
init[f.key] = f.defaultValue ?? '';
|
||||||
}
|
}
|
||||||
setValues(init);
|
setValues(init);
|
||||||
|
setActiveDropdownKey(null);
|
||||||
|
setFocusedKey(null);
|
||||||
}
|
}
|
||||||
}, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 将包含 flex 的连续字段组合成同行渲染组
|
||||||
|
const renderField = (f: FormField) => {
|
||||||
|
const isFocused = focusedKey === f.key || activeDropdownKey === f.key;
|
||||||
|
|
||||||
|
if (f.type === 'dropdown' && f.options) {
|
||||||
|
const selectedOpt = f.options.find(o => o.value === (values[f.key] ?? ''));
|
||||||
|
const rawValue = selectedOpt ? selectedOpt.value : (values[f.key] ?? '');
|
||||||
|
const rawLabel = selectedOpt ? selectedOpt.label : '';
|
||||||
|
|
||||||
|
const hasColon = rawValue.includes(':');
|
||||||
|
const triggerPrefix = hasColon ? rawValue.slice(0, rawValue.lastIndexOf(':')) : '';
|
||||||
|
const triggerTitle = hasColon ? rawValue.slice(rawValue.lastIndexOf(':') + 1) : (rawLabel || f.placeholder || '');
|
||||||
|
const isPlaceholder = !rawValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={f.key} style={[styles.field, f.flex ? { flex: f.flex } : undefined]}>
|
||||||
|
<Text style={[theme.typography.caption, styles.fieldLabel, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{f.label}
|
||||||
|
</Text>
|
||||||
|
<Touchable
|
||||||
|
style={[
|
||||||
|
commonStyles.input,
|
||||||
|
styles.dropdownTrigger,
|
||||||
|
{
|
||||||
|
borderColor: isFocused ? theme.colors.accent : theme.colors.border,
|
||||||
|
backgroundColor: isFocused ? theme.colors.bgPrimary : theme.colors.bgTertiary,
|
||||||
|
borderWidth: isFocused ? 1.5 : StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => setActiveDropdownKey(f.key)}
|
||||||
|
>
|
||||||
|
<View style={styles.dropdownTriggerContent}>
|
||||||
|
{triggerPrefix ? (
|
||||||
|
<Text style={[styles.dropdownValueText, { color: theme.colors.fgSecondary, fontSize: 10, lineHeight: 12 }]} numberOfLines={1}>
|
||||||
|
{triggerPrefix}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dropdownLabelText,
|
||||||
|
{
|
||||||
|
color: isPlaceholder ? theme.colors.fgSecondary : theme.colors.fgPrimary,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: isPlaceholder ? '400' : '600',
|
||||||
|
lineHeight: 16,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{triggerTitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-down" size={16} color={isFocused ? theme.colors.accent : theme.colors.fgSecondary} />
|
||||||
|
</Touchable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f.type === 'select' && f.options) {
|
||||||
|
return (
|
||||||
|
<View key={f.key} style={[styles.field, f.flex ? { flex: f.flex } : undefined]}>
|
||||||
|
<Text style={[theme.typography.caption, styles.fieldLabel, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{f.label}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.optionContainer}>
|
||||||
|
{f.options.map(opt => {
|
||||||
|
const isSelected = (values[f.key] ?? '') === opt.value;
|
||||||
|
return (
|
||||||
|
<Touchable
|
||||||
|
key={opt.value}
|
||||||
|
style={[
|
||||||
|
styles.optionChip,
|
||||||
|
{
|
||||||
|
backgroundColor: isSelected ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
|
||||||
|
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => updateFieldValue(f.key, opt.value)}
|
||||||
|
>
|
||||||
|
<View style={styles.optionTextWrap}>
|
||||||
|
<Text style={[styles.dropdownValueText, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{opt.value}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dropdownLabelText,
|
||||||
|
{
|
||||||
|
color: isSelected ? theme.colors.accent : theme.colors.fgPrimary,
|
||||||
|
fontWeight: isSelected ? '700' : '600',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isSelected && (
|
||||||
|
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} style={{ marginLeft: 'auto' }} />
|
||||||
|
)}
|
||||||
|
</Touchable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={f.key} style={[styles.field, f.flex ? { flex: f.flex } : undefined]}>
|
||||||
|
<Text style={[theme.typography.caption, styles.fieldLabel, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{f.label}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={values[f.key] ?? ''}
|
||||||
|
onChangeText={(text) => updateFieldValue(f.key, text)}
|
||||||
|
onFocus={() => setFocusedKey(f.key)}
|
||||||
|
onBlur={() => setFocusedKey(null)}
|
||||||
|
placeholder={f.placeholder}
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
keyboardType={f.keyboardType ?? 'default'}
|
||||||
|
multiline={f.multiline}
|
||||||
|
secureTextEntry={f.secureTextEntry}
|
||||||
|
style={[
|
||||||
|
commonStyles.input,
|
||||||
|
f.flex ? { height: 44 } : undefined,
|
||||||
|
{
|
||||||
|
backgroundColor: isFocused ? theme.colors.bgPrimary : theme.colors.bgTertiary,
|
||||||
|
borderColor: isFocused ? theme.colors.accent : theme.colors.border,
|
||||||
|
borderWidth: isFocused ? 1.5 : StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分组具有 flex 且相同 row 的连续字段
|
||||||
|
const fieldElements: React.ReactNode[] = [];
|
||||||
|
let flexGroup: FormField[] = [];
|
||||||
|
let currentGroupRow: number | undefined = undefined;
|
||||||
|
|
||||||
|
const flushFlexGroup = () => {
|
||||||
|
if (flexGroup.length > 0) {
|
||||||
|
fieldElements.push(
|
||||||
|
<View key={`row-${flexGroup[0].key}`} style={styles.rowFieldGroup}>
|
||||||
|
{flexGroup.map(renderField)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
flexGroup = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const f of fields) {
|
||||||
|
if (f.flex) {
|
||||||
|
const fieldRow = f.row ?? 1;
|
||||||
|
if (flexGroup.length > 0 && currentGroupRow !== fieldRow) {
|
||||||
|
flushFlexGroup();
|
||||||
|
}
|
||||||
|
currentGroupRow = fieldRow;
|
||||||
|
flexGroup.push(f);
|
||||||
|
} else {
|
||||||
|
flushFlexGroup();
|
||||||
|
currentGroupRow = undefined;
|
||||||
|
fieldElements.push(renderField(f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushFlexGroup();
|
||||||
|
|
||||||
|
const activeField = fields.find(f => f.key === activeDropdownKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
|
||||||
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onCancel}>
|
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onCancel}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.sheet, {
|
style={[
|
||||||
backgroundColor: theme.colors.bgSecondary,
|
styles.sheet,
|
||||||
borderRadius: theme.radii.xl,
|
theme.shadows.lg,
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
{
|
||||||
borderColor: theme.colors.border,
|
backgroundColor: theme.colors.bgSecondary,
|
||||||
}]}
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
onPress={(e) => e.stopPropagation()}
|
onPress={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<View style={[styles.header, { borderBottomColor: theme.colors.divider }]}>
|
{/* 顶部 Drag Handle */}
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{title}</Text>
|
<View style={[styles.dragHandle, { backgroundColor: theme.colors.divider }]} />
|
||||||
<Pressable onPress={onCancel} hitSlop={8}>
|
|
||||||
<Ionicons name="close" size={20} color={theme.colors.fgSecondary} />
|
<View style={styles.header}>
|
||||||
</Pressable>
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{title}</Text>
|
||||||
|
<Touchable onPress={onCancel} hitSlop={8} style={[styles.closeCircleBtn, { backgroundColor: theme.colors.bgTertiary }]}>
|
||||||
|
<Ionicons name="close" size={16} color={theme.colors.fgSecondary} />
|
||||||
|
</Touchable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView style={styles.body}>
|
<ScrollView style={styles.body} showsVerticalScrollIndicator={false}>
|
||||||
{fields.map(f => (
|
{fieldElements}
|
||||||
<View key={f.key} style={styles.field}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4 }]}>
|
|
||||||
{f.label}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={values[f.key] ?? ''}
|
|
||||||
onChangeText={(text) => setValues(prev => ({ ...prev, [f.key]: text }))}
|
|
||||||
placeholder={f.placeholder}
|
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
|
||||||
keyboardType={f.keyboardType ?? 'default'}
|
|
||||||
multiline={f.multiline}
|
|
||||||
secureTextEntry={f.secureTextEntry}
|
|
||||||
style={commonStyles.input}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
@ -118,6 +308,64 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
|
|||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
|
{/* 下拉菜单弹出 Modal */}
|
||||||
|
<Modal visible={activeDropdownKey !== null} transparent animationType="fade" onRequestClose={() => setActiveDropdownKey(null)}>
|
||||||
|
<Pressable style={[styles.menuOverlay, { backgroundColor: theme.colors.overlay }]} onPress={() => setActiveDropdownKey(null)}>
|
||||||
|
<Pressable style={[styles.dropdownMenu, theme.shadows.lg, { backgroundColor: theme.colors.bgSecondary, borderColor: theme.colors.border }]} onPress={e => e.stopPropagation()}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, letterSpacing: 0.5, fontWeight: '600' }]}>
|
||||||
|
{activeField?.label}
|
||||||
|
</Text>
|
||||||
|
{activeField?.options?.map(opt => {
|
||||||
|
const isSelected = (values[activeField.key] ?? '') === opt.value;
|
||||||
|
const hasColon = opt.value.includes(':');
|
||||||
|
const pathPrefix = hasColon ? opt.value.slice(0, opt.value.lastIndexOf(':')) : '';
|
||||||
|
const shortName = hasColon ? opt.value.slice(opt.value.lastIndexOf(':') + 1) : opt.label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Touchable
|
||||||
|
key={opt.value}
|
||||||
|
style={[
|
||||||
|
styles.dropdownMenuItem,
|
||||||
|
{
|
||||||
|
backgroundColor: isSelected ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
|
||||||
|
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
updateFieldValue(activeField.key, opt.value);
|
||||||
|
setActiveDropdownKey(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{pathPrefix ? (
|
||||||
|
<Text style={[styles.dropdownValueText, { color: theme.colors.fgSecondary, fontSize: 10, lineHeight: 12 }]}>
|
||||||
|
{pathPrefix}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.dropdownLabelText,
|
||||||
|
{
|
||||||
|
color: isSelected ? theme.colors.accent : theme.colors.fgPrimary,
|
||||||
|
fontWeight: isSelected ? '700' : '600',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{shortName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isSelected && (
|
||||||
|
<Ionicons name="checkmark-circle" size={18} color={theme.colors.accent} />
|
||||||
|
)}
|
||||||
|
</Touchable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -130,26 +378,113 @@ const styles = StyleSheet.create({
|
|||||||
sheet: {
|
sheet: {
|
||||||
maxHeight: '80%',
|
maxHeight: '80%',
|
||||||
padding: 20,
|
padding: 20,
|
||||||
|
paddingTop: 12,
|
||||||
paddingBottom: 36,
|
paddingBottom: 36,
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
},
|
||||||
|
dragHandle: {
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
alignSelf: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingBottom: 12,
|
marginBottom: 16,
|
||||||
borderBottomWidth: 1,
|
},
|
||||||
marginBottom: 12,
|
closeCircleBtn: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
maxHeight: 400,
|
maxHeight: 400,
|
||||||
},
|
},
|
||||||
field: {
|
field: {
|
||||||
marginBottom: 12,
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
fieldLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
rowFieldGroup: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
dropdownTrigger: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 2,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
dropdownTriggerContent: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
dropdownValueText: {
|
||||||
|
fontSize: 10,
|
||||||
|
lineHeight: 12,
|
||||||
|
},
|
||||||
|
dropdownLabelText: {
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
optionContainer: {
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
optionChip: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
optionTextWrap: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
optionSubLabel: {
|
||||||
|
fontSize: 11,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 10,
|
||||||
marginTop: 8,
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
menuOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
dropdownMenu: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 320,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 18,
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
dropdownMenuItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
import { Alert, ScrollView, StyleSheet, Text, View, type ViewStyle } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../theme';
|
import { useTheme } from '../theme';
|
||||||
import { useT } from '../i18n';
|
import { useT } from '../i18n';
|
||||||
import { ScreenHeader } from './ScreenHeader';
|
import { ScreenHeader } from './ScreenHeader';
|
||||||
import { FormModal, type FormField } from './FormModal';
|
import { FormModal, type FormField } from './FormModal';
|
||||||
|
import { Touchable } from './Touchable';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理页模板(P2):统一「列表 + +新增 + 点按编辑 + 长按删除 + FormModal」模式。
|
* 管理页模板(P2):统一「列表 + +新增 + 点按编辑 + 长按删除 + FormModal」模式。
|
||||||
@ -27,6 +28,8 @@ interface ManagementScreenProps<T> {
|
|||||||
deleteConfirmText: (item: T) => string;
|
deleteConfirmText: (item: T) => string;
|
||||||
/** 删除确认弹窗标题(默认通用「删除」)。 */
|
/** 删除确认弹窗标题(默认通用「删除」)。 */
|
||||||
deleteConfirmTitle?: string;
|
deleteConfirmTitle?: string;
|
||||||
|
/** 字段值变更时的联动回调,返回新值覆盖。 */
|
||||||
|
onValuesChange?: (changedKey: string, newValue: string, prevValues: Record<string, string>) => Record<string, string> | void;
|
||||||
/** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */
|
/** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */
|
||||||
formExtra?: (editing: T | null) => React.ReactNode;
|
formExtra?: (editing: T | null) => React.ReactNode;
|
||||||
/** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */
|
/** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */
|
||||||
@ -71,9 +74,9 @@ export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
|
|||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
title={props.title}
|
title={props.title}
|
||||||
right={
|
right={
|
||||||
<Pressable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}>
|
<Touchable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}>
|
||||||
<Ionicons name="add" size={26} color={theme.colors.accent} />
|
<Ionicons name="add" size={26} color={theme.colors.accent} />
|
||||||
</Pressable>
|
</Touchable>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
@ -97,6 +100,7 @@ export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
|
|||||||
visible={modalOpen}
|
visible={modalOpen}
|
||||||
title={props.formTitle(editing)}
|
title={props.formTitle(editing)}
|
||||||
fields={props.formFields(editing)}
|
fields={props.formFields(editing)}
|
||||||
|
onValuesChange={props.onValuesChange}
|
||||||
onConfirm={handleSubmit}
|
onConfirm={handleSubmit}
|
||||||
onCancel={closeModal}
|
onCancel={closeModal}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../theme';
|
import { useTheme } from '../theme';
|
||||||
|
import { Touchable } from './Touchable';
|
||||||
|
|
||||||
/** 键值:数字/小数点/加减走 numpadExpression;today/done 由宿主处理。 */
|
/** 键值:数字/小数点/加减走 numpadExpression;today/done 由宿主处理。 */
|
||||||
export type KeyboardKey = string;
|
export type KeyboardKey = string;
|
||||||
@ -21,49 +22,97 @@ const ROWS: KeyboardKey[][] = [
|
|||||||
['.', '0', 'backspace', 'done'],
|
['.', '0', 'backspace', 'done'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 内嵌数字键盘(4×4):金额优先,+/- 连续计算,「今天」快速设日期。 */
|
/** 内嵌数字键盘(4×4):金额优先,+/- 连续计算,「今天」快速设日期。带有按压微缩放与变色触控反馈。 */
|
||||||
export function NumpadKeyboard({ onKey, todayLabel, doneLabel, backspaceLabel }: NumpadKeyboardProps) {
|
export function NumpadKeyboard({ onKey, todayLabel, doneLabel, backspaceLabel }: NumpadKeyboardProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
const renderKey = (key: KeyboardKey) => {
|
const renderKey = (key: KeyboardKey) => {
|
||||||
if (key === 'done') {
|
if (key === 'done') {
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Touchable
|
||||||
key={key}
|
key={key}
|
||||||
onPress={() => onKey(key)}
|
onPress={() => onKey(key)}
|
||||||
style={[styles.key, styles.doneKey, { backgroundColor: theme.colors.accent, borderRadius: theme.radii.lg }]}
|
disableRipple
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.key,
|
||||||
|
styles.doneKey,
|
||||||
|
{
|
||||||
|
backgroundColor: pressed ? theme.colors.accentDark : theme.colors.accent,
|
||||||
|
borderRadius: theme.radii.lg,
|
||||||
|
},
|
||||||
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={doneLabel}
|
accessibilityLabel={doneLabel}
|
||||||
>
|
>
|
||||||
<Text style={[styles.doneText, { color: theme.colors.fgInverse }]}>{doneLabel}</Text>
|
<Text style={[styles.doneText, { color: theme.colors.fgInverse }]}>{doneLabel}</Text>
|
||||||
</Pressable>
|
</Touchable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key === 'backspace') {
|
if (key === 'backspace') {
|
||||||
return (
|
return (
|
||||||
<Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={backspaceLabel}>
|
<Touchable
|
||||||
|
key={key}
|
||||||
|
onPress={() => onKey(key)}
|
||||||
|
disableRipple
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.key,
|
||||||
|
pressed && { backgroundColor: theme.colors.bgTertiary },
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={backspaceLabel}
|
||||||
|
>
|
||||||
<Ionicons name="backspace-outline" size={22} color={theme.colors.fgPrimary} />
|
<Ionicons name="backspace-outline" size={22} color={theme.colors.fgPrimary} />
|
||||||
</Pressable>
|
</Touchable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key === 'today') {
|
if (key === 'today') {
|
||||||
return (
|
return (
|
||||||
<Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={todayLabel}>
|
<Touchable
|
||||||
|
key={key}
|
||||||
|
onPress={() => onKey(key)}
|
||||||
|
disableRipple
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.key,
|
||||||
|
pressed && { backgroundColor: theme.colors.bgTertiary },
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={todayLabel}
|
||||||
|
>
|
||||||
<Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{todayLabel}</Text>
|
<Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{todayLabel}</Text>
|
||||||
</Pressable>
|
</Touchable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key === '+' || key === '-') {
|
if (key === '+' || key === '-') {
|
||||||
return (
|
return (
|
||||||
<Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={key}>
|
<Touchable
|
||||||
|
key={key}
|
||||||
|
onPress={() => onKey(key)}
|
||||||
|
disableRipple
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.key,
|
||||||
|
pressed && { backgroundColor: theme.colors.bgTertiary },
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={key}
|
||||||
|
>
|
||||||
<Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{key === '+' ? '+' : '-'}</Text>
|
<Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{key === '+' ? '+' : '-'}</Text>
|
||||||
</Pressable>
|
</Touchable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={key}>
|
<Touchable
|
||||||
|
key={key}
|
||||||
|
onPress={() => onKey(key)}
|
||||||
|
disableRipple
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.key,
|
||||||
|
pressed && { backgroundColor: theme.colors.bgTertiary },
|
||||||
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={key}
|
||||||
|
>
|
||||||
<Text style={[styles.digitText, { color: theme.colors.fgPrimary }]}>{key}</Text>
|
<Text style={[styles.digitText, { color: theme.colors.fgPrimary }]}>{key}</Text>
|
||||||
</Pressable>
|
</Touchable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -81,7 +130,7 @@ export function NumpadKeyboard({ onKey, todayLabel, doneLabel, backspaceLabel }:
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
grid: { gap: 2 },
|
grid: { gap: 2 },
|
||||||
row: { flexDirection: 'row' },
|
row: { flexDirection: 'row' },
|
||||||
key: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 14 },
|
key: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 14, borderRadius: 8 },
|
||||||
digitText: { fontSize: 22, fontWeight: '600', fontVariant: ['tabular-nums'] },
|
digitText: { fontSize: 22, fontWeight: '600', fontVariant: ['tabular-nums'] },
|
||||||
opText: { fontSize: 16, fontWeight: '600' },
|
opText: { fontSize: 16, fontWeight: '600' },
|
||||||
doneKey: { margin: 4 },
|
doneKey: { margin: 4 },
|
||||||
|
|||||||
@ -21,11 +21,13 @@ import { useLedgerStore } from '../store/ledgerStore';
|
|||||||
import { useMetadataStore } from '../store/metadataStore';
|
import { useMetadataStore } from '../store/metadataStore';
|
||||||
import { useSettingsStore } from '../store/settingsStore';
|
import { useSettingsStore } from '../store/settingsStore';
|
||||||
import { TAG_COLORS } from '../theme/palette';
|
import { TAG_COLORS } from '../theme/palette';
|
||||||
|
import { AccountCreateModal } from './AccountCreateModal';
|
||||||
import { BottomSheet } from './BottomSheet';
|
import { BottomSheet } from './BottomSheet';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { CategoryIcon } from './CategoryIcon';
|
import { CategoryIcon } from './CategoryIcon';
|
||||||
import { CategoryPicker } from './CategoryPicker';
|
import { CategoryPicker } from './CategoryPicker';
|
||||||
import { DatePickerField } from './DatePickerField';
|
import { DatePickerField } from './DatePickerField';
|
||||||
|
import { FormModal, type FormField } from './FormModal';
|
||||||
import { NumpadKeyboard } from './NumpadKeyboard';
|
import { NumpadKeyboard } from './NumpadKeyboard';
|
||||||
import { PostingEditor } from './PostingEditor';
|
import { PostingEditor } from './PostingEditor';
|
||||||
import { TagPicker } from './TagPicker';
|
import { TagPicker } from './TagPicker';
|
||||||
@ -39,11 +41,12 @@ import { matchCategory } from '../domain/categories';
|
|||||||
import type { Category } from '../domain/categories';
|
import type { Category } from '../domain/categories';
|
||||||
import type { Tag } from '../domain/tags';
|
import type { Tag } from '../domain/tags';
|
||||||
import type { Posting } from '../domain/types';
|
import type { Posting } from '../domain/types';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
type Direction = 'expense' | 'income' | 'transfer';
|
type Direction = 'expense' | 'income' | 'transfer';
|
||||||
|
|
||||||
interface NumpadSheetProps {
|
interface NumpadSheetProps {
|
||||||
presentation: 'page' | 'modal';
|
presentation?: 'modal' | 'page';
|
||||||
editId?: string;
|
editId?: string;
|
||||||
draftJson?: string;
|
draftJson?: string;
|
||||||
autoOcr?: boolean;
|
autoOcr?: boolean;
|
||||||
@ -52,7 +55,7 @@ interface NumpadSheetProps {
|
|||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose, onDirtyChange }: NumpadSheetProps) {
|
export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr, onClose, onDirtyChange }: NumpadSheetProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@ -61,6 +64,7 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
|
|||||||
|
|
||||||
const ledger = useLedgerStore(s => s.ledger);
|
const ledger = useLedgerStore(s => s.ledger);
|
||||||
const editTransaction = useLedgerStore(s => s.editTransaction);
|
const editTransaction = useLedgerStore(s => s.editTransaction);
|
||||||
|
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
|
||||||
const categories = useMetadataStore(s => s.categories);
|
const categories = useMetadataStore(s => s.categories);
|
||||||
const tags = useMetadataStore(s => s.tags);
|
const tags = useMetadataStore(s => s.tags);
|
||||||
const lastUsedAccount = useSettingsStore(s => s.lastUsedAccount);
|
const lastUsedAccount = useSettingsStore(s => s.lastUsedAccount);
|
||||||
@ -85,6 +89,42 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
|
|||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
const [accountPicker, setAccountPicker] = useState<'source' | 'target' | null>(null);
|
const [accountPicker, setAccountPicker] = useState<'source' | 'target' | null>(null);
|
||||||
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false);
|
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false);
|
||||||
|
const [isQuickAddingAccount, setIsQuickAddingAccount] = useState(false);
|
||||||
|
|
||||||
|
const handleQuickAddAccount = async (values: Record<string, string>) => {
|
||||||
|
const typeVal = values.type?.trim();
|
||||||
|
const nameVal = values.name?.trim();
|
||||||
|
if (!typeVal || !nameVal) {
|
||||||
|
Alert.alert(t('account.addFail'), t('account.addFailEmpty'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rawAccount = `${typeVal}:${nameVal}`;
|
||||||
|
let sanitized = rawAccount.replace(/:/g, ':');
|
||||||
|
sanitized = sanitized
|
||||||
|
.split(':')
|
||||||
|
.map((seg, idx) => {
|
||||||
|
if (idx === 0) return seg;
|
||||||
|
let cleaned = seg.replace(/[\(\)()\[\]]/g, '-');
|
||||||
|
cleaned = cleaned.replace(/-+/g, '-');
|
||||||
|
cleaned = cleaned.replace(/^-|-$/g, '');
|
||||||
|
cleaned = cleaned.replace(/[^\w\u4e00-\u9fa5-]/g, '');
|
||||||
|
return cleaned;
|
||||||
|
})
|
||||||
|
.join(':');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await autoOpenAccounts([sanitized]);
|
||||||
|
setIsQuickAddingAccount(false);
|
||||||
|
if (accountPicker === 'source') {
|
||||||
|
setSourceAccount(sanitized);
|
||||||
|
} else if (accountPicker === 'target') {
|
||||||
|
setTargetAccount(sanitized);
|
||||||
|
}
|
||||||
|
setAccountPicker(null);
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert(t('account.openFail'), e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 编辑模式保留字段
|
// 编辑模式保留字段
|
||||||
const [oldRaw, setOldRaw] = useState('');
|
const [oldRaw, setOldRaw] = useState('');
|
||||||
@ -130,6 +170,8 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
|
|||||||
return [...(pinned ? [pinned] : []), ...rest].slice(0, 3);
|
return [...(pinned ? [pinned] : []), ...rest].slice(0, 3);
|
||||||
}, [availableCategories, lastUsedCategoryId]);
|
}, [availableCategories, lastUsedCategoryId]);
|
||||||
|
|
||||||
|
const parsedOriginalDraftRef = useRef<{ payee?: string; categoryAccount?: string; sourceAccount?: string; amount?: string; direction?: string } | null>(null);
|
||||||
|
|
||||||
// ===== 预填:draftJson(草稿) =====
|
// ===== 预填:draftJson(草稿) =====
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!draftJson) return;
|
if (!draftJson) return;
|
||||||
@ -150,6 +192,13 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
|
|||||||
if (matched) setSelectedCategory(matched);
|
if (matched) setSelectedCategory(matched);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
parsedOriginalDraftRef.current = {
|
||||||
|
payee: (draft.payee || '').trim(),
|
||||||
|
categoryAccount: legs?.targetAccount || '',
|
||||||
|
sourceAccount: legs?.sourceAccount || '',
|
||||||
|
amount: legs?.amount || '',
|
||||||
|
direction: legs?.direction || 'expense',
|
||||||
|
};
|
||||||
if (Array.isArray(draft.postings) && draft.postings.length > 0) {
|
if (Array.isArray(draft.postings) && draft.postings.length > 0) {
|
||||||
setPostings(draft.postings.map((p: any) => ({
|
setPostings(draft.postings.map((p: any) => ({
|
||||||
account: p.account, amount: p.amount, currency: p.currency,
|
account: p.account, amount: p.amount, currency: p.currency,
|
||||||
@ -324,8 +373,7 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
|
|||||||
};
|
};
|
||||||
|
|
||||||
const closeSoon = () => {
|
const closeSoon = () => {
|
||||||
if (presentation === 'modal') onClose?.();
|
onClose?.();
|
||||||
else setTimeout(() => router.back(), 600);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// modal 工具行关闭按钮:脏数据时先确认(提交成功走 closeSoon 直关,isSubmitted 已置位不会误拦)
|
// modal 工具行关闭按钮:脏数据时先确认(提交成功走 closeSoon 直关,isSubmitted 已置位不会误拦)
|
||||||
@ -416,6 +464,29 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
|
|||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
closeSoon();
|
closeSoon();
|
||||||
} else {
|
} else {
|
||||||
|
if (parsedOriginalDraftRef.current) {
|
||||||
|
const orig = parsedOriginalDraftRef.current;
|
||||||
|
const userPayee = payee.trim();
|
||||||
|
const userCategory = categoryAccount;
|
||||||
|
const userSource = sourceAccount;
|
||||||
|
const userAmount = amt;
|
||||||
|
const userDirection = direction;
|
||||||
|
|
||||||
|
const isCategoryChanged = userCategory !== orig.categoryAccount;
|
||||||
|
const isPayeeChanged = userPayee !== orig.payee;
|
||||||
|
const isAmountChanged = userAmount !== orig.amount;
|
||||||
|
const isSourceChanged = userSource !== orig.sourceAccount;
|
||||||
|
const isDirectionChanged = userDirection !== orig.direction;
|
||||||
|
|
||||||
|
if (isCategoryChanged || isPayeeChanged || isAmountChanged || isSourceChanged || isDirectionChanged) {
|
||||||
|
logger.info('userCorrection', `[用户修正草稿记账] 预填推荐 vs 用户手动修正:`, {
|
||||||
|
original: { payee: orig.payee, category: orig.categoryAccount, sourceAccount: orig.sourceAccount, amount: orig.amount, direction: orig.direction },
|
||||||
|
corrected: { payee: userPayee, category: userCategory, sourceAccount: userSource, amount: userAmount, direction: userDirection },
|
||||||
|
changes: { categoryChanged: isCategoryChanged, payeeChanged: isPayeeChanged, amountChanged: isAmountChanged, sourceChanged: isSourceChanged, directionChanged: isDirectionChanged },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await buildAndSaveTransaction({
|
await buildAndSaveTransaction({
|
||||||
date,
|
date,
|
||||||
amount: amt,
|
amount: amt,
|
||||||
@ -698,6 +769,15 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
|
|||||||
{/* 全部账户 BottomSheet */}
|
{/* 全部账户 BottomSheet */}
|
||||||
<BottomSheet visible={accountPicker !== null} onClose={() => setAccountPicker(null)} title={t('numpad.allAccounts')}>
|
<BottomSheet visible={accountPicker !== null} onClose={() => setAccountPicker(null)} title={t('numpad.allAccounts')}>
|
||||||
<ScrollView style={styles.accountList}>
|
<ScrollView style={styles.accountList}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setIsQuickAddingAccount(true)}
|
||||||
|
style={[styles.accountListItem, { borderBottomColor: theme.colors.divider, flexDirection: 'row', alignItems: 'center' }]}
|
||||||
|
>
|
||||||
|
<Ionicons name="add-circle-outline" size={20} color={theme.colors.accent} />
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700', marginLeft: 6 }]}>
|
||||||
|
{t('account.addNew')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
{assetAccounts.map(acct => (
|
{assetAccounts.map(acct => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={acct}
|
key={acct}
|
||||||
@ -714,6 +794,14 @@ export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose,
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
|
||||||
|
{/* 快捷新建账户 AccountCreateModal (全应用 100% 统一复用) */}
|
||||||
|
<AccountCreateModal
|
||||||
|
visible={isQuickAddingAccount}
|
||||||
|
defaultType="Assets"
|
||||||
|
onConfirm={handleQuickAddAccount}
|
||||||
|
onCancel={() => setIsQuickAddingAccount(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 全部分类 BottomSheet */}
|
{/* 全部分类 BottomSheet */}
|
||||||
<BottomSheet visible={categoryPickerOpen} onClose={() => setCategoryPickerOpen(false)} title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
|
<BottomSheet visible={categoryPickerOpen} onClose={() => setCategoryPickerOpen(false)} title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
|
||||||
<ScrollView style={styles.accountList}>
|
<ScrollView style={styles.accountList}>
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useTheme } from '../theme';
|
import { useTheme } from '../theme';
|
||||||
|
import { Touchable } from './Touchable';
|
||||||
|
|
||||||
interface ScreenHeaderProps {
|
interface ScreenHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
@ -20,14 +21,14 @@ export function ScreenHeader({ title, right, onBack, backLabel = '返回' }: Scr
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Pressable
|
<Touchable
|
||||||
onPress={onBack ?? (() => router.back())}
|
onPress={onBack ?? (() => router.back())}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={backLabel}
|
accessibilityLabel={backLabel}
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
|
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
|
||||||
</Pressable>
|
</Touchable>
|
||||||
<Text style={[theme.typography.h2, styles.title, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
|
<Text style={[theme.typography.h2, styles.title, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
44
src/components/Touchable.tsx
Normal file
44
src/components/Touchable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { useTheme } from '../theme';
|
import { useTheme } from '../theme';
|
||||||
import { useT } from '../i18n';
|
import { useT } from '../i18n';
|
||||||
import { CategoryIcon } from './CategoryIcon';
|
import { CategoryIcon } from './CategoryIcon';
|
||||||
|
import { Touchable } from './Touchable';
|
||||||
import type { Transaction } from '../domain/types';
|
import type { Transaction } from '../domain/types';
|
||||||
|
|
||||||
interface TransactionCardProps {
|
interface TransactionCardProps {
|
||||||
@ -36,11 +37,11 @@ export const TransactionCard = React.memo(function TransactionCard({ transaction
|
|||||||
].filter(Boolean).join(' · ');
|
].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Touchable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`${transaction.narration || transaction.payee || ''} ${amountText} ${transaction.date.slice(0, 10)}`}
|
accessibilityLabel={`${transaction.narration || transaction.payee || ''} ${amountText} ${transaction.date.slice(0, 10)}`}
|
||||||
onPress={() => onPress?.(transaction)}
|
onPress={() => onPress?.(transaction)}
|
||||||
style={({ pressed }) => [
|
style={[
|
||||||
styles.card,
|
styles.card,
|
||||||
{
|
{
|
||||||
backgroundColor: theme.colors.bgSecondary,
|
backgroundColor: theme.colors.bgSecondary,
|
||||||
@ -48,7 +49,6 @@ export const TransactionCard = React.memo(function TransactionCard({ transaction
|
|||||||
padding: theme.spacing.md,
|
padding: theme.spacing.md,
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
opacity: pressed ? 0.8 : 1,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@ -75,7 +75,7 @@ export const TransactionCard = React.memo(function TransactionCard({ transaction
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Touchable>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -10,12 +10,16 @@ import { detectDirection } from './constants';
|
|||||||
import { negateDecimal } from './decimal';
|
import { negateDecimal } from './decimal';
|
||||||
import type { ImportedEvent } from './types';
|
import type { ImportedEvent } from './types';
|
||||||
import { splitCsvLine, pickFirst } from './csvUtils';
|
import { splitCsvLine, pickFirst } from './csvUtils';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
/** 通用 CSV 行解析(引号感知,支持含逗号字段)。 */
|
/** 通用 CSV 行解析(引号感知,支持含逗号字段)。 */
|
||||||
function parseCsvRows(content: string): Record<string, string>[] {
|
function parseCsvRows(content: string): Record<string, string>[] {
|
||||||
const lines = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n').filter(l => l.trim());
|
const lines = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n').filter(l => l.trim());
|
||||||
const headerLine = lines.find(l => /交易|金额|时间|日期|余额|商户|对方/i.test(l));
|
const headerLine = lines.find(l => /交易|金额|时间|日期|余额|商户|对方/i.test(l));
|
||||||
if (!headerLine) throw new Error('未找到账单表头');
|
if (!headerLine) {
|
||||||
|
logger.error('import', 'CSV 文件解析失败:未找到包含交易/金额/日期的表头行');
|
||||||
|
throw new Error('未找到账单表头');
|
||||||
|
}
|
||||||
const headers = splitCsvLine(headerLine).map(h => h.trim().replace(/^"|"$/g, ''));
|
const headers = splitCsvLine(headerLine).map(h => h.trim().replace(/^"|"$/g, ''));
|
||||||
return lines.slice(lines.indexOf(headerLine) + 1).map(line => {
|
return lines.slice(lines.indexOf(headerLine) + 1).map(line => {
|
||||||
const values = splitCsvLine(line).map(v => v.trim().replace(/^"|"$/g, ''));
|
const values = splitCsvLine(line).map(v => v.trim().replace(/^"|"$/g, ''));
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { classifyWithCategories, inferPostingAmounts } from './rules';
|
import { classifyWithCategories, inferPostingAmounts } from './rules';
|
||||||
import { batchDedup, dedupAgainstHistory, type DedupConfig } from './dedup';
|
import { batchDedup, dedupAgainstHistory, type DedupConfig, type DuplicateDetail } from './dedup';
|
||||||
import { recognizeTransfers, type TransferConfig, type TransferPair } from './transferRecognizer';
|
import { recognizeTransfers, type TransferConfig, type TransferPair } from './transferRecognizer';
|
||||||
import { compareDecimals } from './decimal';
|
import { compareDecimals } from './decimal';
|
||||||
import type { Category } from './categories';
|
import type { Category } from './categories';
|
||||||
import type { ImportedEvent, LedgerIndex, Rule, Transaction, TransactionDraft } from './types';
|
import type { ImportedEvent, LedgerIndex, Rule, Transaction, TransactionDraft } from './types';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账单处理责任链(参考 AutoAccounting 的 BillService.analyze + deduplicationMutex)。
|
* 账单处理责任链(参考 AutoAccounting 的 BillService.analyze + deduplicationMutex)。
|
||||||
@ -44,6 +45,8 @@ export interface PipelineResult {
|
|||||||
drafts: ProcessedDraft[];
|
drafts: ProcessedDraft[];
|
||||||
/** 被去重丢弃的事件。 */
|
/** 被去重丢弃的事件。 */
|
||||||
duplicates: ImportedEvent[];
|
duplicates: ImportedEvent[];
|
||||||
|
/** 详细的去重匹配信息。 */
|
||||||
|
duplicateDetails?: DuplicateDetail[];
|
||||||
/** 转账识别统计。 */
|
/** 转账识别统计。 */
|
||||||
transferCount: number;
|
transferCount: number;
|
||||||
}
|
}
|
||||||
@ -98,25 +101,45 @@ export class BillPipeline {
|
|||||||
return { drafts: [], duplicates: [], transferCount: 0 };
|
return { drafts: [], duplicates: [], transferCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('billPipeline', `开始批次处理账单事件 (事件总数: ${events.length})`);
|
||||||
|
|
||||||
// 1. 转账识别(先于去重)
|
// 1. 转账识别(先于去重)
|
||||||
const { transfers, remaining: afterTransfer } = recognizeTransfers(
|
const { transfers, remaining: afterTransfer } = recognizeTransfers(
|
||||||
events,
|
events,
|
||||||
ctx.transferConfig,
|
ctx.transferConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const t of transfers) {
|
||||||
|
logger.info('billPipeline', `[转账识别] 命中转账对 (置信度: ${t.confidence}): "${t.draft.narration}", 金额: ${t.draft.postings[1]?.amount}, 原因: ${t.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 多通道去重(剩余事件之间)
|
// 2. 多通道去重(剩余事件之间)
|
||||||
const { accepted: afterBatchDedup, duplicates: batchDups } = batchDedup(
|
const { accepted: afterBatchDedup, duplicates: batchDups, duplicateDetails: batchDetails } = batchDedup(
|
||||||
afterTransfer,
|
afterTransfer,
|
||||||
ctx.dedupConfig,
|
ctx.dedupConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const detail of batchDetails) {
|
||||||
|
const d = detail.event;
|
||||||
|
const m = detail.matchedWith;
|
||||||
|
const matchText = m ? ` -> 匹配到已有事件 (时间: ${m.time}, 金额: ${m.amount}, 对方: "${m.counterparty || '未知'}")` : '';
|
||||||
|
logger.info('billPipeline', `[同批次去重] 过滤重复事件 [ID: ${d.id}, 时间: ${d.occurredAt}, 金额: ${d.amount} ${d.currency}, 对方: "${d.counterparty || '未知'}"]${matchText} | 原因: ${detail.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 与历史交易去重(避免重复入账)
|
// 3. 与历史交易去重(避免重复入账)
|
||||||
const { accepted: afterHistoryDedup, duplicates: historyDups } = dedupAgainstHistory(
|
const { accepted: afterHistoryDedup, duplicates: historyDups, duplicateDetails: historyDetails } = dedupAgainstHistory(
|
||||||
afterBatchDedup,
|
afterBatchDedup,
|
||||||
ctx.history,
|
ctx.history,
|
||||||
ctx.dedupConfig,
|
ctx.dedupConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const detail of historyDetails) {
|
||||||
|
const d = detail.event;
|
||||||
|
const m = detail.matchedWith;
|
||||||
|
const matchText = m ? ` -> 匹配到历史账本 [时间: ${m.time}, 金额: ${m.amount}, 对方: "${m.counterparty || '未知'}"]` : '';
|
||||||
|
logger.info('billPipeline', `[历史交易去重] 过滤重复事件 [ID: ${d.id}, 时间: ${d.occurredAt}, 金额: ${d.amount} ${d.currency}, 对方: "${d.counterparty || '未知'}"]${matchText} | 原因: ${detail.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 分类(转账已是完整草稿;非转账事件走 classifyWithCategories)
|
// 4. 分类(转账已是完整草稿;非转账事件走 classifyWithCategories)
|
||||||
const drafts: ProcessedDraft[] = [];
|
const drafts: ProcessedDraft[] = [];
|
||||||
const transferDups: ImportedEvent[] = [];
|
const transferDups: ImportedEvent[] = [];
|
||||||
@ -131,6 +154,7 @@ export class BillPipeline {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 转账已在历史中,将两个源事件标记为重复
|
// 转账已在历史中,将两个源事件标记为重复
|
||||||
|
logger.info('billPipeline', `[转账历史去重] 转账草稿 "${transfer.draft.narration}" 已存在于历史账本中,标记丢弃`);
|
||||||
const leftEvt = events.find(e => e.id === transfer.leftEventId);
|
const leftEvt = events.find(e => e.id === transfer.leftEventId);
|
||||||
const rightEvt = events.find(e => e.id === transfer.rightEventId);
|
const rightEvt = events.find(e => e.id === transfer.rightEventId);
|
||||||
if (leftEvt) transferDups.push(leftEvt);
|
if (leftEvt) transferDups.push(leftEvt);
|
||||||
@ -171,9 +195,13 @@ export class BillPipeline {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalDups = batchDups.length + historyDups.length + transferDups.length;
|
||||||
|
logger.info('billPipeline', `批次处理完成: 生成 ${drafts.length} 笔草稿 (转账: ${transfers.length}), 过滤 ${totalDups} 笔重复事件`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
drafts,
|
drafts,
|
||||||
duplicates: [...batchDups, ...historyDups, ...transferDups],
|
duplicates: [...batchDups, ...historyDups, ...transferDups],
|
||||||
|
duplicateDetails: [...batchDetails, ...historyDetails],
|
||||||
transferCount: transfers.length,
|
transferCount: transfers.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,8 @@ export interface DedupResult {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
/** 重复的来源事件/交易 id。 */
|
/** 重复的来源事件/交易 id。 */
|
||||||
duplicateOf?: string;
|
duplicateOf?: string;
|
||||||
|
/** 匹配到的对比项信息。 */
|
||||||
|
matchedItem?: DedupComparable;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 去重比较所需的归一化形状(从 ImportedEvent 或 Transaction 映射)。 */
|
/** 去重比较所需的归一化形状(从 ImportedEvent 或 Transaction 映射)。 */
|
||||||
@ -41,6 +43,8 @@ export interface DedupComparable {
|
|||||||
time: string;
|
time: string;
|
||||||
amount: string;
|
amount: string;
|
||||||
counterparty?: string;
|
counterparty?: string;
|
||||||
|
transactionId?: string;
|
||||||
|
rawTransaction?: Transaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把 ImportedEvent 归一化为去重比较形状。 */
|
/** 把 ImportedEvent 归一化为去重比较形状。 */
|
||||||
@ -103,24 +107,24 @@ export function checkDuplicate(
|
|||||||
if (!hasTimeEvent || !hasTimeEx) {
|
if (!hasTimeEvent || !hasTimeEx) {
|
||||||
// 至少一方是日期粒度(例如历史账本数据)
|
// 至少一方是日期粒度(例如历史账本数据)
|
||||||
if (isSameDay && compareDecimals(eventAmountAbs, exAmountAbs) === 0) {
|
if (isSameDay && compareDecimals(eventAmountAbs, exAmountAbs) === 0) {
|
||||||
const sameCounterparty = event.counterparty && exCounterparty &&
|
const normEv = (event.counterparty || '').trim();
|
||||||
event.counterparty === exCounterparty;
|
const normEx = (exCounterparty || '').trim();
|
||||||
|
const sameCounterparty = (normEv && normEx && (normEv === normEx || normEv.includes(normEx) || normEx.includes(normEv))) || (!normEv && !normEx);
|
||||||
if (sameCounterparty) {
|
if (sameCounterparty) {
|
||||||
return {
|
return {
|
||||||
isDuplicate: true,
|
isDuplicate: true,
|
||||||
confidence: 'medium',
|
confidence: 'medium',
|
||||||
reason: `同天发生、金额相同、对手方一致(${exTimeStr})`,
|
reason: `同天发生、金额相同、对手方匹配(${exTimeStr})`,
|
||||||
};
|
matchedItem: normalize(item),
|
||||||
}
|
|
||||||
// 双方都无对手方信息 → 低置信度重复
|
|
||||||
// 仅一方有对手方 → 不判定重复(保留条,避免误杀)
|
|
||||||
if (!event.counterparty && !exCounterparty) {
|
|
||||||
return {
|
|
||||||
isDuplicate: true,
|
|
||||||
confidence: 'low',
|
|
||||||
reason: `同天发生、金额相同(${exTimeStr})`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// 同天 + 同金额 → 判定为低置信度重复,避免二次导入相同账单
|
||||||
|
return {
|
||||||
|
isDuplicate: true,
|
||||||
|
confidence: 'low',
|
||||||
|
reason: `同天发生、金额相同(${exTimeStr})`,
|
||||||
|
matchedItem: normalize(item),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -139,6 +143,7 @@ export function checkDuplicate(
|
|||||||
isDuplicate: true,
|
isDuplicate: true,
|
||||||
confidence: 'high',
|
confidence: 'high',
|
||||||
reason: `时间相同、金额一致(${exTimeStr})`,
|
reason: `时间相同、金额一致(${exTimeStr})`,
|
||||||
|
matchedItem: normalize(item),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,6 +157,7 @@ export function checkDuplicate(
|
|||||||
isDuplicate: true,
|
isDuplicate: true,
|
||||||
confidence: 'medium',
|
confidence: 'medium',
|
||||||
reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同、对手方一致`,
|
reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同、对手方一致`,
|
||||||
|
matchedItem: normalize(item),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// 无对手方信息或不一致 → 低置信度重复
|
// 无对手方信息或不一致 → 低置信度重复
|
||||||
@ -159,6 +165,7 @@ export function checkDuplicate(
|
|||||||
isDuplicate: true,
|
isDuplicate: true,
|
||||||
confidence: 'low',
|
confidence: 'low',
|
||||||
reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同`,
|
reason: `时间相近(${Math.round(timeDiff / 60000)}分钟)、金额相同`,
|
||||||
|
matchedItem: normalize(item),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -182,16 +189,24 @@ function dateWindowDays(timeWindowMinutes: number): number {
|
|||||||
return Math.ceil(timeWindowMinutes / (24 * 60));
|
return Math.ceil(timeWindowMinutes / (24 * 60));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DuplicateDetail {
|
||||||
|
event: ImportedEvent;
|
||||||
|
matchedWith?: DedupComparable;
|
||||||
|
reason?: string;
|
||||||
|
confidence?: DedupConfidence;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量去重:从一组事件中筛出重复的,保留首个出现的。
|
* 批量去重:从一组事件中筛出重复的,保留首个出现的。
|
||||||
* 返回去重后的列表与被丢弃的重复项。
|
* 返回去重后的列表与被丢弃的重复项及对比详情。
|
||||||
*/
|
*/
|
||||||
export function batchDedup(
|
export function batchDedup(
|
||||||
events: ImportedEvent[],
|
events: ImportedEvent[],
|
||||||
config: DedupConfig = DEFAULT_DEDUP_CONFIG,
|
config: DedupConfig = DEFAULT_DEDUP_CONFIG,
|
||||||
): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } {
|
): { accepted: ImportedEvent[]; duplicates: ImportedEvent[]; duplicateDetails: DuplicateDetail[] } {
|
||||||
const accepted: ImportedEvent[] = [];
|
const accepted: ImportedEvent[] = [];
|
||||||
const duplicates: ImportedEvent[] = [];
|
const duplicates: ImportedEvent[] = [];
|
||||||
|
const duplicateDetails: DuplicateDetail[] = [];
|
||||||
const acceptedByDate = new Map<string, ImportedEvent[]>();
|
const acceptedByDate = new Map<string, ImportedEvent[]>();
|
||||||
const windowDays = dateWindowDays(config.timeWindowMinutes);
|
const windowDays = dateWindowDays(config.timeWindowMinutes);
|
||||||
|
|
||||||
@ -210,6 +225,12 @@ export function batchDedup(
|
|||||||
const result = checkDuplicate(event, candidates, config);
|
const result = checkDuplicate(event, candidates, config);
|
||||||
if (result.isDuplicate) {
|
if (result.isDuplicate) {
|
||||||
duplicates.push(event);
|
duplicates.push(event);
|
||||||
|
duplicateDetails.push({
|
||||||
|
event,
|
||||||
|
matchedWith: result.matchedItem,
|
||||||
|
reason: result.reason,
|
||||||
|
confidence: result.confidence,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
accepted.push(event);
|
accepted.push(event);
|
||||||
const list = acceptedByDate.get(eventDate) ?? [];
|
const list = acceptedByDate.get(eventDate) ?? [];
|
||||||
@ -218,7 +239,7 @@ export function batchDedup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { accepted, duplicates };
|
return { accepted, duplicates, duplicateDetails };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -229,12 +250,23 @@ export function dedupAgainstHistory(
|
|||||||
events: ImportedEvent[],
|
events: ImportedEvent[],
|
||||||
history: Transaction[],
|
history: Transaction[],
|
||||||
config: DedupConfig = DEFAULT_DEDUP_CONFIG,
|
config: DedupConfig = DEFAULT_DEDUP_CONFIG,
|
||||||
): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } {
|
): { accepted: ImportedEvent[]; duplicates: ImportedEvent[]; duplicateDetails: DuplicateDetail[] } {
|
||||||
const accepted: ImportedEvent[] = [];
|
const accepted: ImportedEvent[] = [];
|
||||||
const duplicates: ImportedEvent[] = [];
|
const duplicates: ImportedEvent[] = [];
|
||||||
|
const duplicateDetails: DuplicateDetail[] = [];
|
||||||
const windowDays = dateWindowDays(config.timeWindowMinutes);
|
const windowDays = dateWindowDays(config.timeWindowMinutes);
|
||||||
|
|
||||||
// Group history by date YYYY-MM-DD
|
// 1. 提取历史已保存交易中的 import_id(精确判重集合)
|
||||||
|
const historyImportIds = new Set<string>();
|
||||||
|
for (const t of history) {
|
||||||
|
if (t.metadata?.import_id) {
|
||||||
|
for (const id of t.metadata.import_id.split(',')) {
|
||||||
|
historyImportIds.add(id.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Group history by date YYYY-MM-DD
|
||||||
const historyByDate = new Map<string, DedupComparable[]>();
|
const historyByDate = new Map<string, DedupComparable[]>();
|
||||||
for (const t of history) {
|
for (const t of history) {
|
||||||
const dateStr = t.date.slice(0, 10);
|
const dateStr = t.date.slice(0, 10);
|
||||||
@ -244,7 +276,9 @@ export function dedupAgainstHistory(
|
|||||||
const comparable: DedupComparable = {
|
const comparable: DedupComparable = {
|
||||||
time: dateStr,
|
time: dateStr,
|
||||||
amount: (sourcePosting?.amount ?? '0').replace(/^-/, ''),
|
amount: (sourcePosting?.amount ?? '0').replace(/^-/, ''),
|
||||||
counterparty: t.payee ?? '',
|
counterparty: (t.payee || t.narration || '').trim(),
|
||||||
|
transactionId: t.id,
|
||||||
|
rawTransaction: t,
|
||||||
};
|
};
|
||||||
const list = historyByDate.get(dateStr) ?? [];
|
const list = historyByDate.get(dateStr) ?? [];
|
||||||
list.push(comparable);
|
list.push(comparable);
|
||||||
@ -252,6 +286,16 @@ export function dedupAgainstHistory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
|
// 优先:O(1) 精确匹配已有的 import_id
|
||||||
|
if (event.id && historyImportIds.has(event.id)) {
|
||||||
|
duplicates.push(event);
|
||||||
|
duplicateDetails.push({
|
||||||
|
event,
|
||||||
|
reason: '匹配到历史交易 import_id',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const eventDate = event.occurredAt.slice(0, 10);
|
const eventDate = event.occurredAt.slice(0, 10);
|
||||||
const [y, m, d] = eventDate.split('-').map(Number);
|
const [y, m, d] = eventDate.split('-').map(Number);
|
||||||
|
|
||||||
@ -266,10 +310,16 @@ export function dedupAgainstHistory(
|
|||||||
const result = checkDuplicate(event, candidates, config);
|
const result = checkDuplicate(event, candidates, config);
|
||||||
if (result.isDuplicate) {
|
if (result.isDuplicate) {
|
||||||
duplicates.push(event);
|
duplicates.push(event);
|
||||||
|
duplicateDetails.push({
|
||||||
|
event,
|
||||||
|
matchedWith: result.matchedItem,
|
||||||
|
reason: result.reason,
|
||||||
|
confidence: result.confidence,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
accepted.push(event);
|
accepted.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { accepted, duplicates };
|
return { accepted, duplicates, duplicateDetails };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { addDecimals } from './decimal';
|
import { addDecimals } from './decimal';
|
||||||
import type { Account, BalanceAssertion, Diagnostic, LedgerFile, LedgerIndex, Option, Posting, Transaction, TransactionDraft, ValidationResult } from './types';
|
import type { Account, BalanceAssertion, Diagnostic, LedgerFile, LedgerIndex, Option, Posting, Transaction, TransactionDraft, ValidationResult } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -192,7 +192,7 @@ export function parseLedger(files: LedgerFile[], options?: ParseLedgerOptions):
|
|||||||
}
|
}
|
||||||
const raw = rawLines.join('\n');
|
const raw = rawLines.join('\n');
|
||||||
transactions.push({
|
transactions.push({
|
||||||
id: hash(`${file.path}:${raw}`),
|
id: hash(`${file.path}:${cursor}:${raw}`),
|
||||||
date: start[1], flag: start[2],
|
date: start[1], flag: start[2],
|
||||||
payee: start[4] ? start[3] : undefined,
|
payee: start[4] ? start[3] : undefined,
|
||||||
narration: start[4] ?? start[3] ?? '',
|
narration: start[4] ?? start[3] ?? '',
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { commitMobileTransaction } from './mobile';
|
|||||||
import { hash, serializeTransaction } from './ledger';
|
import { hash, serializeTransaction } from './ledger';
|
||||||
import type { LedgerIndex, TransactionDraft } from './types';
|
import type { LedgerIndex, TransactionDraft } from './types';
|
||||||
import type { MobileCommit } from './mobile';
|
import type { MobileCommit } from './mobile';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* main.bean 的工程化存储:并发写入锁、原子写入、崩溃恢复。
|
* main.bean 的工程化存储:并发写入锁、原子写入、崩溃恢复。
|
||||||
@ -88,9 +89,13 @@ export class MobileBeanStore {
|
|||||||
if (this._initPromise) return this._initPromise;
|
if (this._initPromise) return this._initPromise;
|
||||||
this._initPromise = (async () => {
|
this._initPromise = (async () => {
|
||||||
this._recovered = await this.backend.recoverIfNeeded();
|
this._recovered = await this.backend.recoverIfNeeded();
|
||||||
|
if (this._recovered) {
|
||||||
|
logger.warn('mobileStore', '启动检查:从写入中崩溃残余 (.tmp) 中恢复并清理了临时文件');
|
||||||
|
}
|
||||||
this.current = await this.backend.read();
|
this.current = await this.backend.read();
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
})().catch(err => {
|
})().catch(err => {
|
||||||
|
logger.error('mobileStore', '初始化存储失败', err);
|
||||||
// 失败后清除 promise,允许重试
|
// 失败后清除 promise,允许重试
|
||||||
this._initPromise = null;
|
this._initPromise = null;
|
||||||
throw err;
|
throw err;
|
||||||
@ -126,6 +131,7 @@ export class MobileBeanStore {
|
|||||||
await this.backend.writeAtomic(this.current);
|
await this.backend.writeAtomic(this.current);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 写入失败时回滚内存状态,防止内存与磁盘不一致
|
// 写入失败时回滚内存状态,防止内存与磁盘不一致
|
||||||
|
logger.error('mobileStore', `追加交易失败,内存已回滚. 摘要: "${draft.narration}", 金额: ${draft.postings[0]?.amount}`, e);
|
||||||
this.current = prev;
|
this.current = prev;
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@ -150,6 +156,7 @@ export class MobileBeanStore {
|
|||||||
try {
|
try {
|
||||||
await this.backend.writeAtomic(this.current);
|
await this.backend.writeAtomic(this.current);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error('mobileStore', '替换 main.bean 内容失败,内存已回滚', e);
|
||||||
this.current = prev;
|
this.current = prev;
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@ -167,6 +174,7 @@ export class MobileBeanStore {
|
|||||||
try {
|
try {
|
||||||
await this.backend.writeAtomic(this.current);
|
await this.backend.writeAtomic(this.current);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error('mobileStore', '原子修改 main.bean 失败,内存已回滚', e);
|
||||||
this.current = prev;
|
this.current = prev;
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* OCR 分层处理协调器(plan.md「3.1 分层处理架构」+「3.9 JS 层分层处理」)。
|
* OCR 分层处理协调器(plan.md「3.1 分层处理架构」+「3.9 JS 层分层处理」)。
|
||||||
*
|
*
|
||||||
* Layer 1: 规则匹配(快速,无成本)→ 直接出账单
|
* Layer 1: 规则匹配(快速,无成本)→ 直接出账单
|
||||||
@ -12,6 +12,7 @@ import { DEDUP_CACHE_MAX, IMAGE_HASH_TRUNCATE_LEN } from './constants';
|
|||||||
import { matchOcrRule, parseOcrBill, checkIsDetailPage } from './ocr';
|
import { matchOcrRule, parseOcrBill, checkIsDetailPage } from './ocr';
|
||||||
import { hash } from './ledger';
|
import { hash } from './ledger';
|
||||||
import type { ImportedEvent } from './types';
|
import type { ImportedEvent } from './types';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
/** OCR 引擎抽象(生产用原生 PP-OCRv5 Module,测试用 mock)。 */
|
/** OCR 引擎抽象(生产用原生 PP-OCRv5 Module,测试用 mock)。 */
|
||||||
export interface OcrEngine {
|
export interface OcrEngine {
|
||||||
@ -38,8 +39,12 @@ export interface OcrProcessResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OcrProcessorConfig {
|
export interface OcrProcessorConfig {
|
||||||
/** 是否启用 Layer 3 AI(默认 false,需用户启用)。 */
|
/** 是否启用 Layer 1 规则匹配,默认 true。 */
|
||||||
aiVisionEnabled: boolean;
|
layer1Enabled: boolean;
|
||||||
|
/** 是否启用 Layer 2 OCR 识别,默认 true。 */
|
||||||
|
layer2Enabled: boolean;
|
||||||
|
/** 是否启用 Layer 3 AI Vision,默认 false。 */
|
||||||
|
layer3Enabled: boolean;
|
||||||
/** 横屏免打扰模式。 */
|
/** 横屏免打扰模式。 */
|
||||||
landscapeDnd: boolean;
|
landscapeDnd: boolean;
|
||||||
}
|
}
|
||||||
@ -66,7 +71,7 @@ export class OcrProcessor {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly ocrEngine: OcrEngine,
|
private readonly ocrEngine: OcrEngine,
|
||||||
private readonly aiProvider?: AiVisionProvider,
|
private readonly aiProvider?: AiVisionProvider,
|
||||||
private readonly config: OcrProcessorConfig = { aiVisionEnabled: false, landscapeDnd: false },
|
private readonly config: OcrProcessorConfig = { layer1Enabled: true, layer2Enabled: true, layer3Enabled: false, landscapeDnd: false },
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -92,7 +97,8 @@ export class OcrProcessor {
|
|||||||
try {
|
try {
|
||||||
const result = await this.doProcess(request.imageBase64, request.packageName, request.isLandscape);
|
const result = await this.doProcess(request.imageBase64, request.packageName, request.isLandscape);
|
||||||
request.resolve(result);
|
request.resolve(result);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
logger.error('ocrProcessor', '处理队列包含未捕获异常', e);
|
||||||
request.resolve({ event: null, layer: 'none', skipped: 'non-bill' });
|
request.resolve({ event: null, layer: 'none', skipped: 'non-bill' });
|
||||||
} finally {
|
} finally {
|
||||||
this.processing = false;
|
this.processing = false;
|
||||||
@ -105,6 +111,8 @@ export class OcrProcessor {
|
|||||||
packageName?: string,
|
packageName?: string,
|
||||||
isLandscape = false,
|
isLandscape = false,
|
||||||
): Promise<OcrProcessResult> {
|
): Promise<OcrProcessResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
// 横屏免打扰
|
// 横屏免打扰
|
||||||
if (this.config.landscapeDnd && isLandscape) {
|
if (this.config.landscapeDnd && isLandscape) {
|
||||||
return { event: null, layer: 'none', skipped: 'landscape-dnd' };
|
return { event: null, layer: 'none', skipped: 'landscape-dnd' };
|
||||||
@ -119,58 +127,91 @@ export class OcrProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Layer 2: OCR 识别(Layer 1 基于文本,需先 OCR)
|
let ocrText = '';
|
||||||
const ocrText = await this.ocrEngine.recognizeText(imageBase64);
|
|
||||||
|
|
||||||
// OCR 成功后才标记已处理,避免异常时永久阻断重试
|
// L1 和 L2 都需要 OCR 文本:如果二者都关了,跳过 OCR 调用
|
||||||
this.processedHashes.set(imageHash, Date.now());
|
if (this.config.layer1Enabled || this.config.layer2Enabled) {
|
||||||
// LRU 淘汰:超出阈值 20% 时批量清理最旧条目
|
const ocrStartTime = Date.now();
|
||||||
if (this.processedHashes.size > DEDUP_CACHE_MAX * 1.2) {
|
ocrText = await this.ocrEngine.recognizeText(imageBase64);
|
||||||
const entries = [...this.processedHashes.entries()]
|
const ocrDuration = Date.now() - ocrStartTime;
|
||||||
.sort((a, b) => a[1] - b[1]);
|
logger.debug('ocrProcessor', `Native OCR 引擎识别完成 (耗时: ${ocrDuration}ms, 字符数: ${ocrText.length})`);
|
||||||
const toDelete = entries.slice(0, entries.length - DEDUP_CACHE_MAX);
|
|
||||||
for (const [key] of toDelete) {
|
// OCR 成功后才标记已处理,避免异常时永久阻断重试
|
||||||
this.processedHashes.delete(key);
|
this.processedHashes.set(imageHash, Date.now());
|
||||||
|
// LRU 淘汰:超出阈值 20% 时批量清理最旧条目
|
||||||
|
if (this.processedHashes.size > DEDUP_CACHE_MAX * 1.2) {
|
||||||
|
const entries = [...this.processedHashes.entries()]
|
||||||
|
.sort((a, b) => a[1] - b[1]);
|
||||||
|
const toDelete = entries.slice(0, entries.length - DEDUP_CACHE_MAX);
|
||||||
|
for (const [key] of toDelete) {
|
||||||
|
this.processedHashes.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (!ocrText || !ocrText.trim()) {
|
if (!ocrText || !ocrText.trim()) {
|
||||||
// 无文本,尝试 Layer 3
|
// 无文本,尝试 Layer 3
|
||||||
return await this.tryLayer3(imageBase64, undefined);
|
if (this.config.layer3Enabled) {
|
||||||
|
return await this.tryLayer3(imageBase64, undefined);
|
||||||
|
}
|
||||||
|
return { event: null, layer: 'none', skipped: 'non-bill' };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// L1 和 L2 都关闭,仍标记 hash 避免重复处理
|
||||||
|
this.processedHashes.set(imageHash, Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 针对账单详情页进行特殊路由:若判断是结构化详情页,直接绕过宽松的 Layer 1 规则匹配,
|
// Layer 1: 规则匹配(仅当启用,且非详情页以防火误提取)
|
||||||
// 以避免"银行卡"等规则在详情页上误提取(例如误将"消费1次"提取为"-1元")。
|
if (this.config.layer1Enabled && ocrText) {
|
||||||
const isDetailPage = checkIsDetailPage(ocrText);
|
const isDetailPage = checkIsDetailPage(ocrText);
|
||||||
if (!isDetailPage) {
|
if (!isDetailPage) {
|
||||||
// Layer 1: 规则匹配(基于 OCR 文本)
|
const ruleMatch = matchOcrRule(ocrText, packageName);
|
||||||
const ruleMatch = matchOcrRule(ocrText, packageName);
|
if (ruleMatch.matched && ruleMatch.event) {
|
||||||
if (ruleMatch.matched && ruleMatch.event) {
|
const totalDuration = Date.now() - startTime;
|
||||||
return { event: ruleMatch.event, layer: 'layer1-rule', ocrText };
|
logger.info('ocrProcessor', `[Layer 1] 规则匹配成功 (总耗时: ${totalDuration}ms): 金额 ${ruleMatch.event.amount} ${ruleMatch.event.currency}, 对方: ${ruleMatch.event.counterparty || '未知'}`);
|
||||||
|
return { event: ruleMatch.event, layer: 'layer1-rule', ocrText };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layer 2: 退化解析
|
// Layer 2: OCR 解析(仅当启用)
|
||||||
const fallback = parseOcrBill(ocrText, packageName);
|
if (this.config.layer2Enabled && ocrText) {
|
||||||
if (fallback) {
|
const fallback = parseOcrBill(ocrText, packageName);
|
||||||
return { event: fallback, layer: 'layer2-ocr', ocrText };
|
if (fallback) {
|
||||||
|
const totalDuration = Date.now() - startTime;
|
||||||
|
logger.info('ocrProcessor', `[Layer 2] OCR 正则解析成功 (总耗时: ${totalDuration}ms): 金额 ${fallback.amount} ${fallback.currency}, 对方: ${fallback.counterparty || '未知'}`);
|
||||||
|
return { event: fallback, layer: 'layer2-ocr', ocrText };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 非账单内容(billGuard),尝试 Layer 3
|
// Layer 3: AI Vision(仅当启用)
|
||||||
return await this.tryLayer3(imageBase64, ocrText);
|
if (this.config.layer3Enabled) {
|
||||||
} catch {
|
return await this.tryLayer3(imageBase64, ocrText || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部未命中或全部关闭
|
||||||
|
return { event: null, layer: 'none', skipped: 'non-bill' };
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('ocrProcessor', 'doProcess 执行异常', e);
|
||||||
return { event: null, layer: 'none', skipped: 'non-bill' };
|
return { event: null, layer: 'none', skipped: 'non-bill' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryLayer3(imageBase64: string, ocrText?: string): Promise<OcrProcessResult> {
|
private async tryLayer3(imageBase64: string, ocrText?: string): Promise<OcrProcessResult> {
|
||||||
if (!this.config.aiVisionEnabled || !this.aiProvider) {
|
if (!this.config.layer3Enabled || !this.aiProvider) {
|
||||||
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
|
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
|
||||||
}
|
}
|
||||||
|
const startTime = Date.now();
|
||||||
try {
|
try {
|
||||||
const event = await this.aiProvider.recognizeBill(imageBase64);
|
const event = await this.aiProvider.recognizeBill(imageBase64);
|
||||||
if (event) return { event, layer: 'layer3-ai', ocrText };
|
const duration = Date.now() - startTime;
|
||||||
|
if (event) {
|
||||||
|
logger.info('ocrProcessor', `[Layer 3] AI Vision 识别成功 (耗时: ${duration}ms): 金额 ${event.amount} ${event.currency}, 对方: ${event.counterparty || '未知'}`);
|
||||||
|
return { event, layer: 'layer3-ai', ocrText };
|
||||||
|
}
|
||||||
|
logger.info('ocrProcessor', `[Layer 3] AI Vision 未能识别为账单 (耗时: ${duration}ms)`);
|
||||||
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
|
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
logger.error('ocrProcessor', '[Layer 3] AI Vision 调用异常', e);
|
||||||
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
|
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -186,6 +186,9 @@ export function classifyWithCategories(
|
|||||||
narration,
|
narration,
|
||||||
tags: rule?.tags,
|
tags: rule?.tags,
|
||||||
sourceEventIds: [event.id],
|
sourceEventIds: [event.id],
|
||||||
|
metadata: {
|
||||||
|
import_id: event.id,
|
||||||
|
},
|
||||||
postings: [
|
postings: [
|
||||||
{ account: sourceAccountRaw, amount: sourceAmount, currency: event.currency },
|
{ account: sourceAccountRaw, amount: sourceAmount, currency: event.currency },
|
||||||
{ account: categoryAccount, amount: categoryAmount, currency: event.currency },
|
{ account: categoryAccount, amount: categoryAmount, currency: event.currency },
|
||||||
@ -216,6 +219,9 @@ export function classifyTransferPair(left: ImportedEvent, right: ImportedEvent,
|
|||||||
date: (left.occurredAt < right.occurredAt ? left.occurredAt : right.occurredAt).slice(0, 10),
|
date: (left.occurredAt < right.occurredAt ? left.occurredAt : right.occurredAt).slice(0, 10),
|
||||||
narration: `转账:${from} → ${to}`,
|
narration: `转账:${from} → ${to}`,
|
||||||
sourceEventIds: [left.id, right.id],
|
sourceEventIds: [left.id, right.id],
|
||||||
|
metadata: {
|
||||||
|
import_id: `${left.id},${right.id}`,
|
||||||
|
},
|
||||||
postings: [
|
postings: [
|
||||||
{ account: from, amount: `-${amount}`, currency: left.currency },
|
{ account: from, amount: `-${amount}`, currency: left.currency },
|
||||||
{ account: to, amount, currency: left.currency },
|
{ account: to, amount, currency: left.currency },
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { ImportedEvent, TransactionDraft } from './types';
|
import type { ImportedEvent, TransactionDraft } from './types';
|
||||||
import { resolveSourceAccount } from './rules';
|
import { resolveSourceAccount } from './rules';
|
||||||
import { parseDecimal, formatDecimal } from './decimal';
|
import { parseDecimal, formatDecimal } from './decimal';
|
||||||
|
|
||||||
@ -143,8 +143,9 @@ function findPair(
|
|||||||
const fromAccount = resolveSourceAccount(expense);
|
const fromAccount = resolveSourceAccount(expense);
|
||||||
const toAccount = resolveSourceAccount(income);
|
const toAccount = resolveSourceAccount(income);
|
||||||
|
|
||||||
// ★ 核心校验:双方必须都是资产负债表科目
|
// ★ 核心校验:双方必须都是资产负债表科目,且不能是同一明确账户(避免已知账户自转账)
|
||||||
if (!isBalanceSheetAccount(fromAccount) || !isBalanceSheetAccount(toAccount)) continue;
|
if (!isBalanceSheetAccount(fromAccount) || !isBalanceSheetAccount(toAccount)) continue;
|
||||||
|
if (fromAccount !== 'Assets:Unknown' && fromAccount === toAccount) continue;
|
||||||
|
|
||||||
const amount = incomeAbs;
|
const amount = incomeAbs;
|
||||||
|
|
||||||
|
|||||||
134
src/i18n/en.ts
134
src/i18n/en.ts
@ -84,7 +84,7 @@ export default {
|
|||||||
notFoundDesc: 'Transaction with ID %{id} could not be found. It may have been deleted or not yet loaded.',
|
notFoundDesc: 'Transaction with ID %{id} could not be found. It may have been deleted or not yet loaded.',
|
||||||
summary: 'Summary',
|
summary: 'Summary',
|
||||||
noSummary: '(no summary)',
|
noSummary: '(no summary)',
|
||||||
postings: 'Postings',
|
postings: 'Fund Flow',
|
||||||
tags: 'Tags',
|
tags: 'Tags',
|
||||||
links: 'Links',
|
links: 'Links',
|
||||||
metadata: 'Additional Info',
|
metadata: 'Additional Info',
|
||||||
@ -132,7 +132,7 @@ export default {
|
|||||||
unlinkFail: 'Unlink failed',
|
unlinkFail: 'Unlink failed',
|
||||||
relatedTransactions: 'Related Transactions',
|
relatedTransactions: 'Related Transactions',
|
||||||
readOnlyLinks: 'Read-only transaction, links cannot be edited.',
|
readOnlyLinks: 'Read-only transaction, links cannot be edited.',
|
||||||
linkMobileTx: 'Link Mobile Transaction',
|
linkMobileTx: 'Select Transaction to Link',
|
||||||
searchTxPlaceholder: 'Search narration or account...',
|
searchTxPlaceholder: 'Search narration or account...',
|
||||||
noLinkableTx: 'No linkable mobile transactions',
|
noLinkableTx: 'No linkable mobile transactions',
|
||||||
edit: 'Edit',
|
edit: 'Edit',
|
||||||
@ -283,11 +283,15 @@ export default {
|
|||||||
fieldPaymentDay: 'Payment Day (1-28)',
|
fieldPaymentDay: 'Payment Day (1-28)',
|
||||||
fieldLimit: 'Credit Limit',
|
fieldLimit: 'Credit Limit',
|
||||||
fieldCurrency: 'Currency',
|
fieldCurrency: 'Currency',
|
||||||
fieldLinkedAccount: 'Linked Beancount Account',
|
fieldLinkedAccount: 'Linked Credit Account',
|
||||||
labelBank: 'Bank',
|
labelBank: 'Bank',
|
||||||
labelBillingDay: 'Billing Day',
|
labelBillingDay: 'Billing Day',
|
||||||
labelPaymentDay: 'Payment Day',
|
labelPaymentDay: 'Payment Day',
|
||||||
labelLimit: 'Limit',
|
labelLimit: 'Limit',
|
||||||
|
billingPeriod: 'Current Billing Period',
|
||||||
|
dueDate: 'Payment Due Date',
|
||||||
|
statementAmount: 'Statement Amount',
|
||||||
|
availableCredit: 'Available Credit',
|
||||||
daySuffix: '',
|
daySuffix: '',
|
||||||
unnamed: 'Untitled Card',
|
unnamed: 'Untitled Card',
|
||||||
empty: 'No credit cards yet. Tap above to add one.',
|
empty: 'No credit cards yet. Tap above to add one.',
|
||||||
@ -376,6 +380,16 @@ export default {
|
|||||||
start: 'Get Started',
|
start: 'Get Started',
|
||||||
enableLock: 'Enable App Lock',
|
enableLock: 'Enable App Lock',
|
||||||
lockEnabled: '✓ App Lock Enabled',
|
lockEnabled: '✓ App Lock Enabled',
|
||||||
|
permAccessibility: 'Accessibility Service',
|
||||||
|
permNotification: 'Notification Listener',
|
||||||
|
permSms: 'SMS Permission',
|
||||||
|
permStorage: 'Photos & Storage',
|
||||||
|
permOverlay: 'Display Overlay',
|
||||||
|
permGranted: 'Granted',
|
||||||
|
permNotGranted: 'Not Granted',
|
||||||
|
permOpenSettings: 'Open Settings',
|
||||||
|
permRequest: 'Request',
|
||||||
|
permAllDone: 'All Permissions Granted',
|
||||||
},
|
},
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
title: 'Read-only Compatibility & Diagnostics',
|
title: 'Read-only Compatibility & Diagnostics',
|
||||||
@ -423,7 +437,7 @@ export default {
|
|||||||
backupSuccess: 'Backup successful: %{path}',
|
backupSuccess: 'Backup successful: %{path}',
|
||||||
restoreSuccess: 'Restore successful, ledger refreshed',
|
restoreSuccess: 'Restore successful, ledger refreshed',
|
||||||
maintenanceSuccess: 'Database maintenance completed',
|
maintenanceSuccess: 'Database maintenance completed',
|
||||||
algorithmConfig: 'Auto-Classification Rules Settings',
|
algorithmConfig: 'Dedup & Transfer Recognition',
|
||||||
selectLang: 'Select language',
|
selectLang: 'Select language',
|
||||||
preferencesTitle: 'Preferences & Security',
|
preferencesTitle: 'Preferences & Security',
|
||||||
entryTitle: 'Entry',
|
entryTitle: 'Entry',
|
||||||
@ -432,10 +446,11 @@ export default {
|
|||||||
recurringTitle: 'Recurring Transactions',
|
recurringTitle: 'Recurring Transactions',
|
||||||
systemFeaturesTitle: 'Features & System',
|
systemFeaturesTitle: 'Features & System',
|
||||||
aiSettingsTitle: 'AI & Smart Entry',
|
aiSettingsTitle: 'AI & Smart Entry',
|
||||||
|
llmTitle: 'LLM Config',
|
||||||
syncSettingsTitle: 'Cloud Sync & Backup',
|
syncSettingsTitle: 'Cloud Sync & Backup',
|
||||||
aboutTitle: 'About',
|
aboutTitle: 'About',
|
||||||
aboutVersion: 'v0.1.0 (Hermes)',
|
aboutVersion: 'v0.1.0 (Hermes)',
|
||||||
enableAiHint: 'Enable AI bill classification hints',
|
enableAiHint: 'Enable AI Vision',
|
||||||
aiAssistantTitle: 'AI Bill Recognition Assistant',
|
aiAssistantTitle: 'AI Bill Recognition Assistant',
|
||||||
aiProvider: 'AI Provider',
|
aiProvider: 'AI Provider',
|
||||||
aiConfigBtn: 'Configure API connection',
|
aiConfigBtn: 'Configure API connection',
|
||||||
@ -450,9 +465,30 @@ export default {
|
|||||||
groupPreferences: 'Preferences',
|
groupPreferences: 'Preferences',
|
||||||
diagnostics: 'Parse diagnostics',
|
diagnostics: 'Parse diagnostics',
|
||||||
syncBackup: 'Sync & backup',
|
syncBackup: 'Sync & backup',
|
||||||
|
appLogs: 'App Logs',
|
||||||
preferencesEntry: 'Preferences',
|
preferencesEntry: 'Preferences',
|
||||||
import: 'Import bills',
|
import: 'Import bills',
|
||||||
},
|
},
|
||||||
|
logs: {
|
||||||
|
title: 'App Log Center',
|
||||||
|
export: 'Export Logs',
|
||||||
|
copy: 'Copy Logs',
|
||||||
|
clear: 'Clear Logs',
|
||||||
|
clearConfirmTitle: 'Confirm Clear Logs',
|
||||||
|
clearConfirmDesc: 'Are you sure you want to clear all memory and device log files? This action cannot be undone.',
|
||||||
|
clearSuccess: 'Logs cleared successfully',
|
||||||
|
exportSuccess: 'Logs exported successfully',
|
||||||
|
copySuccess: 'Logs copied to clipboard',
|
||||||
|
todayLive: 'Today (Live)',
|
||||||
|
filterLevelAll: 'All (%{count})',
|
||||||
|
searchPlaceholder: 'Search tag or message...',
|
||||||
|
empty: 'No matching log entries found',
|
||||||
|
totalEntries: '%{count} log entries',
|
||||||
|
fileSize: '%{size} KB',
|
||||||
|
expandData: 'Expand extra data',
|
||||||
|
collapseData: 'Collapse extra data',
|
||||||
|
logFileDate: 'Log file (%{date})',
|
||||||
|
},
|
||||||
numpad: {
|
numpad: {
|
||||||
from: 'From',
|
from: 'From',
|
||||||
to: 'To',
|
to: 'To',
|
||||||
@ -481,6 +517,20 @@ export default {
|
|||||||
tabLiabilities: 'Liabilities',
|
tabLiabilities: 'Liabilities',
|
||||||
tabExpenses: 'Expense Categories',
|
tabExpenses: 'Expense Categories',
|
||||||
tabIncome: 'Income Categories',
|
tabIncome: 'Income Categories',
|
||||||
|
rootTypes: {
|
||||||
|
Assets: 'Savings & Assets',
|
||||||
|
Liabilities: 'Credit & Debts',
|
||||||
|
Expenses: 'Expense Categories',
|
||||||
|
Income: 'Income Categories',
|
||||||
|
Equity: 'Equity & Opening Balances',
|
||||||
|
},
|
||||||
|
rootLabels: {
|
||||||
|
Assets: 'Assets',
|
||||||
|
Liabilities: 'Liabilities',
|
||||||
|
Expenses: 'Expenses',
|
||||||
|
Income: 'Income',
|
||||||
|
Equity: 'Equity',
|
||||||
|
},
|
||||||
addNew: 'New account',
|
addNew: 'New account',
|
||||||
fullName: 'Full path',
|
fullName: 'Full path',
|
||||||
currentBalance: 'Current balance',
|
currentBalance: 'Current balance',
|
||||||
@ -684,6 +734,80 @@ export default {
|
|||||||
bridgeUnavailable: 'Accessibility bridge unavailable (rebuild APK required)',
|
bridgeUnavailable: 'Accessibility bridge unavailable (rebuild APK required)',
|
||||||
currentApp: 'Current App',
|
currentApp: 'Current App',
|
||||||
floatingBall: 'Floating Bill Assistant',
|
floatingBall: 'Floating Bill Assistant',
|
||||||
|
billDetectedTitle: 'New Bill Detected',
|
||||||
|
billDetectedReject: 'Discard',
|
||||||
|
billDetectedEdit: 'Edit & Save',
|
||||||
|
billDetectedConfirm: 'Confirm',
|
||||||
|
billDetectedHeader: 'Parsed Bill',
|
||||||
|
billDetectedPrompt: 'Post this transaction?',
|
||||||
|
billDetectedLineDate: 'Date',
|
||||||
|
billDetectedLineMerchant: 'Merchant',
|
||||||
|
billDetectedLineAmount: 'Amount',
|
||||||
|
billDetectedLineCategory: 'Category',
|
||||||
|
billDetectedLineAccount: 'Account',
|
||||||
|
billDetectedLineNarration: 'Narration',
|
||||||
|
billDetectedNone: 'None',
|
||||||
|
otherPermissions: 'More Permissions',
|
||||||
|
notificationTitle: 'Notification Listener',
|
||||||
|
notificationEnabled: 'Enabled',
|
||||||
|
notificationDisabled: 'Disabled',
|
||||||
|
notificationOpenSettings: 'Open Settings',
|
||||||
|
smsPermissionTitle: 'SMS Permission',
|
||||||
|
smsPermGranted: 'Granted',
|
||||||
|
smsPermRequest: 'Request',
|
||||||
|
storagePermTitle: 'Storage Permission',
|
||||||
|
storagePermGranted: 'Granted',
|
||||||
|
storagePermRequest: 'Request',
|
||||||
|
overlayPermTitle: 'Display Overlay',
|
||||||
|
overlayPermGranted: 'Granted',
|
||||||
|
overlayPermNotGranted: 'Not Granted',
|
||||||
|
overlayPermOpenSettings: 'Open Settings',
|
||||||
|
autoBookkeeping: 'Auto Bookkeeping',
|
||||||
|
layer1Rule: 'Rule Matching',
|
||||||
|
layer1RuleDesc: 'Fast regex-based bill recognition',
|
||||||
|
layer2Ocr: 'OCR Recognition',
|
||||||
|
layer2OcrDesc: 'On-device PP-OCRv6 model',
|
||||||
|
layer3Ai: 'AI Vision',
|
||||||
|
layer3AiDesc: 'Cloud multimodal LLM',
|
||||||
|
layer3AiDisabled: 'LLM provider and API key not configured. Go to LLM Config?',
|
||||||
|
ocrModelTitle: 'OCR Model',
|
||||||
|
ocrModelNotInstalled: 'Not Installed',
|
||||||
|
ocrModelInstalled: 'Installed (%{version})',
|
||||||
|
ocrModelInstall: 'Install Model',
|
||||||
|
ocrModelInstalling: 'Installing...',
|
||||||
|
ocrModelCopying: 'Copying model files...',
|
||||||
|
aiProviderLabel: 'AI Provider',
|
||||||
|
aiConfigTitle: 'AI Configuration',
|
||||||
|
ocrModelDownload: 'Download Model',
|
||||||
|
ocrModelDownloading: 'Downloading... %{progress}%',
|
||||||
|
ocrModelVerifying: 'Verifying files...',
|
||||||
|
ocrModelDownloadFail: 'Download failed, check network and retry',
|
||||||
|
ocrModelRedownload: 'Redownload',
|
||||||
|
},
|
||||||
|
floating: {
|
||||||
|
billTitle: 'Adjust Draft',
|
||||||
|
dirExpense: 'Expense',
|
||||||
|
dirIncome: 'Income',
|
||||||
|
dirTransfer: 'Transfer',
|
||||||
|
amountLabel: 'Amount',
|
||||||
|
payeeLabel: 'Payee',
|
||||||
|
narrationLabel: 'Narration',
|
||||||
|
narrationHint: 'Enter narration',
|
||||||
|
categoryExpense: 'Category',
|
||||||
|
categoryIncome: 'Income Category',
|
||||||
|
transferTarget: 'To Account',
|
||||||
|
accountExpense: 'From Account',
|
||||||
|
accountIncome: 'To Account',
|
||||||
|
accountTransfer: 'From Account',
|
||||||
|
openApp: 'Open App',
|
||||||
|
dismiss: 'Dismiss',
|
||||||
|
confirm: 'Confirm',
|
||||||
|
ballOcr: 'Scan Bill',
|
||||||
|
ballRemember: 'Remember Page',
|
||||||
|
rememberSuccess: 'Page added to recognition whitelist',
|
||||||
|
rememberFail: 'Failed to save',
|
||||||
|
pageRemembered: 'Page signature saved',
|
||||||
|
pageSignatureExists: 'Page signature already exists',
|
||||||
},
|
},
|
||||||
lockScreen: {
|
lockScreen: {
|
||||||
locked: 'App Locked',
|
locked: 'App Locked',
|
||||||
|
|||||||
134
src/i18n/zh.ts
134
src/i18n/zh.ts
@ -86,7 +86,7 @@ export default {
|
|||||||
notFoundDesc: '找不到 ID 为 %{id} 的交易。可能已被删除或尚未加载。',
|
notFoundDesc: '找不到 ID 为 %{id} 的交易。可能已被删除或尚未加载。',
|
||||||
summary: '摘要',
|
summary: '摘要',
|
||||||
noSummary: '(无摘要)',
|
noSummary: '(无摘要)',
|
||||||
postings: '分录',
|
postings: '资金流向',
|
||||||
tags: '标签',
|
tags: '标签',
|
||||||
links: '链接',
|
links: '链接',
|
||||||
metadata: '附加信息',
|
metadata: '附加信息',
|
||||||
@ -134,7 +134,7 @@ export default {
|
|||||||
unlinkFail: '断开失败',
|
unlinkFail: '断开失败',
|
||||||
relatedTransactions: '已关联的其他交易',
|
relatedTransactions: '已关联的其他交易',
|
||||||
readOnlyLinks: '只读交易,不可编辑关联标签。',
|
readOnlyLinks: '只读交易,不可编辑关联标签。',
|
||||||
linkMobileTx: '关联手机端交易',
|
linkMobileTx: '选择要关联的交易',
|
||||||
searchTxPlaceholder: '搜索账单摘要或账户...',
|
searchTxPlaceholder: '搜索账单摘要或账户...',
|
||||||
noLinkableTx: '没有可供关联的手机端账单',
|
noLinkableTx: '没有可供关联的手机端账单',
|
||||||
edit: '编辑',
|
edit: '编辑',
|
||||||
@ -285,11 +285,15 @@ export default {
|
|||||||
fieldPaymentDay: '还款日 (1-28)',
|
fieldPaymentDay: '还款日 (1-28)',
|
||||||
fieldLimit: '额度',
|
fieldLimit: '额度',
|
||||||
fieldCurrency: '币种',
|
fieldCurrency: '币种',
|
||||||
fieldLinkedAccount: '关联 Beancount 账户',
|
fieldLinkedAccount: '关联信用账户',
|
||||||
labelBank: '银行',
|
labelBank: '银行',
|
||||||
labelBillingDay: '账单日',
|
labelBillingDay: '账单日',
|
||||||
labelPaymentDay: '还款日',
|
labelPaymentDay: '还款日',
|
||||||
labelLimit: '额度',
|
labelLimit: '额度',
|
||||||
|
billingPeriod: '当前账单周期',
|
||||||
|
dueDate: '到期还款日',
|
||||||
|
statementAmount: '本期应还',
|
||||||
|
availableCredit: '剩余可用额度',
|
||||||
daySuffix: '日',
|
daySuffix: '日',
|
||||||
unnamed: '未命名信用卡',
|
unnamed: '未命名信用卡',
|
||||||
empty: '暂无信用卡,点击上方添加',
|
empty: '暂无信用卡,点击上方添加',
|
||||||
@ -378,6 +382,16 @@ export default {
|
|||||||
start: '开始使用',
|
start: '开始使用',
|
||||||
enableLock: '启用应用锁',
|
enableLock: '启用应用锁',
|
||||||
lockEnabled: '✓ 启用应用锁',
|
lockEnabled: '✓ 启用应用锁',
|
||||||
|
permAccessibility: '无障碍服务',
|
||||||
|
permNotification: '通知监听',
|
||||||
|
permSms: '短信读取',
|
||||||
|
permStorage: '相册/存储',
|
||||||
|
permOverlay: '悬浮窗',
|
||||||
|
permGranted: '已授权',
|
||||||
|
permNotGranted: '未授权',
|
||||||
|
permOpenSettings: '前往设置',
|
||||||
|
permRequest: '请求权限',
|
||||||
|
permAllDone: '全部已授权',
|
||||||
},
|
},
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
title: '只读兼容与诊断',
|
title: '只读兼容与诊断',
|
||||||
@ -425,7 +439,7 @@ export default {
|
|||||||
backupSuccess: '备份成功: %{path}',
|
backupSuccess: '备份成功: %{path}',
|
||||||
restoreSuccess: '数据恢复成功,账本已刷新',
|
restoreSuccess: '数据恢复成功,账本已刷新',
|
||||||
maintenanceSuccess: '数据库优化维护完成',
|
maintenanceSuccess: '数据库优化维护完成',
|
||||||
algorithmConfig: '自动分类匹配配置',
|
algorithmConfig: '去重与转账识别',
|
||||||
selectLang: '选择系统语言',
|
selectLang: '选择系统语言',
|
||||||
preferencesTitle: '偏好与安全设置',
|
preferencesTitle: '偏好与安全设置',
|
||||||
entryTitle: '记账',
|
entryTitle: '记账',
|
||||||
@ -434,10 +448,11 @@ export default {
|
|||||||
recurringTitle: '周期性记账管理',
|
recurringTitle: '周期性记账管理',
|
||||||
systemFeaturesTitle: '功能与系统设置',
|
systemFeaturesTitle: '功能与系统设置',
|
||||||
aiSettingsTitle: '智能记账与 AI 设置',
|
aiSettingsTitle: '智能记账与 AI 设置',
|
||||||
|
llmTitle: 'LLM 配置',
|
||||||
syncSettingsTitle: '云同步与本地备份',
|
syncSettingsTitle: '云同步与本地备份',
|
||||||
aboutTitle: '关于应用',
|
aboutTitle: '关于应用',
|
||||||
aboutVersion: 'v0.1.0 (Hermes)',
|
aboutVersion: 'v0.1.0 (Hermes)',
|
||||||
enableAiHint: '启用 AI 智能账单分类提示',
|
enableAiHint: '启用 AI 视觉识别',
|
||||||
aiAssistantTitle: 'AI 智能账单识别助手',
|
aiAssistantTitle: 'AI 智能账单识别助手',
|
||||||
aiProvider: 'AI 服务商',
|
aiProvider: 'AI 服务商',
|
||||||
aiConfigBtn: '配置 API 连接信息',
|
aiConfigBtn: '配置 API 连接信息',
|
||||||
@ -452,9 +467,30 @@ export default {
|
|||||||
groupPreferences: '偏好',
|
groupPreferences: '偏好',
|
||||||
diagnostics: '解析诊断',
|
diagnostics: '解析诊断',
|
||||||
syncBackup: '同步与备份',
|
syncBackup: '同步与备份',
|
||||||
|
appLogs: '应用日志',
|
||||||
preferencesEntry: '偏好设置',
|
preferencesEntry: '偏好设置',
|
||||||
import: '导入账单',
|
import: '导入账单',
|
||||||
},
|
},
|
||||||
|
logs: {
|
||||||
|
title: '应用日志中心',
|
||||||
|
export: '导出日志',
|
||||||
|
copy: '复制日志',
|
||||||
|
clear: '清空日志',
|
||||||
|
clearConfirmTitle: '确认清空日志',
|
||||||
|
clearConfirmDesc: '确定要清空内存与手机存储中的所有日志文件吗?此操作不可撤销。',
|
||||||
|
clearSuccess: '日志已清空',
|
||||||
|
exportSuccess: '日志导出成功',
|
||||||
|
copySuccess: '日志内容已复制到剪贴板',
|
||||||
|
todayLive: '今天 (实时)',
|
||||||
|
filterLevelAll: '全部 (%{count})',
|
||||||
|
searchPlaceholder: '搜索日志 Tag 或内容...',
|
||||||
|
empty: '暂无符合条件的日志记录',
|
||||||
|
totalEntries: '共 %{count} 条日志',
|
||||||
|
fileSize: '%{size} KB',
|
||||||
|
expandData: '展开附加数据',
|
||||||
|
collapseData: '收起附加数据',
|
||||||
|
logFileDate: '日志文件 (%{date})',
|
||||||
|
},
|
||||||
numpad: {
|
numpad: {
|
||||||
from: '从',
|
from: '从',
|
||||||
to: '到',
|
to: '到',
|
||||||
@ -483,6 +519,20 @@ export default {
|
|||||||
tabLiabilities: '信用与负债',
|
tabLiabilities: '信用与负债',
|
||||||
tabExpenses: '支出分类',
|
tabExpenses: '支出分类',
|
||||||
tabIncome: '收入分类',
|
tabIncome: '收入分类',
|
||||||
|
rootTypes: {
|
||||||
|
Assets: '储蓄与资产',
|
||||||
|
Liabilities: '信用与负债',
|
||||||
|
Expenses: '支出分类',
|
||||||
|
Income: '收入分类',
|
||||||
|
Equity: '权益与平账',
|
||||||
|
},
|
||||||
|
rootLabels: {
|
||||||
|
Assets: '储蓄与资产',
|
||||||
|
Liabilities: '信用与负债',
|
||||||
|
Expenses: '支出分类',
|
||||||
|
Income: '收入分类',
|
||||||
|
Equity: '权益与平账',
|
||||||
|
},
|
||||||
addNew: '新建账户',
|
addNew: '新建账户',
|
||||||
fullName: '完整路径',
|
fullName: '完整路径',
|
||||||
currentBalance: '当前余额',
|
currentBalance: '当前余额',
|
||||||
@ -686,6 +736,80 @@ export default {
|
|||||||
bridgeUnavailable: '无障碍桥接不可用(需重新编译 APK)',
|
bridgeUnavailable: '无障碍桥接不可用(需重新编译 APK)',
|
||||||
currentApp: '当前 App',
|
currentApp: '当前 App',
|
||||||
floatingBall: '悬浮记账助手',
|
floatingBall: '悬浮记账助手',
|
||||||
|
billDetectedTitle: '识别到新账单',
|
||||||
|
billDetectedReject: '拒绝/丢弃',
|
||||||
|
billDetectedEdit: '修改并入账',
|
||||||
|
billDetectedConfirm: '确认入账',
|
||||||
|
billDetectedHeader: '账单解析',
|
||||||
|
billDetectedPrompt: '是否确认入账?',
|
||||||
|
billDetectedLineDate: '日期',
|
||||||
|
billDetectedLineMerchant: '商户',
|
||||||
|
billDetectedLineAmount: '金额',
|
||||||
|
billDetectedLineCategory: '分类',
|
||||||
|
billDetectedLineAccount: '账户',
|
||||||
|
billDetectedLineNarration: '叙述',
|
||||||
|
billDetectedNone: '无',
|
||||||
|
otherPermissions: '更多权限',
|
||||||
|
notificationTitle: '通知监听服务',
|
||||||
|
notificationEnabled: '已启用',
|
||||||
|
notificationDisabled: '未启用',
|
||||||
|
notificationOpenSettings: '前往系统设置',
|
||||||
|
smsPermissionTitle: '短信读取权限',
|
||||||
|
smsPermGranted: '已授权',
|
||||||
|
smsPermRequest: '请求权限',
|
||||||
|
storagePermTitle: '存储权限',
|
||||||
|
storagePermGranted: '已授权',
|
||||||
|
storagePermRequest: '请求权限',
|
||||||
|
overlayPermTitle: '悬浮窗权限',
|
||||||
|
overlayPermGranted: '已授权',
|
||||||
|
overlayPermNotGranted: '未授权',
|
||||||
|
overlayPermOpenSettings: '前往设置',
|
||||||
|
autoBookkeeping: '自动记账层级',
|
||||||
|
layer1Rule: '规则匹配',
|
||||||
|
layer1RuleDesc: '基于正则规则的快速账单识别',
|
||||||
|
layer2Ocr: 'OCR 识别',
|
||||||
|
layer2OcrDesc: '本地 PP-OCRv6 模型识别',
|
||||||
|
layer3Ai: 'AI 视觉识别',
|
||||||
|
layer3AiDesc: '云端多模态大模型识别',
|
||||||
|
layer3AiDisabled: 'AI 尚未配置 LLM 服务商与 Key,是否前往 LLM 配置页?',
|
||||||
|
ocrModelTitle: 'OCR 模型',
|
||||||
|
ocrModelNotInstalled: '未安装',
|
||||||
|
ocrModelInstalled: '已安装 (%{version})',
|
||||||
|
ocrModelInstall: '安装模型',
|
||||||
|
ocrModelInstalling: '安装中...',
|
||||||
|
ocrModelCopying: '正在复制模型文件...',
|
||||||
|
aiProviderLabel: 'AI 服务商',
|
||||||
|
aiConfigTitle: 'AI 配置',
|
||||||
|
ocrModelDownload: '下载模型',
|
||||||
|
ocrModelDownloading: '正在下载... %{progress}%',
|
||||||
|
ocrModelVerifying: '正在校验文件...',
|
||||||
|
ocrModelDownloadFail: '下载失败,请检查网络后重试',
|
||||||
|
ocrModelRedownload: '重新下载',
|
||||||
|
},
|
||||||
|
floating: {
|
||||||
|
billTitle: '调整交易草稿',
|
||||||
|
dirExpense: '支出',
|
||||||
|
dirIncome: '收入',
|
||||||
|
dirTransfer: '转账',
|
||||||
|
amountLabel: '金额',
|
||||||
|
payeeLabel: '交易对手',
|
||||||
|
narrationLabel: '描述/备注',
|
||||||
|
narrationHint: '输入交易叙述',
|
||||||
|
categoryExpense: '交易分类',
|
||||||
|
categoryIncome: '收入分类',
|
||||||
|
transferTarget: '转入账户',
|
||||||
|
accountExpense: '资金来源',
|
||||||
|
accountIncome: '存入账户',
|
||||||
|
accountTransfer: '转出账户',
|
||||||
|
openApp: '打开应用',
|
||||||
|
dismiss: '忽略',
|
||||||
|
confirm: '确认入账',
|
||||||
|
ballOcr: '识别账单',
|
||||||
|
ballRemember: '记住此页',
|
||||||
|
rememberSuccess: '已将当前页面加入识别白名单',
|
||||||
|
rememberFail: '记录失败',
|
||||||
|
pageRemembered: '已记住页面签名',
|
||||||
|
pageSignatureExists: '该页面签名已存在',
|
||||||
},
|
},
|
||||||
lockScreen: {
|
lockScreen: {
|
||||||
locked: '应用已锁定',
|
locked: '应用已锁定',
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
import type { AiVisionProvider } from '../domain/ocrProcessor';
|
import type { AiVisionProvider } from '../domain/ocrProcessor';
|
||||||
import type { ImportedEvent } from '../domain/types';
|
import type { ImportedEvent } from '../domain/types';
|
||||||
import { parseAiJson, sanitizeForAi } from '../domain/ai';
|
import { parseAiJson, sanitizeForAi } from '../domain/ai';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
export interface AiVisionConfig {
|
export interface AiVisionConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -54,6 +55,7 @@ export class AiVisionProcessor implements AiVisionProvider {
|
|||||||
}
|
}
|
||||||
非账单图片返回空 JSON {}。只返回 JSON。`;
|
非账单图片返回空 JSON {}。只返回 JSON。`;
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
try {
|
try {
|
||||||
const response = await this.fetchImpl(`${this.config.apiUrl}/chat/completions`, {
|
const response = await this.fetchImpl(`${this.config.apiUrl}/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -74,7 +76,13 @@ export class AiVisionProcessor implements AiVisionProvider {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) return null;
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => '无法读取 Error Body');
|
||||||
|
logger.error('AiVision', `AI Vision API 请求失败 [HTTP ${response.status}] (耗时: ${duration}ms, 模型: ${this.config.model}): ${errorText}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const data = await response.json() as { choices: { message: { content: string } }[] };
|
const data = await response.json() as { choices: { message: { content: string } }[] };
|
||||||
const content = data.choices[0]?.message?.content ?? '';
|
const content = data.choices[0]?.message?.content ?? '';
|
||||||
const parsed = parseAiJson<{
|
const parsed = parseAiJson<{
|
||||||
@ -86,9 +94,13 @@ export class AiVisionProcessor implements AiVisionProvider {
|
|||||||
time: string;
|
time: string;
|
||||||
}>(content);
|
}>(content);
|
||||||
|
|
||||||
if (!parsed || !parsed.amount) return null;
|
if (!parsed || !parsed.amount) {
|
||||||
|
logger.warn('AiVision', `AI Vision 响应解析为空或缺少金额 (耗时: ${duration}ms, 模型: ${this.config.model})`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const amount = parseFloat(parsed.amount);
|
const amount = parseFloat(parsed.amount);
|
||||||
|
logger.info('AiVision', `AI Vision 识别成功 (耗时: ${duration}ms, 模型: ${this.config.model}): 金额 ${amount} ${parsed.currency || 'CNY'}, 对方: ${parsed.counterparty || '未知'}`);
|
||||||
return {
|
return {
|
||||||
id: `ai-vision:${Date.now()}:${amount}`,
|
id: `ai-vision:${Date.now()}:${amount}`,
|
||||||
occurredAt: parsed.time || new Date().toISOString().slice(0, 16),
|
occurredAt: parsed.time || new Date().toISOString().slice(0, 16),
|
||||||
@ -99,7 +111,8 @@ export class AiVisionProcessor implements AiVisionProvider {
|
|||||||
memo: 'AI Vision 识别',
|
memo: 'AI Vision 识别',
|
||||||
raw: { source: 'ai-vision', model: this.config.model },
|
raw: { source: 'ai-vision', model: this.config.model },
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
logger.error('AiVision', `AI Vision 异常 (模型: ${this.config.model})`, e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,10 +10,18 @@
|
|||||||
* - removePageSignature(sig) → Promise<boolean>
|
* - removePageSignature(sig) → Promise<boolean>
|
||||||
* - getPaymentPackages() → Promise<string[]>
|
* - getPaymentPackages() → Promise<string[]>
|
||||||
* - getTopApp() → Promise<{ package, activity }>
|
* - getTopApp() → Promise<{ package, activity }>
|
||||||
|
* - showFloatingBill(amount, merchant, time, packageName, categories, accounts, direction, currency, draftId) → Promise<boolean>
|
||||||
|
* - setFloatingBallEnabled(enabled) → Promise<boolean>
|
||||||
|
* - setFloatingUiConfig(config) → Promise<boolean> (P6:浮层 UI 配置下发,见 floatingUiConfig.ts)
|
||||||
|
* - isNotificationListenerEnabled() → Promise<boolean> (P7:通知监听状态检测)
|
||||||
|
* - canDrawOverlays() → Promise<boolean> (P7b:悬浮窗权限检测)
|
||||||
|
* - copyOcrModelsFromAssets() → Promise<string> (P8:从 APK assets 复制 OCR 模型到 filesDir)
|
||||||
*
|
*
|
||||||
* 在非 Android / 原生模块未加载环境(测试/Web)返回 null,调用方应优雅降级。
|
* 在非 Android / 原生模块未加载环境(测试/Web)返回 null,调用方应优雅降级。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { FloatingUiConfig } from './floatingUiConfig';
|
||||||
|
|
||||||
/** 已记住的页面签名。 */
|
/** 已记住的页面签名。 */
|
||||||
export interface PageSignature {
|
export interface PageSignature {
|
||||||
/** 完整签名(pkg|activity)。 */
|
/** 完整签名(pkg|activity)。 */
|
||||||
@ -56,9 +64,14 @@ export interface NativeAccessibilityBridge {
|
|||||||
categories: Array<{ id: string; name: string; account: string; type?: string }>,
|
categories: Array<{ id: string; name: string; account: string; type?: string }>,
|
||||||
accounts: string[],
|
accounts: string[],
|
||||||
direction: string,
|
direction: string,
|
||||||
|
currency: string,
|
||||||
draftId: string
|
draftId: string
|
||||||
): Promise<boolean>;
|
): Promise<boolean>;
|
||||||
setFloatingBallEnabled(enabled: boolean): Promise<boolean>;
|
setFloatingBallEnabled(enabled: boolean): Promise<boolean>;
|
||||||
|
setFloatingUiConfig(config: FloatingUiConfig): Promise<boolean>;
|
||||||
|
isNotificationListenerEnabled(): Promise<boolean>;
|
||||||
|
canDrawOverlays(): Promise<boolean>;
|
||||||
|
copyOcrModelsFromAssets(): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -69,7 +69,9 @@ function parseWechatTexts(texts: string[], packageName: string): ImportedEvent |
|
|||||||
* 特征:包含 "创建时间" 或 "账单详情" 或 "对此订单有疑问"
|
* 特征:包含 "创建时间" 或 "账单详情" 或 "对此订单有疑问"
|
||||||
*/
|
*/
|
||||||
function parseAlipayTexts(texts: string[], packageName: string): ImportedEvent | null {
|
function parseAlipayTexts(texts: string[], packageName: string): ImportedEvent | null {
|
||||||
if (!texts.includes('创建时间') && !texts.includes('账单详情') && !texts.includes('对此订单有疑问')) {
|
const isDetailPage = texts.includes('创建时间') || texts.includes('账单详情') || texts.includes('对此订单有疑问');
|
||||||
|
if (!isDetailPage) {
|
||||||
|
logger.debug('layout', `[无障碍] 支付宝文本解析跳过:未识别为账单详情页特征`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,22 +80,30 @@ function parseAlipayTexts(texts: string[], packageName: string): ImportedEvent |
|
|||||||
let time = '';
|
let time = '';
|
||||||
let direction: 'expense' | 'income' = 'expense';
|
let direction: 'expense' | 'income' = 'expense';
|
||||||
let memo = '支付宝账单';
|
let memo = '支付宝账单';
|
||||||
const amtWithYuanPattern = /^([+-])?(\d+(\.\d+)?)元$/;
|
|
||||||
|
|
||||||
for (const text of texts) {
|
for (const text of texts) {
|
||||||
const match = text.match(amtWithYuanPattern);
|
const trimmed = text.trim();
|
||||||
if (match) {
|
// 匹配 "支出12.93元" / "收入50.00元" / "-12.93元" / "12.93元" / "12.93"
|
||||||
amountStr = match[2];
|
const match = trimmed.match(/^(?:(支出|收入|[+-]))?\s*(\d+(\.\d+)?)元?$/);
|
||||||
if (match[1] === '+') {
|
if (match && match[2]) {
|
||||||
direction = 'income';
|
const val = match[2];
|
||||||
} else if (texts.some(t => t.includes('收款') || t.includes('收入') || t.includes('收益') || t.includes('收到') || t.includes('红包'))) {
|
// 避免匹配整年或订单纯数字(如 "2026"),必须带小数点或包含支出/收入/元标志
|
||||||
direction = 'income';
|
if (val.includes('.') || trimmed.endsWith('元') || trimmed.startsWith('支出') || trimmed.startsWith('收入') || trimmed.startsWith('-') || trimmed.startsWith('+')) {
|
||||||
|
amountStr = val;
|
||||||
|
const prefix = match[1];
|
||||||
|
if (prefix === '+' || prefix === '收入') {
|
||||||
|
direction = 'income';
|
||||||
|
} else if (prefix === '-' || prefix === '支出') {
|
||||||
|
direction = 'expense';
|
||||||
|
} else if (texts.some(t => t.includes('收款') || t.includes('收入') || t.includes('收益') || t.includes('收到') || t.includes('红包'))) {
|
||||||
|
direction = 'income';
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const amtIndex = texts.findIndex(t => amtWithYuanPattern.test(t));
|
const amtIndex = texts.findIndex(t => /^(?:(支出|收入|[+-]))?\s*\d+(\.\d+)?元?$/.test(t.trim()));
|
||||||
if (amtIndex > 0) {
|
if (amtIndex > 0) {
|
||||||
merchant = texts[amtIndex - 1];
|
merchant = texts[amtIndex - 1];
|
||||||
} else {
|
} else {
|
||||||
@ -132,6 +142,8 @@ function parseAlipayTexts(texts: string[], packageName: string): ImportedEvent |
|
|||||||
raw: { packageName },
|
raw: { packageName },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('layout', `[无障碍] 支付宝详情页解析未成功原因: 金额解析无效(amountStr="${amountStr}") 或 商户为空(merchant="${merchant}") | 截获完整文本: ${JSON.stringify(texts)}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { getNativeOcrBridge } from './ocrBridge';
|
|||||||
import { AiVisionProcessor } from '../ocr/AiVisionProcessor';
|
import { AiVisionProcessor } from '../ocr/AiVisionProcessor';
|
||||||
import type { AiVisionProvider } from '../domain/ocrProcessor';
|
import type { AiVisionProvider } from '../domain/ocrProcessor';
|
||||||
import { useSettingsStore } from '../store/settingsStore';
|
import { useSettingsStore } from '../store/settingsStore';
|
||||||
|
import { useNumpadUiStore } from '../store/numpadUiStore';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { Alert, AppState, DeviceEventEmitter } from 'react-native';
|
import { Alert, AppState, DeviceEventEmitter } from 'react-native';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
@ -25,6 +26,7 @@ import { sharedPipeline } from '../domain/pipelineSingleton';
|
|||||||
import type { ImportedEvent } from '../domain/types';
|
import type { ImportedEvent } from '../domain/types';
|
||||||
import type { ProcessedDraft } from '../domain/billPipeline';
|
import type { ProcessedDraft } from '../domain/billPipeline';
|
||||||
import { getAccessibilityBridge } from './accessibilityBridge';
|
import { getAccessibilityBridge } from './accessibilityBridge';
|
||||||
|
import { t } from '../i18n';
|
||||||
|
|
||||||
// 从拆分模块导入
|
// 从拆分模块导入
|
||||||
import {
|
import {
|
||||||
@ -50,7 +52,7 @@ export type { PendingDraft } from './floatingBillManager';
|
|||||||
const BILLING_LISTENER_KEY = 'billingConfirmed';
|
const BILLING_LISTENER_KEY = 'billingConfirmed';
|
||||||
let billingListener: { remove(): void } | null = null;
|
let billingListener: { remove(): void } | null = null;
|
||||||
|
|
||||||
async function handleBillingConfirmed(res: { draftId?: string; confirmed?: boolean; amount?: string; time?: string; direction?: string; merchant?: string; narration?: string; category?: string; account?: string }) {
|
async function handleBillingConfirmed(res: { draftId?: string; confirmed?: boolean; amount?: string; time?: string; direction?: string; merchant?: string; narration?: string; category?: string; account?: string; currency?: string }) {
|
||||||
const { draftId, confirmed } = res;
|
const { draftId, confirmed } = res;
|
||||||
if (!draftId) return;
|
if (!draftId) return;
|
||||||
|
|
||||||
@ -75,12 +77,39 @@ async function handleBillingConfirmed(res: { draftId?: string; confirmed?: boole
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const draftDate = pending?.draft?.date || (res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0]);
|
const draftDate = pending?.draft?.date || (res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0]);
|
||||||
const currency = pending?.event?.currency || 'CNY';
|
const currency = res.currency || pending?.event?.currency || 'CNY';
|
||||||
const direction = (res.direction || pending?.event?.direction || 'expense') as 'expense' | 'income' | 'transfer';
|
const direction = (res.direction || pending?.event?.direction || 'expense') as 'expense' | 'income' | 'transfer';
|
||||||
const tags = pending?.draft?.tags || undefined;
|
const tags = pending?.draft?.tags || undefined;
|
||||||
const links = pending?.draft?.links || undefined;
|
const links = pending?.draft?.links || undefined;
|
||||||
const metadata = pending?.draft?.metadata || undefined;
|
const metadata = pending?.draft?.metadata || undefined;
|
||||||
|
|
||||||
|
const origPayee = pending?.draft?.payee ?? pending?.event?.counterparty ?? '';
|
||||||
|
const origCategory = pending?.draft?.postings[1]?.account ?? 'Expenses:未分类';
|
||||||
|
const origSource = pending?.draft?.postings[0]?.account ?? 'Assets:未知';
|
||||||
|
const origAmount = (pending?.draft?.postings[0]?.amount ?? pending?.event?.amount ?? '0').replace(/^-/, '');
|
||||||
|
const origDirection = pending?.event?.direction ?? 'expense';
|
||||||
|
|
||||||
|
const userPayee = res.merchant || origPayee;
|
||||||
|
const userCategory = res.category || origCategory;
|
||||||
|
const userSource = res.account || origSource;
|
||||||
|
const userAmount = res.amount || origAmount;
|
||||||
|
const userDirection = direction;
|
||||||
|
|
||||||
|
const isCategoryChanged = userCategory !== origCategory;
|
||||||
|
const isPayeeChanged = userPayee !== origPayee;
|
||||||
|
const isAmountChanged = userAmount !== origAmount;
|
||||||
|
const isSourceChanged = userSource !== origSource;
|
||||||
|
const isDirectionChanged = userDirection !== origDirection;
|
||||||
|
|
||||||
|
if (isCategoryChanged || isPayeeChanged || isAmountChanged || isSourceChanged || isDirectionChanged) {
|
||||||
|
logger.info('userCorrection', `[用户修正识别/分类] 推荐识别 vs 用户修改比对:`, {
|
||||||
|
source: pending?.event?.raw?.source ?? 'unknown',
|
||||||
|
original: { payee: origPayee, category: origCategory, sourceAccount: origSource, amount: origAmount, direction: origDirection },
|
||||||
|
corrected: { payee: userPayee, category: userCategory, sourceAccount: userSource, amount: userAmount, direction: userDirection },
|
||||||
|
changes: { categoryChanged: isCategoryChanged, payeeChanged: isPayeeChanged, amountChanged: isAmountChanged, sourceChanged: isSourceChanged, directionChanged: isDirectionChanged },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await buildAndSaveTransaction({
|
await buildAndSaveTransaction({
|
||||||
date: draftDate,
|
date: draftDate,
|
||||||
amount: res.amount ?? '0',
|
amount: res.amount ?? '0',
|
||||||
@ -94,7 +123,7 @@ async function handleBillingConfirmed(res: { draftId?: string; confirmed?: boole
|
|||||||
links,
|
links,
|
||||||
metadata,
|
metadata,
|
||||||
}, useLedgerStore.getState(), false);
|
}, useLedgerStore.getState(), false);
|
||||||
logger.info('automationPipeline', `[浮窗入账] 交易已成功在后台确认入账:${res.amount ?? '0'} CNY`);
|
logger.info('automationPipeline', `[浮窗入账] 交易已成功在后台确认入账:${res.amount ?? '0'} ${currency}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('automationPipeline', '后台浮窗入账失败', e);
|
logger.error('automationPipeline', '后台浮窗入账失败', e);
|
||||||
}
|
}
|
||||||
@ -119,11 +148,11 @@ function getOcrProcessor(): OcrProcessor | null {
|
|||||||
lastSettingsRef = settings;
|
lastSettingsRef = settings;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ocrEngine = getNativeOcrBridge();
|
// P8:从 settingsStore 读取 OCR 模型目录,传入原生引擎从 filesystem 加载模型
|
||||||
|
const ocrEngine = getNativeOcrBridge(settings.ocrModelDir || undefined);
|
||||||
// settings already obtained above for config check
|
// settings already obtained above for config check
|
||||||
|
|
||||||
let aiProvider: AiVisionProvider | undefined;
|
let aiProvider: AiVisionProvider | undefined;
|
||||||
let aiVisionEnabled = false;
|
|
||||||
if (settings.aiEnabled && settings.aiApiKey && settings.aiBaseUrl) {
|
if (settings.aiEnabled && settings.aiApiKey && settings.aiBaseUrl) {
|
||||||
aiProvider = new AiVisionProcessor({
|
aiProvider = new AiVisionProcessor({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -131,11 +160,12 @@ function getOcrProcessor(): OcrProcessor | null {
|
|||||||
apiKey: settings.aiApiKey,
|
apiKey: settings.aiApiKey,
|
||||||
model: settings.aiModel || 'glm-4v-flash',
|
model: settings.aiModel || 'glm-4v-flash',
|
||||||
});
|
});
|
||||||
aiVisionEnabled = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ocrProcessor = new OcrProcessor(ocrEngine, aiProvider, {
|
ocrProcessor = new OcrProcessor(ocrEngine, aiProvider, {
|
||||||
aiVisionEnabled,
|
layer1Enabled: settings.layer1RuleEnabled,
|
||||||
|
layer2Enabled: settings.layer2OcrEnabled,
|
||||||
|
layer3Enabled: settings.layer3AiEnabled,
|
||||||
landscapeDnd: false,
|
landscapeDnd: false,
|
||||||
});
|
});
|
||||||
return ocrProcessor;
|
return ocrProcessor;
|
||||||
@ -217,7 +247,7 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
|
|||||||
|
|
||||||
const draft = result.drafts[0];
|
const draft = result.drafts[0];
|
||||||
if (!draft) {
|
if (!draft) {
|
||||||
logger.info('automationPipeline', `[实时入账] 处理结束,可能与已有交易重复已被去重过滤。`);
|
logger.info('automationPipeline', `[实时入账] 处理结束,事件已被去重过滤 (事件ID: ${event.id}, 时间: ${event.occurredAt}, 金额: ${event.amount} ${event.currency}, 对方: "${event.counterparty || '未知'}")`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -267,6 +297,7 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
|
|||||||
sortedCategories,
|
sortedCategories,
|
||||||
sortedAccounts,
|
sortedAccounts,
|
||||||
event.direction || 'expense',
|
event.direction || 'expense',
|
||||||
|
currency,
|
||||||
draftId,
|
draftId,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -296,35 +327,34 @@ export async function handleIncomingBillEvent(source: string, event: ImportedEve
|
|||||||
|
|
||||||
// 前台模式:弹窗
|
// 前台模式:弹窗
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'🌟 识别到新账单',
|
t('automation.billDetectedTitle'),
|
||||||
`【账单解析】\n` +
|
`${t('automation.billDetectedHeader')}\n` +
|
||||||
`日期: ${draft.draft.date}\n` +
|
`${t('automation.billDetectedLineDate')}: ${draft.draft.date}\n` +
|
||||||
`商户: ${draft.draft.payee ?? '无'}\n` +
|
`${t('automation.billDetectedLineMerchant')}: ${draft.draft.payee ?? t('automation.billDetectedNone')}\n` +
|
||||||
`金额: ${amount} ${currency}\n` +
|
`${t('automation.billDetectedLineAmount')}: ${amount} ${currency}\n` +
|
||||||
`分类: ${targetAccount}\n` +
|
`${t('automation.billDetectedLineCategory')}: ${targetAccount}\n` +
|
||||||
`账户: ${sourceAccount}\n` +
|
`${t('automation.billDetectedLineAccount')}: ${sourceAccount}\n` +
|
||||||
`叙述: ${draft.draft.narration || '无'}\n\n` +
|
`${t('automation.billDetectedLineNarration')}: ${draft.draft.narration || t('automation.billDetectedNone')}\n\n` +
|
||||||
`是否确认入账?`,
|
t('automation.billDetectedPrompt'),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: '拒绝/丢弃',
|
text: t('automation.billDetectedReject'),
|
||||||
style: 'cancel',
|
style: 'cancel',
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
logger.info('automationPipeline', `[实时入账] 用户选择拒绝/丢弃交易`);
|
logger.info('automationPipeline', `[实时入账] 用户选择拒绝/丢弃交易`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: '修改并入账',
|
text: t('automation.billDetectedEdit'),
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
logger.info('automationPipeline', `[实时入账] 用户选择修改交易,跳转编辑页`);
|
logger.info('automationPipeline', `[实时入账] 用户选择修改交易,跳转编辑页`);
|
||||||
router.push({
|
useNumpadUiStore.getState().open({
|
||||||
pathname: '/transaction/new',
|
draftJson: JSON.stringify(draft.draft)
|
||||||
params: { draftJson: JSON.stringify(draft.draft) }
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: '确认入账',
|
text: t('automation.billDetectedConfirm'),
|
||||||
style: 'default',
|
style: 'default',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import { hash } from '../domain/ledger';
|
import { hash } from '../domain/ledger';
|
||||||
import type { LedgerFile } from '../domain/types';
|
import type { LedgerFile } from '../domain/types';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
/** 备份内容(序列化为 JSON 后打包)。 */
|
/** 备份内容(序列化为 JSON 后打包)。 */
|
||||||
export interface BackupBundle {
|
export interface BackupBundle {
|
||||||
@ -38,7 +39,7 @@ export function createBackupBundle(
|
|||||||
settings?: Record<string, unknown>,
|
settings?: Record<string, unknown>,
|
||||||
metadata?: Record<string, unknown>,
|
metadata?: Record<string, unknown>,
|
||||||
): BackupBundle {
|
): BackupBundle {
|
||||||
return {
|
const bundle: BackupBundle = {
|
||||||
version: 2,
|
version: 2,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
files: files.filter(f => f.path !== 'main.bean'),
|
files: files.filter(f => f.path !== 'main.bean'),
|
||||||
@ -46,6 +47,8 @@ export function createBackupBundle(
|
|||||||
settings,
|
settings,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
logger.info('backup', `创建本地备份包成功 (文件数: ${bundle.files.length + 1}, 含设置/元数据)`);
|
||||||
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 序列化为 JSON(便于打包/传输)。 */
|
/** 序列化为 JSON(便于打包/传输)。 */
|
||||||
@ -55,14 +58,21 @@ export function serializeBundle(bundle: BackupBundle): string {
|
|||||||
|
|
||||||
/** 反序列化 + 校验(兼容 v1 和 v2,以及旧版 mobileBean 字段名)。 */
|
/** 反序列化 + 校验(兼容 v1 和 v2,以及旧版 mobileBean 字段名)。 */
|
||||||
export function deserializeBundle(json: string): BackupBundle {
|
export function deserializeBundle(json: string): BackupBundle {
|
||||||
const data = JSON.parse(json) as Record<string, unknown>;
|
try {
|
||||||
if (data.version !== 1 && data.version !== 2) throw new Error(`不支持的备份版本: ${data.version}`);
|
const data = JSON.parse(json) as Record<string, unknown>;
|
||||||
if (!Array.isArray(data.files)) throw new Error('备份文件格式错误:files 非数组');
|
if (data.version !== 1 && data.version !== 2) throw new Error(`不支持的备份版本: ${data.version}`);
|
||||||
// 兼容旧版 mobileBean 字段名
|
if (!Array.isArray(data.files)) throw new Error('备份文件格式错误:files 非数组');
|
||||||
const mainBean = (typeof data.mainBean === 'string' ? data.mainBean : undefined)
|
// 兼容旧版 mobileBean 字段名
|
||||||
?? (typeof data.mobileBean === 'string' ? data.mobileBean as string : undefined);
|
const mainBean = (typeof data.mainBean === 'string' ? data.mainBean : undefined)
|
||||||
if (typeof mainBean !== 'string') throw new Error('备份文件格式错误:mainBean 非字符串');
|
?? (typeof data.mobileBean === 'string' ? data.mobileBean as string : undefined);
|
||||||
return { ...data, mainBean } as BackupBundle;
|
if (typeof mainBean !== 'string') throw new Error('备份文件格式错误:mainBean 非字符串');
|
||||||
|
const result = { ...data, mainBean } as BackupBundle;
|
||||||
|
logger.info('backup', `反序列化备份包成功 (版本: v${result.version}, 创建时间: ${result.createdAt})`);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('backup', `解析备份包 JSON 失败: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 计算备份的校验和(用于完整性校验)。 */
|
/** 计算备份的校验和(用于完整性校验)。 */
|
||||||
|
|||||||
147
src/services/floatingUiConfig.ts
Normal file
147
src/services/floatingUiConfig.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 原生浮层 UI 配置下发(docs/ui-redesign-p6-plan.md「FloatingUiConfig 契约」)。
|
||||||
|
*
|
||||||
|
* 原生 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
113
src/services/logBackend.ts
Normal 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();
|
||||||
249
src/services/modelDownloader.ts
Normal file
249
src/services/modelDownloader.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -18,6 +18,8 @@ export interface NativeOcrModule {
|
|||||||
recognizeTextBlocks(imageBase64: string): Promise<OcrTextBlock[]>;
|
recognizeTextBlocks(imageBase64: string): Promise<OcrTextBlock[]>;
|
||||||
/** OCR 引擎是否已就绪(模型加载完成)。 */
|
/** OCR 引擎是否已就绪(模型加载完成)。 */
|
||||||
isReady(): Promise<boolean>;
|
isReady(): Promise<boolean>;
|
||||||
|
/** 设置模型文件目录(绝对路径),触发从 filesystem 重新加载模型。 */
|
||||||
|
setModelDir(dir: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OcrTextBlock {
|
export interface OcrTextBlock {
|
||||||
@ -94,11 +96,17 @@ export function getNativeOcrModule(): NativeOcrModule | null {
|
|||||||
/**
|
/**
|
||||||
* 获取配置好的 OcrEngine 实例(用于 OcrProcessor)。
|
* 获取配置好的 OcrEngine 实例(用于 OcrProcessor)。
|
||||||
* RN 环境用 NativeOcrBridge 包裹原生模块;无原生模块时抛错。
|
* RN 环境用 NativeOcrBridge 包裹原生模块;无原生模块时抛错。
|
||||||
|
*
|
||||||
|
* @param modelDir 可选:模型文件目录绝对路径,传入时通知原生模块从该目录加载模型。
|
||||||
*/
|
*/
|
||||||
export function getNativeOcrBridge(): OcrEngine {
|
export function getNativeOcrBridge(modelDir?: string): OcrEngine {
|
||||||
const native = getNativeOcrModule();
|
const native = getNativeOcrModule();
|
||||||
if (!native) {
|
if (!native) {
|
||||||
throw new Error('OCR engine not available');
|
throw new Error('OCR engine not available');
|
||||||
}
|
}
|
||||||
|
// P8:设置模型目录(从 filesystem 加载 ONNX,替代 asset 内联)
|
||||||
|
if (modelDir) {
|
||||||
|
native.setModelDir(modelDir).catch(() => {});
|
||||||
|
}
|
||||||
return new NativeOcrBridge(native);
|
return new NativeOcrBridge(native);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,8 +65,9 @@ export class WebDAVClient {
|
|||||||
|
|
||||||
/** 获取远程文件信息(Last-Modified / 大小)。 */
|
/** 获取远程文件信息(Last-Modified / 大小)。 */
|
||||||
async stat(): Promise<{ lastModified: string | null; exists: boolean }> {
|
async stat(): Promise<{ lastModified: string | null; exists: boolean }> {
|
||||||
|
const url = this.buildUrl(this.config.remotePath);
|
||||||
try {
|
try {
|
||||||
const resp = await this.fetchImpl(this.buildUrl(this.config.remotePath), {
|
const resp = await this.fetchImpl(url, {
|
||||||
method: 'PROPFIND',
|
method: 'PROPFIND',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
@ -75,33 +76,43 @@ export class WebDAVClient {
|
|||||||
},
|
},
|
||||||
body: '<?xml version="1.0"?><propfind xmlns="DAV:"><prop><getlastmodified/></prop></propfind>',
|
body: '<?xml version="1.0"?><propfind xmlns="DAV:"><prop><getlastmodified/></prop></propfind>',
|
||||||
});
|
});
|
||||||
if (!resp.ok) return { lastModified: null, exists: false };
|
if (!resp.ok) {
|
||||||
|
logger.warn('webdavSync', `PROPFIND 请求未成功 [HTTP ${resp.status}], 目标路径: ${this.config.remotePath}`);
|
||||||
|
return { lastModified: null, exists: false };
|
||||||
|
}
|
||||||
const body = await resp.text();
|
const body = await resp.text();
|
||||||
const match = body.match(/<getlastmodified>([^<]+)<\/getlastmodified>/);
|
const match = body.match(/<getlastmodified>([^<]+)<\/getlastmodified>/);
|
||||||
const headerLastMod = resp.headers.get('Last-Modified');
|
const headerLastMod = resp.headers.get('Last-Modified');
|
||||||
return { lastModified: match?.[1] ?? headerLastMod, exists: true };
|
return { lastModified: match?.[1] ?? headerLastMod, exists: true };
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
logger.error('webdavSync', `PROPFIND 网络或数据异常 (URL: ${url}): ${e instanceof Error ? e.message : String(e)}`, e);
|
||||||
return { lastModified: null, exists: false };
|
return { lastModified: null, exists: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下载文件内容。 */
|
/** 下载文件内容。 */
|
||||||
async get(): Promise<string | null> {
|
async get(): Promise<string | null> {
|
||||||
|
const url = this.buildUrl(this.config.remotePath);
|
||||||
try {
|
try {
|
||||||
const resp = await this.fetchImpl(this.buildUrl(this.config.remotePath), {
|
const resp = await this.fetchImpl(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Authorization': this.authHeader },
|
headers: { 'Authorization': this.authHeader },
|
||||||
});
|
});
|
||||||
if (!resp.ok) return null;
|
if (!resp.ok) {
|
||||||
|
logger.warn('webdavSync', `GET 远程文件未成功 [HTTP ${resp.status}], 目标路径: ${this.config.remotePath}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return await resp.text();
|
return await resp.text();
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
logger.error('webdavSync', `GET 远程文件网络或数据异常 (URL: ${url}): ${e instanceof Error ? e.message : String(e)}`, e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传文件内容。 */
|
/** 上传文件内容。 */
|
||||||
async put(content: string): Promise<void> {
|
async put(content: string): Promise<void> {
|
||||||
const resp = await this.fetchImpl(this.buildUrl(this.config.remotePath), {
|
const url = this.buildUrl(this.config.remotePath);
|
||||||
|
const resp = await this.fetchImpl(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
@ -109,7 +120,10 @@ export class WebDAVClient {
|
|||||||
},
|
},
|
||||||
body: content,
|
body: content,
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(`WebDAV PUT 失败: ${resp.status}`);
|
if (!resp.ok) {
|
||||||
|
logger.error('webdavSync', `PUT 上传文件失败 [HTTP ${resp.status}], 目标路径: ${this.config.remotePath}`);
|
||||||
|
throw new Error(`WebDAV PUT 失败: ${resp.status}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 列出备份目录。 */
|
/** 列出备份目录。 */
|
||||||
@ -128,7 +142,8 @@ export class WebDAVClient {
|
|||||||
const body = await resp.text();
|
const body = await resp.text();
|
||||||
const names = [...body.matchAll(/<displayname>([^<]+)<\/displayname>/g)].map(m => m[1]);
|
const names = [...body.matchAll(/<displayname>([^<]+)<\/displayname>/g)].map(m => m[1]);
|
||||||
return names.filter(n => n.includes('backup'));
|
return names.filter(n => n.includes('backup'));
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
logger.warn('webdavSync', `列出备份目录失败 (路径: ${backupDir}): ${e}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@
|
|||||||
* 迁移采用版本号 + 迁移函数,幂等助手(IF NOT EXISTS)抗部分状态重跑。
|
* 迁移采用版本号 + 迁移函数,幂等助手(IF NOT EXISTS)抗部分状态重跑。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 2;
|
export const SCHEMA_VERSION = 2;
|
||||||
|
|
||||||
/** v1:基础缓存表。 */
|
/** v1:基础缓存表。 */
|
||||||
@ -54,19 +56,24 @@ async function setSchemaVersion(db: MigrationDb, version: number): Promise<void>
|
|||||||
export async function migrateDb(db: MigrationDb): Promise<void> {
|
export async function migrateDb(db: MigrationDb): Promise<void> {
|
||||||
const current = await getSchemaVersion(db);
|
const current = await getSchemaVersion(db);
|
||||||
if (current >= SCHEMA_VERSION) return; // 已是最新,完全跳过(幂等)
|
if (current >= SCHEMA_VERSION) return; // 已是最新,完全跳过(幂等)
|
||||||
|
|
||||||
|
logger.info('storage', `准备升级数据库 Schema: v${current} -> v${SCHEMA_VERSION}`);
|
||||||
await db.execAsync('BEGIN TRANSACTION');
|
await db.execAsync('BEGIN TRANSACTION');
|
||||||
try {
|
try {
|
||||||
for (let v = current + 1; v <= SCHEMA_VERSION; v++) {
|
for (let v = current + 1; v <= SCHEMA_VERSION; v++) {
|
||||||
const statements = MIGRATIONS[v];
|
const statements = MIGRATIONS[v];
|
||||||
if (!statements) continue;
|
if (!statements) continue;
|
||||||
for (const sql of statements) {
|
for (const sql of statements) {
|
||||||
|
logger.debug('storage', `执行迁移 SQL (v${v}): ${sql}`);
|
||||||
await db.execAsync(sql);
|
await db.execAsync(sql);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await setSchemaVersion(db, SCHEMA_VERSION);
|
await setSchemaVersion(db, SCHEMA_VERSION);
|
||||||
await db.execAsync('COMMIT');
|
await db.execAsync('COMMIT');
|
||||||
|
logger.info('storage', `数据库 Schema 成功升级到 v${SCHEMA_VERSION}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await db.execAsync('ROLLBACK');
|
await db.execAsync('ROLLBACK');
|
||||||
|
logger.error('storage', `数据库 Schema 升级失败 (v${current} -> v${SCHEMA_VERSION}),已执行 ROLLBACK`, e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import type { TransferConfig } from '../domain/transferRecognizer';
|
|||||||
import { sharedPipeline } from '../domain/pipelineSingleton';
|
import { sharedPipeline } from '../domain/pipelineSingleton';
|
||||||
import type { ProcessedDraft } from '../domain/billPipeline';
|
import type { ProcessedDraft } from '../domain/billPipeline';
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
export type AutomationSource = 'ocr' | 'notification' | 'sms' | 'screenshot' | 'manual';
|
export type AutomationSource = 'ocr' | 'notification' | 'sms' | 'screenshot' | 'manual';
|
||||||
|
|
||||||
export interface AutomationEvent {
|
export interface AutomationEvent {
|
||||||
@ -66,6 +68,7 @@ export const useAutomationStore = create<AutomationState>((set, get) => ({
|
|||||||
stats: { ocr: 0, notification: 0, sms: 0, screenshot: 0, manual: 0 },
|
stats: { ocr: 0, notification: 0, sms: 0, screenshot: 0, manual: 0 },
|
||||||
|
|
||||||
addDetected: (source, event) => {
|
addDetected: (source, event) => {
|
||||||
|
logger.info('automationStore', `[收到自动化账单事件] 来源: ${source}, ID: ${event.id}, 时间: ${event.occurredAt}, 金额: ${event.amount} ${event.currency}, 对方: "${event.counterparty || '未知'}"`);
|
||||||
set(state => ({
|
set(state => ({
|
||||||
detected: [...state.detected, {
|
detected: [...state.detected, {
|
||||||
id: `${source}:${event.id}`,
|
id: `${source}:${event.id}`,
|
||||||
@ -95,6 +98,7 @@ export const useAutomationStore = create<AutomationState>((set, get) => ({
|
|||||||
isProcessing: false,
|
isProcessing: false,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error('automationStore', '处理自动化账单事件异常', e);
|
||||||
set({ isProcessing: false, error: e instanceof Error ? e.message : String(e) });
|
set({ isProcessing: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -103,19 +107,27 @@ export const useAutomationStore = create<AutomationState>((set, get) => ({
|
|||||||
const { drafts } = get();
|
const { drafts } = get();
|
||||||
const draft = drafts[index];
|
const draft = drafts[index];
|
||||||
if (!draft) return null;
|
if (!draft) return null;
|
||||||
|
logger.info('automationStore', `[自动化草稿确认] 确认自动记账草稿 [日期: ${draft.draft.date}, 商户: "${draft.draft.payee ?? '未知'}", 叙述: "${draft.draft.narration}"]`);
|
||||||
set({ drafts: drafts.filter((_, i) => i !== index) });
|
set({ drafts: drafts.filter((_, i) => i !== index) });
|
||||||
return draft;
|
return draft;
|
||||||
},
|
},
|
||||||
|
|
||||||
rejectDraft: (index) => {
|
rejectDraft: (index) => {
|
||||||
const { drafts } = get();
|
const { drafts } = get();
|
||||||
|
const draft = drafts[index];
|
||||||
|
if (draft) {
|
||||||
|
logger.info('automationStore', `[自动化草稿拒绝] 丢弃自动记账草稿 [日期: ${draft.draft.date}, 商户: "${draft.draft.payee ?? '未知'}", 叙述: "${draft.draft.narration}"]`);
|
||||||
|
}
|
||||||
set({ drafts: drafts.filter((_, i) => i !== index) });
|
set({ drafts: drafts.filter((_, i) => i !== index) });
|
||||||
},
|
},
|
||||||
|
|
||||||
clear: () => set({
|
clear: () => {
|
||||||
detected: [],
|
logger.info('automationStore', '[自动化队列清空] 已清空自动化事件与待处理草稿');
|
||||||
drafts: [],
|
set({
|
||||||
isProcessing: false,
|
detected: [],
|
||||||
stats: { ocr: 0, notification: 0, sms: 0, screenshot: 0, manual: 0 },
|
drafts: [],
|
||||||
}),
|
isProcessing: false,
|
||||||
|
stats: { ocr: 0, notification: 0, sms: 0, screenshot: 0, manual: 0 },
|
||||||
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import type { DedupConfig } from '../domain/dedup';
|
|||||||
import type { TransferConfig } from '../domain/transferRecognizer';
|
import type { TransferConfig } from '../domain/transferRecognizer';
|
||||||
import type { StatementAdapter } from '../domain/statements';
|
import type { StatementAdapter } from '../domain/statements';
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账单导入状态管理(plan.md「0.1 状态管理」+「2.8 账单处理责任链」)。
|
* 账单导入状态管理(plan.md「0.1 状态管理」+「2.8 账单处理责任链」)。
|
||||||
*
|
*
|
||||||
@ -72,9 +74,11 @@ export const useImportStore = create<ImportState>((set, get) => ({
|
|||||||
importCsv: (content, adapter) => {
|
importCsv: (content, adapter) => {
|
||||||
try {
|
try {
|
||||||
const events = importStatement(content, adapter);
|
const events = importStatement(content, adapter);
|
||||||
|
logger.info('import', `导入 CSV 账单成功, 解析得到 ${events.length} 笔原始事件`);
|
||||||
set({ events, error: null });
|
set({ events, error: null });
|
||||||
return events;
|
return events;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error('import', '解析 CSV 账单代码或格式失败', e);
|
||||||
set({ error: e instanceof Error ? e.message : String(e) });
|
set({ error: e instanceof Error ? e.message : String(e) });
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@ -105,6 +109,7 @@ export const useImportStore = create<ImportState>((set, get) => ({
|
|||||||
if (!draft) return null;
|
if (!draft) return null;
|
||||||
// 用 draft 引用而非索引过滤,避免竞态
|
// 用 draft 引用而非索引过滤,避免竞态
|
||||||
const next = pendingDrafts.filter((d) => d !== draft);
|
const next = pendingDrafts.filter((d) => d !== draft);
|
||||||
|
logger.info('import', `[手动确认草稿] 确认导入草稿 [日期: ${draft.draft.date}, 商户: "${draft.draft.payee ?? '未知'}", 叙述: "${draft.draft.narration}"]`);
|
||||||
set({ pendingDrafts: next });
|
set({ pendingDrafts: next });
|
||||||
return draft;
|
return draft;
|
||||||
},
|
},
|
||||||
@ -113,13 +118,21 @@ export const useImportStore = create<ImportState>((set, get) => ({
|
|||||||
const { pendingDrafts } = get();
|
const { pendingDrafts } = get();
|
||||||
// 使用引用过滤而非索引过滤,避免并发覆盖
|
// 使用引用过滤而非索引过滤,避免并发覆盖
|
||||||
const toRemove = new Set(indices.map(i => pendingDrafts[i]));
|
const toRemove = new Set(indices.map(i => pendingDrafts[i]));
|
||||||
|
logger.info('import', `[批量确认草稿] 批量确认 ${toRemove.size} 笔草稿入账`);
|
||||||
set({ pendingDrafts: pendingDrafts.filter(d => !toRemove.has(d)) });
|
set({ pendingDrafts: pendingDrafts.filter(d => !toRemove.has(d)) });
|
||||||
},
|
},
|
||||||
|
|
||||||
rejectDraft: (index) => {
|
rejectDraft: (index) => {
|
||||||
const { pendingDrafts } = get();
|
const { pendingDrafts } = get();
|
||||||
|
const draft = pendingDrafts[index];
|
||||||
|
if (draft) {
|
||||||
|
logger.info('import', `[手动忽略草稿] 丢弃草稿 [日期: ${draft.draft.date}, 商户: "${draft.draft.payee ?? '未知'}", 叙述: "${draft.draft.narration}"]`);
|
||||||
|
}
|
||||||
set({ pendingDrafts: pendingDrafts.filter((_, i) => i !== index) });
|
set({ pendingDrafts: pendingDrafts.filter((_, i) => i !== index) });
|
||||||
},
|
},
|
||||||
|
|
||||||
clear: () => set({ events: [], pendingDrafts: [], lastResult: null, error: null }),
|
clear: () => {
|
||||||
|
logger.info('import', '[清空导入缓存] 已清空当前导入队列与草稿列表');
|
||||||
|
set({ events: [], pendingDrafts: [], lastResult: null, error: null });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -166,6 +166,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
|
|||||||
return withLock(async () => {
|
return withLock(async () => {
|
||||||
const { mobileStore } = get();
|
const { mobileStore } = get();
|
||||||
if (!mobileStore) throw new Error('账本未加载');
|
if (!mobileStore) throw new Error('账本未加载');
|
||||||
|
logger.info('ledgerStore', `正在全量替换 mobile.bean 内容 (新长度: ${content.length} 字节)`);
|
||||||
await mobileStore.replace(content);
|
await mobileStore.replace(content);
|
||||||
const ledger = reparseLedger(content);
|
const ledger = reparseLedger(content);
|
||||||
if (get().mobileStore !== mobileStore) return;
|
if (get().mobileStore !== mobileStore) return;
|
||||||
@ -181,6 +182,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
|
|||||||
// 去重:跳过已 open 的账户
|
// 去重:跳过已 open 的账户
|
||||||
const newAccounts = accounts.filter(acc => !ledger?.accounts.has(acc));
|
const newAccounts = accounts.filter(acc => !ledger?.accounts.has(acc));
|
||||||
if (newAccounts.length === 0) return;
|
if (newAccounts.length === 0) return;
|
||||||
|
logger.info('ledgerStore', `自动开户 ${newAccounts.length} 个账户: ${newAccounts.join(', ')}`);
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const currency = getDefaultCurrency(ledger);
|
const currency = getDefaultCurrency(ledger);
|
||||||
const openStatements = newAccounts.map(acc => `${today} open ${acc} ${currency}`).join('\n');
|
const openStatements = newAccounts.map(acc => `${today} open ${acc} ${currency}`).join('\n');
|
||||||
@ -195,6 +197,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
|
|||||||
return withLock(async () => {
|
return withLock(async () => {
|
||||||
const { mobileStore } = get();
|
const { mobileStore } = get();
|
||||||
if (!mobileStore) throw new Error('账本未加载');
|
if (!mobileStore) throw new Error('账本未加载');
|
||||||
|
logger.info('ledgerStore', `自动销户账户: ${accountName}`);
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
await mobileStore.modifyContent(current => `${current.trimEnd()}\n\n${today} close ${accountName}\n`.trim());
|
await mobileStore.modifyContent(current => `${current.trimEnd()}\n\n${today} close ${accountName}\n`.trim());
|
||||||
const newLedger = reparseLedger(mobileStore.getContent());
|
const newLedger = reparseLedger(mobileStore.getContent());
|
||||||
@ -209,6 +212,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
|
|||||||
if (!mobileStore) throw new Error('账本未加载');
|
if (!mobileStore) throw new Error('账本未加载');
|
||||||
const ledger = get().ledger;
|
const ledger = get().ledger;
|
||||||
const currency = getDefaultCurrency(ledger);
|
const currency = getDefaultCurrency(ledger);
|
||||||
|
logger.info('ledgerStore', `校准账户余额: ${accountName} -> ${balance} ${currency} (日期: ${date})`);
|
||||||
// 计算前一天的日期用于 pad(使用本地时间避免 UTC 偏移)
|
// 计算前一天的日期用于 pad(使用本地时间避免 UTC 偏移)
|
||||||
const [y, m, d] = date.split('-').map(Number);
|
const [y, m, d] = date.split('-').map(Number);
|
||||||
const padDate = toDateString(new Date(y, m - 1, d - 1));
|
const padDate = toDateString(new Date(y, m - 1, d - 1));
|
||||||
@ -224,6 +228,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
|
|||||||
return withLock(async () => {
|
return withLock(async () => {
|
||||||
const { mobileStore } = get();
|
const { mobileStore } = get();
|
||||||
if (!mobileStore) throw new Error('账本未加载');
|
if (!mobileStore) throw new Error('账本未加载');
|
||||||
|
logger.info('ledgerStore', `批量追加 ${drafts.length} 笔交易到 mobile.bean`);
|
||||||
const mobileBean = get().mobileBean; // 锁内读取最新值
|
const mobileBean = get().mobileBean; // 锁内读取最新值
|
||||||
const serializedChunk = drafts.map(d => serializeTransaction(d)).join('\n\n');
|
const serializedChunk = drafts.map(d => serializeTransaction(d)).join('\n\n');
|
||||||
const newContent = `${mobileBean.trimEnd()}\n\n${serializedChunk}\n`.trim();
|
const newContent = `${mobileBean.trimEnd()}\n\n${serializedChunk}\n`.trim();
|
||||||
@ -244,6 +249,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
|
|||||||
const newContent = mobileBean.slice(0, idx) + newRaw + mobileBean.slice(idx + oldRaw.length);
|
const newContent = mobileBean.slice(0, idx) + newRaw + mobileBean.slice(idx + oldRaw.length);
|
||||||
await mobileStore.replace(newContent);
|
await mobileStore.replace(newContent);
|
||||||
const newLedger = reparseLedger(newContent);
|
const newLedger = reparseLedger(newContent);
|
||||||
|
logger.info('ledgerStore', `修改已保存交易成功:\n【修改前】:\n${oldRaw.trim()}\n【修改后】:\n${newRaw.trim()}`);
|
||||||
if (get().mobileStore !== mobileStore) return '';
|
if (get().mobileStore !== mobileStore) return '';
|
||||||
set({ mobileBean: newContent, ledger: newLedger });
|
set({ mobileBean: newContent, ledger: newLedger });
|
||||||
const newTx = newLedger.transactions.find(t => t.raw === newRaw);
|
const newTx = newLedger.transactions.find(t => t.raw === newRaw);
|
||||||
@ -262,6 +268,7 @@ export const useLedgerStore = create<LedgerState>((set, get) => ({
|
|||||||
newContent = newContent.replace(/\n{3,}/g, '\n\n').trim();
|
newContent = newContent.replace(/\n{3,}/g, '\n\n').trim();
|
||||||
await mobileStore.replace(newContent);
|
await mobileStore.replace(newContent);
|
||||||
const newLedger = reparseLedger(newContent);
|
const newLedger = reparseLedger(newContent);
|
||||||
|
logger.info('ledgerStore', `删除已保存交易成功:\n【被删除交易内容】:\n${raw.trim()}`);
|
||||||
if (get().mobileStore !== mobileStore) return;
|
if (get().mobileStore !== mobileStore) return;
|
||||||
set({ mobileBean: newContent, ledger: newLedger });
|
set({ mobileBean: newContent, ledger: newLedger });
|
||||||
});
|
});
|
||||||
|
|||||||
@ -106,7 +106,7 @@ const DEFAULTS: PersistableMetadata = {
|
|||||||
{ id: 'b2', name: '交通月预算', amount: '500', period: 'monthly', categoryAccount: 'Expenses:交通', startDate: '2026-01-01' },
|
{ id: 'b2', name: '交通月预算', amount: '500', period: 'monthly', categoryAccount: 'Expenses:交通', startDate: '2026-01-01' },
|
||||||
],
|
],
|
||||||
creditCards: [
|
creditCards: [
|
||||||
{ id: 'c1', name: '示例信用卡', lastFour: '0000', bankName: 'Bank', billingDay: 5, paymentDay: 25, creditLimit: '10000', currency: 'CNY', linkedAccount: 'Liabilities:信用卡' },
|
{ id: 'cc1', name: '招商银行信用卡', lastFour: '1589', bankName: '招商银行', billingDay: 5, paymentDay: 23, creditLimit: '50000', currency: 'CNY', linkedAccount: 'Liabilities:CreditCard:CMB' },
|
||||||
],
|
],
|
||||||
rules: [
|
rules: [
|
||||||
// 通用匹配规则示例
|
// 通用匹配规则示例
|
||||||
@ -141,10 +141,8 @@ export function generateId(prefix = 'id'): string {
|
|||||||
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
import { logger } from '../utils/logger';
|
||||||
* CRUD 工厂:为指定 key 生成 add/update/remove 三个 action。
|
|
||||||
* 消除 7 个实体的重复模式。
|
|
||||||
*/
|
|
||||||
function createCrudActions<T extends { id: string }>(
|
function createCrudActions<T extends { id: string }>(
|
||||||
key: keyof PersistableMetadata,
|
key: keyof PersistableMetadata,
|
||||||
set: (fn: (s: MetadataState) => Partial<MetadataState>) => void,
|
set: (fn: (s: MetadataState) => Partial<MetadataState>) => void,
|
||||||
@ -153,18 +151,22 @@ function createCrudActions<T extends { id: string }>(
|
|||||||
return {
|
return {
|
||||||
add: (item: T) => {
|
add: (item: T) => {
|
||||||
set((s) => ({ [key]: [...((s[key] as unknown as T[])), item] }) as Partial<MetadataState>);
|
set((s) => ({ [key]: [...((s[key] as unknown as T[])), item] }) as Partial<MetadataState>);
|
||||||
|
const name = (item as Record<string, unknown>).name || (item as Record<string, unknown>).narration || item.id;
|
||||||
|
logger.info('metadataStore', `添加元数据实体 [${key}]: ${name} (ID: ${item.id})`, item);
|
||||||
get().persistTo?.(toPersistable(get()));
|
get().persistTo?.(toPersistable(get()));
|
||||||
},
|
},
|
||||||
update: (id: string, patch: Partial<T>) => {
|
update: (id: string, patch: Partial<T>) => {
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
[key]: (s[key] as unknown as T[]).map(item => item.id === id ? { ...item, ...patch } : item),
|
[key]: (s[key] as unknown as T[]).map(item => item.id === id ? { ...item, ...patch } : item),
|
||||||
}) as Partial<MetadataState>);
|
}) as Partial<MetadataState>);
|
||||||
|
logger.info('metadataStore', `更新元数据实体 [${key}] (ID: ${id})`, patch);
|
||||||
get().persistTo?.(toPersistable(get()));
|
get().persistTo?.(toPersistable(get()));
|
||||||
},
|
},
|
||||||
remove: (id: string) => {
|
remove: (id: string) => {
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
[key]: (s[key] as unknown as T[]).filter(item => item.id !== id),
|
[key]: (s[key] as unknown as T[]).filter(item => item.id !== id),
|
||||||
}) as Partial<MetadataState>);
|
}) as Partial<MetadataState>);
|
||||||
|
logger.info('metadataStore', `删除元数据实体 [${key}] (ID: ${id})`);
|
||||||
get().persistTo?.(toPersistable(get()));
|
get().persistTo?.(toPersistable(get()));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -30,6 +30,11 @@ export interface SettingsState {
|
|||||||
/** 引导是否完成。 */
|
/** 引导是否完成。 */
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
setOnboardingCompleted: (v: boolean) => void;
|
setOnboardingCompleted: (v: boolean) => void;
|
||||||
|
setLayer1RuleEnabled: (enabled: boolean) => void;
|
||||||
|
setLayer2OcrEnabled: (enabled: boolean) => void;
|
||||||
|
setLayer3AiEnabled: (enabled: boolean) => void;
|
||||||
|
setOcrModelVersion: (version: string) => void;
|
||||||
|
setOcrModelDir: (dir: string) => void;
|
||||||
|
|
||||||
// Sync Configurations
|
// Sync Configurations
|
||||||
webdavUrl?: string;
|
webdavUrl?: string;
|
||||||
@ -57,6 +62,18 @@ export interface SettingsState {
|
|||||||
floatingBallEnabled: boolean;
|
floatingBallEnabled: boolean;
|
||||||
setFloatingBallEnabled: (enabled: boolean) => void;
|
setFloatingBallEnabled: (enabled: boolean) => void;
|
||||||
|
|
||||||
|
// 自动记账层级
|
||||||
|
/** 自动记账 L1:正则规则匹配。 */
|
||||||
|
layer1RuleEnabled: boolean;
|
||||||
|
/** 自动记账 L2:本地 OCR 识别。 */
|
||||||
|
layer2OcrEnabled: boolean;
|
||||||
|
/** 自动记账 L3:云端 AI Vision。 */
|
||||||
|
layer3AiEnabled: boolean;
|
||||||
|
/** OCR 模型版本(已下载),空字符串表示未安装。 */
|
||||||
|
ocrModelVersion: string;
|
||||||
|
/** OCR 模型文件目录的绝对路径。 */
|
||||||
|
ocrModelDir: string;
|
||||||
|
|
||||||
// 记账面板
|
// 记账面板
|
||||||
/** +按钮是否打开全局记账面板(P5 起默认 true;已持久化 false 的用户保持关闭)。 */
|
/** +按钮是否打开全局记账面板(P5 起默认 true;已持久化 false 的用户保持关闭)。 */
|
||||||
numpadGlobalEntry: boolean;
|
numpadGlobalEntry: boolean;
|
||||||
@ -117,6 +134,11 @@ export interface PersistableSettings {
|
|||||||
numpadGlobalEntry: boolean;
|
numpadGlobalEntry: boolean;
|
||||||
lastUsedAccount: string | null;
|
lastUsedAccount: string | null;
|
||||||
lastUsedCategoryId: string | null;
|
lastUsedCategoryId: string | null;
|
||||||
|
layer1RuleEnabled: boolean;
|
||||||
|
layer2OcrEnabled: boolean;
|
||||||
|
layer3AiEnabled: boolean;
|
||||||
|
ocrModelVersion: string;
|
||||||
|
ocrModelDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -157,6 +179,11 @@ const DEFAULTS: PersistableSettings & { skipPersist: boolean; floatingBallEnable
|
|||||||
numpadGlobalEntry: true,
|
numpadGlobalEntry: true,
|
||||||
lastUsedAccount: null,
|
lastUsedAccount: null,
|
||||||
lastUsedCategoryId: null,
|
lastUsedCategoryId: null,
|
||||||
|
layer1RuleEnabled: true,
|
||||||
|
layer2OcrEnabled: true,
|
||||||
|
layer3AiEnabled: false,
|
||||||
|
ocrModelVersion: '',
|
||||||
|
ocrModelDir: '',
|
||||||
skipPersist: false,
|
skipPersist: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -190,40 +217,101 @@ export function toPersistable(state: SettingsState): PersistableSettings {
|
|||||||
numpadGlobalEntry: state.numpadGlobalEntry,
|
numpadGlobalEntry: state.numpadGlobalEntry,
|
||||||
lastUsedAccount: state.lastUsedAccount,
|
lastUsedAccount: state.lastUsedAccount,
|
||||||
lastUsedCategoryId: state.lastUsedCategoryId,
|
lastUsedCategoryId: state.lastUsedCategoryId,
|
||||||
|
layer1RuleEnabled: state.layer1RuleEnabled,
|
||||||
|
layer2OcrEnabled: state.layer2OcrEnabled,
|
||||||
|
layer3AiEnabled: state.layer3AiEnabled,
|
||||||
|
ocrModelVersion: state.ocrModelVersion,
|
||||||
|
ocrModelDir: state.ocrModelDir,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
export const useSettingsStore = create<SettingsState>((set) => ({
|
export const useSettingsStore = create<SettingsState>((set) => ({
|
||||||
...DEFAULTS,
|
...DEFAULTS,
|
||||||
|
|
||||||
updateDedupConfig: (patch) => set((s) => ({ dedupConfig: { ...s.dedupConfig, ...patch } })),
|
updateDedupConfig: (patch) => {
|
||||||
|
logger.info('settings', '更新去重配置', patch);
|
||||||
|
set((s) => ({ dedupConfig: { ...s.dedupConfig, ...patch } }));
|
||||||
|
},
|
||||||
|
|
||||||
updateTransferConfig: (patch) => set((s) => ({ transferConfig: { ...s.transferConfig, ...patch } })),
|
updateTransferConfig: (patch) => {
|
||||||
|
logger.info('settings', '更新转账识别配置', patch);
|
||||||
|
set((s) => ({ transferConfig: { ...s.transferConfig, ...patch } }));
|
||||||
|
},
|
||||||
|
|
||||||
setDedupEnabled: (enabled) => set({ dedupEnabled: enabled }),
|
setDedupEnabled: (enabled) => {
|
||||||
|
logger.info('settings', `设置自动去重功能状态: ${enabled ? '开启' : '关闭'}`);
|
||||||
|
set({ dedupEnabled: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
setTransferRecognitionEnabled: (enabled) => set({ transferRecognitionEnabled: enabled }),
|
setTransferRecognitionEnabled: (enabled) => {
|
||||||
|
logger.info('settings', `设置转账识别功能状态: ${enabled ? '开启' : '关闭'}`);
|
||||||
|
set({ transferRecognitionEnabled: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
setThemeMode: (mode) => set({ themeMode: mode }),
|
setThemeMode: (mode) => {
|
||||||
|
logger.info('settings', `切换主题模式: ${mode}`);
|
||||||
|
set({ themeMode: mode });
|
||||||
|
},
|
||||||
|
|
||||||
setLocale: (locale) => set({ locale }),
|
setLocale: (locale) => {
|
||||||
|
logger.info('settings', `切换应用语言: ${locale}`);
|
||||||
|
set({ locale });
|
||||||
|
},
|
||||||
|
|
||||||
setAppLockEnabled: (enabled) => set({ appLockEnabled: enabled }),
|
setAppLockEnabled: (enabled) => {
|
||||||
|
logger.info('settings', `设置应用锁安全防护: ${enabled ? '开启' : '关闭'}`);
|
||||||
|
set({ appLockEnabled: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
updateSyncConfig: (patch) => set((s) => ({ ...s, ...patch })),
|
updateSyncConfig: (patch) => {
|
||||||
|
const safePatch = { ...patch };
|
||||||
|
if (safePatch.webdavPassword) safePatch.webdavPassword = '***';
|
||||||
|
if (safePatch.gitPassword) safePatch.gitPassword = '***';
|
||||||
|
logger.info('settings', '更新云同步配置', safePatch);
|
||||||
|
set((s) => ({ ...s, ...patch }));
|
||||||
|
},
|
||||||
|
|
||||||
updateAiConfig: (patch) => set((s) => ({ ...s, ...patch })),
|
updateAiConfig: (patch) => {
|
||||||
|
const safePatch = { ...patch };
|
||||||
|
if (safePatch.aiApiKey) safePatch.aiApiKey = '***';
|
||||||
|
logger.info('settings', '更新 AI 智记配置', safePatch);
|
||||||
|
set((s) => ({ ...s, ...patch }));
|
||||||
|
},
|
||||||
|
|
||||||
updateReminderConfig: (patch) => set((s) => ({ ...s, ...patch })),
|
updateReminderConfig: (patch) => {
|
||||||
|
logger.info('settings', '更新记账提醒配置', patch);
|
||||||
|
set((s) => ({ ...s, ...patch }));
|
||||||
|
},
|
||||||
|
|
||||||
setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }),
|
setOnboardingCompleted: (completed) => {
|
||||||
|
logger.info('settings', `设置新手引导完成状态: ${completed}`);
|
||||||
|
set({ onboardingCompleted: completed });
|
||||||
|
},
|
||||||
|
|
||||||
setFloatingBallEnabled: (enabled) => set({ floatingBallEnabled: enabled }),
|
setFloatingBallEnabled: (enabled) => {
|
||||||
|
logger.info('settings', `设置桌面悬浮球: ${enabled ? '开启' : '关闭'}`);
|
||||||
|
set({ floatingBallEnabled: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
setNumpadGlobalEntry: (enabled) => set({ numpadGlobalEntry: enabled }),
|
setNumpadGlobalEntry: (enabled) => {
|
||||||
|
logger.info('settings', `设置全局记账小键盘: ${enabled ? '开启' : '关闭'}`);
|
||||||
|
set({ numpadGlobalEntry: enabled });
|
||||||
|
},
|
||||||
|
|
||||||
setLastUsedEntry: (account, categoryId) => set({ lastUsedAccount: account, lastUsedCategoryId: categoryId }),
|
setLastUsedEntry: (account, categoryId) => set({ lastUsedAccount: account, lastUsedCategoryId: categoryId }),
|
||||||
|
|
||||||
|
setLayer1RuleEnabled: (enabled) => set({ layer1RuleEnabled: enabled }),
|
||||||
|
|
||||||
|
setLayer2OcrEnabled: (enabled) => set({ layer2OcrEnabled: enabled }),
|
||||||
|
|
||||||
|
setLayer3AiEnabled: (enabled) => set({ layer3AiEnabled: enabled }),
|
||||||
|
|
||||||
|
setOcrModelVersion: (version) => set({ ocrModelVersion: version }),
|
||||||
|
|
||||||
|
setOcrModelDir: (dir) => set({ ocrModelDir: dir }),
|
||||||
|
|
||||||
hydrate: (data) => {
|
hydrate: (data) => {
|
||||||
// hydrate 期间跳过自动持久化
|
// hydrate 期间跳过自动持久化
|
||||||
// 使用单次 setState 合并所有数据,避免多次触发 subscriber
|
// 使用单次 setState 合并所有数据,避免多次触发 subscriber
|
||||||
|
|||||||
@ -13,5 +13,6 @@ export function createTheme(overrides: Partial<ThemeTokens>): ThemeTokens {
|
|||||||
radii: { ...lightTheme.radii, ...overrides.radii },
|
radii: { ...lightTheme.radii, ...overrides.radii },
|
||||||
typography: { ...lightTheme.typography, ...overrides.typography },
|
typography: { ...lightTheme.typography, ...overrides.typography },
|
||||||
shadows: { ...lightTheme.shadows, ...overrides.shadows },
|
shadows: { ...lightTheme.shadows, ...overrides.shadows },
|
||||||
|
opacities: { ...lightTheme.opacities, ...overrides.opacities },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,10 @@ export const lightTheme: ThemeTokens = {
|
|||||||
md: { shadowColor: '#111318', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 8, elevation: 2 },
|
md: { shadowColor: '#111318', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 8, elevation: 2 },
|
||||||
lg: { shadowColor: '#111318', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.10, shadowRadius: 12, elevation: 4 },
|
lg: { shadowColor: '#111318', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.10, shadowRadius: 12, elevation: 4 },
|
||||||
},
|
},
|
||||||
|
opacities: {
|
||||||
|
pressed: 0.7,
|
||||||
|
disabled: 0.4,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const darkTheme: ThemeTokens = {
|
export const darkTheme: ThemeTokens = {
|
||||||
|
|||||||
@ -97,10 +97,16 @@ export interface ThemeShadows {
|
|||||||
lg: ThemeShadow;
|
lg: ThemeShadow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ThemeOpacities {
|
||||||
|
pressed: number; // 统一点击按压透明度 (0.7)
|
||||||
|
disabled: number; // 禁用透明度 (0.4)
|
||||||
|
}
|
||||||
|
|
||||||
export interface ThemeTokens {
|
export interface ThemeTokens {
|
||||||
colors: ThemeColors;
|
colors: ThemeColors;
|
||||||
spacing: ThemeSpacing;
|
spacing: ThemeSpacing;
|
||||||
radii: ThemeRadii;
|
radii: ThemeRadii;
|
||||||
typography: ThemeTypography;
|
typography: ThemeTypography;
|
||||||
shadows: ThemeShadows;
|
shadows: ThemeShadows;
|
||||||
|
opacities: ThemeOpacities;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 内存缓冲日志系统(plan.md「0.6 日志系统」)。
|
* 带有磁盘持久化与可配置后端的日志系统(plan.md「0.6 日志系统」)。
|
||||||
*
|
*
|
||||||
* 参考 AutoAccounting 的 Logger + BeeCount 的 Log Center:
|
* - 4 级日志(DEBUG/INFO/WARN/ERROR)
|
||||||
* - 4 级日志(debug/info/warn/error)
|
* - 循环内存缓冲(默认 1000 条),供实时 UI 显示
|
||||||
* - 循环缓冲(默认最多 1000 条),避免无限增长
|
* - 支持扩展 LogFileBackend 接口,持久化到手机本地磁盘(按日期保存日志文件)
|
||||||
* - 生产环境可对接 Sentry(plan.md Phase 9)
|
* - 支持日志筛选、历史日志加载、日志文件过期清理(默认保留 7 天)与文本导出
|
||||||
* - 提供 getLogs/clearLogs 供「日志中心」UI 导出
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export enum LogLevel {
|
export enum LogLevel {
|
||||||
@ -16,14 +15,30 @@ export enum LogLevel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
timestamp: string; // ISO
|
timestamp: string; // ISO 时间戳
|
||||||
level: LogLevel;
|
level: LogLevel;
|
||||||
tag: string; // 模块标识(如 'mobileStore'、'billPipeline')
|
tag: string; // 模块标识(如 'mobileStore'、'billPipeline')
|
||||||
message: string;
|
message: string;
|
||||||
data?: unknown;
|
data?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
class LoggerImpl {
|
export interface LogFileInfo {
|
||||||
|
name: string; // 文件名,例如 'app-2026-07-22.log'
|
||||||
|
path: string; // 文件完整路径/URI
|
||||||
|
size: number; // 字节大小
|
||||||
|
date: string; // 日期字符串 YYYY-MM-DD
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 磁盘日志持久化后端抽象(解耦 React Native / Expo 文件系统)。 */
|
||||||
|
export interface LogFileBackend {
|
||||||
|
appendLog(dateStr: string, logLine: string): Promise<void>;
|
||||||
|
readLogFile(dateStr: string): Promise<string>;
|
||||||
|
listLogFiles(): Promise<LogFileInfo[]>;
|
||||||
|
deleteLogFile(dateStr: string): Promise<void>;
|
||||||
|
clearAllLogFiles(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LoggerImpl {
|
||||||
private buffer: LogEntry[] = [];
|
private buffer: LogEntry[] = [];
|
||||||
private maxBuffer: number;
|
private maxBuffer: number;
|
||||||
/** 最低输出级别(默认 DEBUG,生产环境可调高)。 */
|
/** 最低输出级别(默认 DEBUG,生产环境可调高)。 */
|
||||||
@ -31,10 +46,23 @@ class LoggerImpl {
|
|||||||
/** 是否输出到 console(开发环境 true)。 */
|
/** 是否输出到 console(开发环境 true)。 */
|
||||||
consoleOutput: boolean = true;
|
consoleOutput: boolean = true;
|
||||||
|
|
||||||
|
private fileBackend: LogFileBackend | null = null;
|
||||||
|
private pendingWriteQueue: { dateStr: string; line: string }[] = [];
|
||||||
|
private isFlushingQueue: boolean = false;
|
||||||
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
constructor(maxBuffer = 1000) {
|
constructor(maxBuffer = 1000) {
|
||||||
this.maxBuffer = maxBuffer;
|
this.maxBuffer = maxBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置磁盘持久化后端。 */
|
||||||
|
async initFileBackend(backend: LogFileBackend, options?: { maxDays?: number }): Promise<void> {
|
||||||
|
this.fileBackend = backend;
|
||||||
|
if (options?.maxDays) {
|
||||||
|
await this.cleanExpiredLogFiles(options.maxDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
debug(tag: string, message: string, data?: unknown): void {
|
debug(tag: string, message: string, data?: unknown): void {
|
||||||
this.log(LogLevel.DEBUG, tag, message, data);
|
this.log(LogLevel.DEBUG, tag, message, data);
|
||||||
}
|
}
|
||||||
@ -74,34 +102,191 @@ class LoggerImpl {
|
|||||||
case LogLevel.ERROR: console.error(prefix, message, data ?? ''); break;
|
case LogLevel.ERROR: console.error(prefix, message, data ?? ''); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 写入文件队列
|
||||||
|
if (this.fileBackend) {
|
||||||
|
const dateStr = entry.timestamp.slice(0, 10); // YYYY-MM-DD
|
||||||
|
const line = JSON.stringify(entry);
|
||||||
|
this.pendingWriteQueue.push({ dateStr, line });
|
||||||
|
this.scheduleQueueFlush();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取所有日志(副本)。 */
|
/** 防抖批量刷新写入文件。 */
|
||||||
|
private scheduleQueueFlush(): void {
|
||||||
|
if (this.flushTimer) return;
|
||||||
|
this.flushTimer = setTimeout(() => {
|
||||||
|
this.flushTimer = null;
|
||||||
|
this.flushQueue().catch(() => {});
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 强制立即刷新文件写入队列。 */
|
||||||
|
async flushQueue(): Promise<void> {
|
||||||
|
if (!this.fileBackend || this.isFlushingQueue || this.pendingWriteQueue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isFlushingQueue = true;
|
||||||
|
const itemsToFlush = [...this.pendingWriteQueue];
|
||||||
|
this.pendingWriteQueue = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 按日期分组写入
|
||||||
|
const groupedByDate: Record<string, string[]> = {};
|
||||||
|
for (const item of itemsToFlush) {
|
||||||
|
if (!groupedByDate[item.dateStr]) {
|
||||||
|
groupedByDate[item.dateStr] = [];
|
||||||
|
}
|
||||||
|
groupedByDate[item.dateStr].push(item.line);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [dateStr, lines] of Object.entries(groupedByDate)) {
|
||||||
|
await this.fileBackend.appendLog(dateStr, lines.join('\n') + '\n');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 写入失败重试逻辑:将未成功写入的重新放回队列头部
|
||||||
|
this.pendingWriteQueue.unshift(...itemsToFlush);
|
||||||
|
} finally {
|
||||||
|
this.isFlushingQueue = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取内存中所有日志(副本)。 */
|
||||||
getLogs(): LogEntry[] {
|
getLogs(): LogEntry[] {
|
||||||
return [...this.buffer];
|
return [...this.buffer];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按级别/标签过滤。 */
|
/** 按级别/标签过滤内存日志。 */
|
||||||
query(filter: { minLevel?: LogLevel; tag?: string }): LogEntry[] {
|
query(filter: { minLevel?: LogLevel; tag?: string; keyword?: string }): LogEntry[] {
|
||||||
return this.buffer.filter(e =>
|
return this.buffer.filter(e => {
|
||||||
(filter.minLevel === undefined || e.level >= filter.minLevel) &&
|
if (filter.minLevel !== undefined && e.level < filter.minLevel) return false;
|
||||||
(filter.tag === undefined || e.tag === filter.tag),
|
if (filter.tag !== undefined && filter.tag !== '' && e.tag !== filter.tag) return false;
|
||||||
);
|
if (filter.keyword) {
|
||||||
|
const kw = filter.keyword.toLowerCase();
|
||||||
|
const msgMatch = e.message.toLowerCase().includes(kw);
|
||||||
|
const tagMatch = e.tag.toLowerCase().includes(kw);
|
||||||
|
const dataMatch = e.data ? JSON.stringify(e.data).toLowerCase().includes(kw) : false;
|
||||||
|
if (!msgMatch && !tagMatch && !dataMatch) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清空日志。 */
|
/** 清空内存日志。 */
|
||||||
clearLogs(): void {
|
clearLogs(): void {
|
||||||
this.buffer = [];
|
this.buffer = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 调整缓冲上限。 */
|
/** 清空内存与磁盘日志。 */
|
||||||
|
async clearAllLogs(): Promise<void> {
|
||||||
|
this.buffer = [];
|
||||||
|
this.pendingWriteQueue = [];
|
||||||
|
if (this.fileBackend) {
|
||||||
|
await this.fileBackend.clearAllLogFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调整内存缓冲上限。 */
|
||||||
setMaxBuffer(size: number): void {
|
setMaxBuffer(size: number): void {
|
||||||
this.maxBuffer = size;
|
this.maxBuffer = size;
|
||||||
// 立即裁剪
|
|
||||||
if (this.buffer.length > size) {
|
if (this.buffer.length > size) {
|
||||||
this.buffer = this.buffer.slice(this.buffer.length - size);
|
this.buffer = this.buffer.slice(this.buffer.length - size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取所有日志文件列表。 */
|
||||||
|
async getLogFiles(): Promise<LogFileInfo[]> {
|
||||||
|
if (!this.fileBackend) return [];
|
||||||
|
return this.fileBackend.listLogFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从文件读取特定日期的日志。 */
|
||||||
|
async loadLogsFromFile(dateStr: string): Promise<LogEntry[]> {
|
||||||
|
if (!this.fileBackend) return [];
|
||||||
|
try {
|
||||||
|
const content = await this.fileBackend.readLogFile(dateStr);
|
||||||
|
if (!content.trim()) return [];
|
||||||
|
|
||||||
|
const entries: LogEntry[] = [];
|
||||||
|
const lines = content.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(trimmed) as LogEntry;
|
||||||
|
if (entry && entry.timestamp && entry.level !== undefined && entry.message) {
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略非法数据行
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取今天的全量日志(包含已被写入手机磁盘的历史日志 + 当前 Session 内存中的最新日志,自动去重)。 */
|
||||||
|
async getTodayLogs(): Promise<LogEntry[]> {
|
||||||
|
const todayStr = new Date().toISOString().slice(0, 10);
|
||||||
|
const diskLogs = await this.loadLogsFromFile(todayStr);
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const combined: LogEntry[] = [];
|
||||||
|
|
||||||
|
for (const entry of diskLogs) {
|
||||||
|
const key = `${entry.timestamp}_${entry.tag}_${entry.message}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
combined.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of this.buffer) {
|
||||||
|
const key = `${entry.timestamp}_${entry.tag}_${entry.message}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
combined.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理超过 maxDays 天数的历史日志文件。 */
|
||||||
|
async cleanExpiredLogFiles(maxDays = 7): Promise<void> {
|
||||||
|
if (!this.fileBackend) return;
|
||||||
|
try {
|
||||||
|
const files = await this.fileBackend.listLogFiles();
|
||||||
|
const cutoffTime = Date.now() - maxDays * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const fileDate = new Date(file.date).getTime();
|
||||||
|
if (!isNaN(fileDate) && fileDate < cutoffTime) {
|
||||||
|
await this.fileBackend.deleteLogFile(file.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 静默处理清理错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化日志列表为文本形式(可用于导出或分享)。 */
|
||||||
|
exportAsFormattedText(entries?: LogEntry[]): string {
|
||||||
|
const list = entries ?? this.buffer;
|
||||||
|
if (list.length === 0) return '--- 无日志记录 ---';
|
||||||
|
|
||||||
|
return list
|
||||||
|
.map(e => {
|
||||||
|
const levelName = LEVEL_LABELS[e.level] ?? 'INFO';
|
||||||
|
const hasData = e.data !== undefined && e.data !== null && (typeof e.data !== 'object' || Object.keys(e.data).length > 0);
|
||||||
|
const dataStr = hasData ? ` | Data: ${JSON.stringify(e.data)}` : '';
|
||||||
|
return `[${e.timestamp}] [${levelName}] [${e.tag}] ${e.message}${dataStr}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全局单例日志器。 */
|
/** 全局单例日志器。 */
|
||||||
@ -119,3 +304,4 @@ export const LEVEL_LABELS: Record<LogLevel, string> = {
|
|||||||
[LogLevel.WARN]: 'WARN',
|
[LogLevel.WARN]: 'WARN',
|
||||||
[LogLevel.ERROR]: 'ERROR',
|
[LogLevel.ERROR]: 'ERROR',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ describe('ocrBridge', () => {
|
|||||||
recognizeText: async () => '微信支付 金额 10 商户:x',
|
recognizeText: async () => '微信支付 金额 10 商户:x',
|
||||||
recognizeTextBlocks: async () => [],
|
recognizeTextBlocks: async () => [],
|
||||||
isReady: async () => true,
|
isReady: async () => true,
|
||||||
|
setModelDir: async () => true,
|
||||||
};
|
};
|
||||||
const bridge = new NativeOcrBridge(native);
|
const bridge = new NativeOcrBridge(native);
|
||||||
expect(await bridge.recognizeText('img')).toBe('微信支付 金额 10 商户:x');
|
expect(await bridge.recognizeText('img')).toBe('微信支付 金额 10 商户:x');
|
||||||
@ -27,6 +28,7 @@ describe('ocrBridge', () => {
|
|||||||
recognizeText: async () => { throw new Error('原生崩溃'); },
|
recognizeText: async () => { throw new Error('原生崩溃'); },
|
||||||
recognizeTextBlocks: async () => [],
|
recognizeTextBlocks: async () => [],
|
||||||
isReady: async () => false,
|
isReady: async () => false,
|
||||||
|
setModelDir: async () => true,
|
||||||
};
|
};
|
||||||
const fallback = new SimulatedOcrBridge();
|
const fallback = new SimulatedOcrBridge();
|
||||||
fallback.preset('fallback', '降级文本');
|
fallback.preset('fallback', '降级文本');
|
||||||
|
|||||||
71
tests/floating-ui-config.test.ts
Normal file
71
tests/floating-ui-config.test.ts
Normal 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
167
tests/logger.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -290,7 +290,7 @@ describe('OcrProcessor 分层处理', () => {
|
|||||||
id: 'ai-vision', occurredAt: '2026-07-13', amount: '99', currency: 'CNY', direction: 'expense',
|
id: 'ai-vision', occurredAt: '2026-07-13', amount: '99', currency: 'CNY', direction: 'expense',
|
||||||
counterparty: 'AI识别', memo: '', raw: {},
|
counterparty: 'AI识别', memo: '', raw: {},
|
||||||
});
|
});
|
||||||
const processor = new OcrProcessor(engine, aiProvider, { aiVisionEnabled: true, landscapeDnd: false });
|
const processor = new OcrProcessor(engine, aiProvider, { layer1Enabled: true, layer2Enabled: true, layer3Enabled: true, landscapeDnd: false });
|
||||||
const result = await processor.process('img4');
|
const result = await processor.process('img4');
|
||||||
expect(result.layer).toBe('layer3-ai');
|
expect(result.layer).toBe('layer3-ai');
|
||||||
expect(result.event?.amount).toBe('99');
|
expect(result.event?.amount).toBe('99');
|
||||||
@ -298,7 +298,7 @@ describe('OcrProcessor 分层处理', () => {
|
|||||||
|
|
||||||
it('横屏免打扰跳过', async () => {
|
it('横屏免打扰跳过', async () => {
|
||||||
const engine = new MockOcrEngine();
|
const engine = new MockOcrEngine();
|
||||||
const processor = new OcrProcessor(engine, undefined, { aiVisionEnabled: false, landscapeDnd: true });
|
const processor = new OcrProcessor(engine, undefined, { layer1Enabled: true, layer2Enabled: true, layer3Enabled: false, landscapeDnd: true });
|
||||||
const result = await processor.process('img5', undefined, true);
|
const result = await processor.process('img5', undefined, true);
|
||||||
expect(result.skipped).toBe('landscape-dnd');
|
expect(result.skipped).toBe('landscape-dnd');
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user