Compare commits
4
Commits
76a5853ab6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6767dd538a | ||
|
|
bf04400852 | ||
|
|
2e73b5a2c6 | ||
|
|
7fa345b558 |
@@ -48,3 +48,5 @@ CLAUDE.md
|
|||||||
/.agents/
|
/.agents/
|
||||||
/reference_project/
|
/reference_project/
|
||||||
/.zcode/
|
/.zcode/
|
||||||
|
# superpowers brainstorm mockups
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
DriftLedger (浮记) is an offline-first mobile client for [Beancount](https://beancount.github.io/) double-entry accounting, built with React Native + Expo (CNG), TypeScript, Zustand, and expo-sqlite. The app treats desktop-maintained `.bean` files as the read-only source of truth and appends new entries to `main.bean`.
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
| Directory | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `src/domain/` | Pure business logic (ledger parser, bill pipeline, dedup, rules, OCR processor). No React/RN imports. |
|
||||||
|
| `src/app/` | Expo Router file-based routes; `(tabs)/` is bottom navigation. |
|
||||||
|
| `src/components/` | Reusable React Native UI components and charts. |
|
||||||
|
| `src/store/` | Zustand stores (ledger, import, settings, metadata, automation). |
|
||||||
|
| `src/services/` | Platform-facing concerns (sync, backup, security, OCR bridge, automation pipeline, accessibility text parser). `src/services/automation/accessibilityParser.ts` parses WeChat/Alipay/bank accessibility node texts into `ImportedEvent`. |
|
||||||
|
| `src/storage/` | SQLite read-cache with versioned migrations. |
|
||||||
|
| `src/theme/` | Token-based theming system. |
|
||||||
|
| `src/i18n/` | zh/en localization dictionaries. |
|
||||||
|
| `plugins/` | Expo Config Plugins for native modules (ppocr, accessibility, sms-receiver, etc.). |
|
||||||
|
| `tests/` | Vitest unit tests (mock-backed, domain-focused). |
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --legacy-peer-deps # Install dependencies (legacy-peer-deps required)
|
||||||
|
npm run start # Start Metro dev server
|
||||||
|
npm run android # Build & run on Android device/emulator
|
||||||
|
npm test # Run full Vitest suite
|
||||||
|
npm run typecheck # TypeScript strict-mode check (tsc --noEmit)
|
||||||
|
|
||||||
|
# Run a single test file:
|
||||||
|
npx vitest run tests/ledger.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
- **TypeScript strict mode** is enabled; path alias `@/*` maps to `src/*`.
|
||||||
|
- **Money math** uses string decimals (`src/domain/decimal.ts`) — never raw JS floats.
|
||||||
|
- **Domain layer** (`src/domain/`) must remain pure: no React, RN, or Expo imports. Inject external dependencies via interfaces.
|
||||||
|
- Code comments and `plan.md` are written in **Chinese**; match that convention.
|
||||||
|
- New domain modules must be re-exported from `src/domain/index.ts`.
|
||||||
|
- Native code lives exclusively in `plugins/<name>/` as Expo Config Plugins — never edit generated `android/` files directly.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
- Framework: **Vitest** (no config file; picks up `tests/**/*.test.ts` by default).
|
||||||
|
- Tests target the domain layer using mock backends (`MemoryBackend`, `MockOcrEngine`).
|
||||||
|
- Name test files descriptively: `<module-or-feature>.test.ts` (e.g., `decimal.test.ts`, `billPipeline.test.ts`).
|
||||||
|
- Run `npm run typecheck` after non-trivial changes to catch type regressions.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
- Commit messages follow the pattern: `feat: <concise summary in Chinese>` with a detailed multi-line body listing changes by area.
|
||||||
|
- Example: `feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构`
|
||||||
|
- PRs should describe what changed and why; link related issues. Include screenshots for UI changes.
|
||||||
|
|
||||||
|
## Key Architectural Invariants
|
||||||
|
|
||||||
|
1. `.bean` files are the source of truth; SQLite is a disposable read-cache.
|
||||||
|
2. All bill ingestion funnels through `BillPipeline` (`src/domain/billPipeline.ts`) under a serialized mutex.
|
||||||
|
3. Every Config Plugin function **must `return config`** at the end.
|
||||||
|
4. IDs/checksums use FNV-1a hashing (`hash()` in `ledger.ts`), not crypto hashes.
|
||||||
|
5. **Modal 弹窗**脱离主窗口 context:Modal 内必须重新包 `<SafeAreaProvider>`,且键盘避让用**响应式 paddingBottom**(`Math.max(insets.bottom, 24) + kbHeight`),不要用 `KeyboardAvoidingView` 或 `translateY` 位移。详见 `docs/modal-keyboard-guide.md`。
|
||||||
|
6. **改了 TS 后 release 包若不更新**:gradle 的 bundle task 会因缓存跳过重打包,Metro 也有 transformer cache。验证 bundle 必须用 `grep -a`(Hermes 字节码是二进制)。详见 `docs/android-build-guide.md §7`。
|
||||||
|
7. **改了原生 `.kt` 后必须让 `android/` 副本同步**:Config Plugin 的 `withDangerousMod` 只在 `prebuild` 时把 `plugins/*/android/*.kt` 复制到 `android/` 并替换包名,直接 gradle 编译会用旧副本(「改了没生效」的另一类根因,与第 6 条的 TS bundle 缓存并列)。全量 `prebuild` 或「只改单个 .kt 时手动同步副本」二选一。详见 `docs/android-build-guide.md §0 / §5.4`。
|
||||||
|
8. **OCR 的 det 后处理必须用连通域法**(`OcrModule.kt` 的 `dbPostprocess`:4-连通 BFS + 官方 unclip 外扩 + box_score_fast 过滤),**禁止回退到水平/垂直投影切行**——投影法会把基线孤立小数点切到文本框外,导致 `¥143.97` 识别成 `¥14397`。怀疑「模型识别能力不足」前先用官方 PaddleOCR 跑同一对模型对照。详见 `docs/ocr-pipeline-guide.md`。
|
||||||
|
9. **无障碍服务伪装必须包名+类名同时匹配**:微信 8.0.52+ 按 `ComponentName`(`包名/类名`)识别系统服务白名单,只伪装类名无效。8 个 kt 文件整体在 Config Plugin(`plugins/accessibility/app.plugin.js`)里 rewrite 到 `com.google.android.accessibility.selecttospeak` 包,Manifest `android:name` 写成完整全限定名 `com.google.android.accessibility.selecttospeak.SelectToSpeakService`。**kt 的 `package` 声明、物理路径、Manifest 服务名三者必须逐字一致**,否则 `ClassNotFoundException`。改 package 重写正则时必须精确匹配源码的 `com.beancount.mobile.accessibility`(含 `.accessibility` 后缀),漏匹配会多出一段导致编译失败。伪装后 `triggerManualExtraction` 必须用 `dumpAllTexts()`(覆盖 WebView 子窗口),不能用只读 `rootInActiveWindow` 的旧路径。详见 `docs/accessibility-wechat-guide.md`。
|
||||||
@@ -1,26 +1,56 @@
|
|||||||
# Bean Mobile
|
# DriftLedger (浮记)
|
||||||
|
|
||||||
面向 Beancount 用户的离线移动端原型:桌面账本只读,移动端仅写入 `mobile.bean`;CSV 账单先生成复式分录草稿,再由用户确认。
|
面向 [Beancount](https://beancount.github.io/) 用户的离线移动记账客户端。桌面账本只读,移动端仅追加写入 `main.bean`;支持 OCR、无障碍服务、通知监听、短信等多渠道自动记账。
|
||||||
|
|
||||||
## 启动
|
**技术栈**:React Native + Expo (CNG) · TypeScript (strict) · Zustand · expo-sqlite · PP-OCRv6 (ONNX Runtime)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install --legacy-peer-deps # 安装依赖(必须带 --legacy-peer-deps)
|
||||||
npm run start
|
npm run start # 启动 Metro 开发服务器
|
||||||
|
npm run android # 构建并运行 Android
|
||||||
|
npm test # 运行 Vitest 全量测试
|
||||||
|
npm run typecheck # TypeScript 类型检查
|
||||||
```
|
```
|
||||||
|
|
||||||
使用 Expo CNG 开发构建运行:`npm run android` 或 `npm run ios`。应用配置启用了 `expo-sqlite` 的 SQLCipher 构建选项,实际密钥管理应接入 SecureStore/Keychain 后再发布。
|
> 本应用为侧载开源项目,不上架应用商店。Android 构建详见 [docs/android-build-guide.md](docs/android-build-guide.md)。
|
||||||
|
|
||||||
|
## 模块概览
|
||||||
|
|
||||||
|
| 目录 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `src/domain/` | 纯业务逻辑:`.bean` 解析器、账单管道、去重、规则引擎、OCR 处理器 |
|
||||||
|
| `src/app/` | Expo Router 文件路由(`(tabs)/` 底部导航) |
|
||||||
|
| `src/components/` | React Native UI 组件与图表 |
|
||||||
|
| `src/store/` | Zustand 状态管理(ledger / import / settings / metadata / automation) |
|
||||||
|
| `src/services/` | 平台服务(同步、备份、安全、OCR 桥接) |
|
||||||
|
| `src/storage/` | SQLite 读缓存 + 版本化迁移 |
|
||||||
|
| `src/theme/` | Token 主题系统(浅色 / OLED 暗色) |
|
||||||
|
| `src/i18n/` | 中英文国际化 |
|
||||||
|
| `plugins/` | Expo Config Plugins 原生模块(OCR、无障碍、通知、短信、截图) |
|
||||||
|
| `tests/` | Vitest 单元测试(mock 后端,覆盖 domain 层) |
|
||||||
|
|
||||||
|
## 文档导航
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| [docs/architecture.md](docs/architecture.md) | 系统架构与数据流 |
|
||||||
|
| [docs/development.md](docs/development.md) | 开发指南(环境、命令、规范、测试) |
|
||||||
|
| [docs/android-build-guide.md](docs/android-build-guide.md) | Android 打包与体积优化 |
|
||||||
|
| [docs/accessibility-wechat-guide.md](docs/accessibility-wechat-guide.md) | 无障碍服务与微信文本抓取(绕过 8.0.52+ 节点混淆) |
|
||||||
|
| [docs/ocr-pipeline-guide.md](docs/ocr-pipeline-guide.md) | OCR 推理管线与诊断方法论 |
|
||||||
|
| [docs/design/](docs/design/) | UI 重设计规格与分阶段实施计划 |
|
||||||
|
| [design-system/](design-system/beancount-mobile/MASTER.md) | 设计系统 Token 定义 |
|
||||||
|
| [AGENTS.md](AGENTS.md) | AI 编程助手贡献指南 |
|
||||||
|
| [plan.md](plan.md) | 设计决策与实施路线图(权威文档) |
|
||||||
|
|
||||||
## 账本约定
|
## 账本约定
|
||||||
|
|
||||||
在主账本添加一次:
|
在桌面主账本中添加一次:
|
||||||
|
|
||||||
```beancount
|
```beancount
|
||||||
include "mobile.bean"
|
include "main.bean"
|
||||||
```
|
```
|
||||||
|
|
||||||
应用不改写主账本或其他桌面维护文件;确认的交易只追加到 `mobile.bean`。导出的账本包由用户再同步到 Git、WebDAV 或 iCloud。
|
应用不改写主账本或其他桌面维护文件;确认的交易只追加到 `main.bean`。
|
||||||
|
|
||||||
## 当前边界
|
|
||||||
|
|
||||||
已实现账务校验、基础 `.bean` 解析、CSV 账单规范化、去重、规则分类、转账候选和 `mobile.bean` 序列化。Tree-sitter 原生模块、真实文件选择/ZIP 导出、正式支付宝/微信/各银行字段映射、SecureStore 密钥和多设备冲突处理仍需在真机集成阶段接入。
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "Bean Mobile",
|
"name": "浮记",
|
||||||
"slug": "bean-mobile",
|
"slug": "drift-ledger",
|
||||||
"scheme": "beanmobile",
|
"scheme": "driftledger",
|
||||||
|
"icon": "./assets/icon/direction_a_feather.png",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
[
|
[
|
||||||
"expo-sqlite",
|
"expo-sqlite",
|
||||||
@@ -25,10 +26,15 @@
|
|||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"bundleIdentifier": "com.example.beanmobile"
|
"bundleIdentifier": "com.example.driftledger",
|
||||||
|
"icon": "./assets/icon/direction_a_feather.png"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "com.example.beanmobile"
|
"package": "com.example.driftledger",
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"foregroundImage": "./assets/icon/direction_a_feather.png",
|
||||||
|
"backgroundColor": "#1B1F20"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 486 KiB |
@@ -1,206 +1,72 @@
|
|||||||
# Design System Master File
|
# Beancount Mobile 设计系统(MASTER)
|
||||||
|
|
||||||
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
> 本文件与 `src/theme/presets.ts` 一一对应,是实现的事实描述而非平行标准。
|
||||||
> If that file exists, its rules **override** this Master file.
|
> 修改配色/字阶/圆角时必须先改 presets.ts,再同步本文件。
|
||||||
> If not, strictly follow the rules below.
|
|
||||||
|
|
||||||
---
|
## 设计方向:明亮 Bento 现代风
|
||||||
|
|
||||||
**Project:** beancount-mobile
|
浅色为主、大圆角卡片(Bento)、近黑白单色 + 财务语义色点缀。
|
||||||
**Generated:** 2026-07-16 16:32:57
|
品牌感靠排版与财务色表达,不依赖彩色 accent。暗色为 OLED 纯黑完整对等主题。
|
||||||
**Category:** Personal Finance Tracker
|
|
||||||
|
|
||||||
---
|
## 色板(= presets.ts)
|
||||||
|
|
||||||
## Global Rules
|
### 浅色(默认)
|
||||||
|
| Token | 值 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| bgPrimary | `#F6F7F9` | 页面背景 |
|
||||||
|
| bgSecondary | `#FFFFFF` | 卡片 |
|
||||||
|
| bgTertiary | `#EFF1F4` | 输入框 / chip |
|
||||||
|
| fgPrimary | `#111318` | 主文字 |
|
||||||
|
| fgSecondary | `#6B7280` | 次要文字 |
|
||||||
|
| fgInverse | `#FFFFFF` | 反色文字 |
|
||||||
|
| accent | `#111318` | 按钮 / 选中态(近黑) |
|
||||||
|
| accentLight | `rgba(17,19,24,0.08)` | 选中高亮底色 |
|
||||||
|
| accentDark | `#000000` | 按压态 |
|
||||||
|
| financial.income | `#10B981` | 收入 |
|
||||||
|
| financial.expense | `#EF4444` | 支出 |
|
||||||
|
| financial.transfer | `#3B82F6` | 转账 |
|
||||||
|
| border | `#E5E7EB` | 边框 |
|
||||||
|
| overlay | `rgba(17,19,24,0.4)` | 遮罩 |
|
||||||
|
|
||||||
### Color Palette
|
### 暗色(OLED)
|
||||||
|
| Token | 值 |
|
||||||
|
|---|---|
|
||||||
|
| bgPrimary | `#040508` |
|
||||||
|
| bgSecondary | `#101218` |
|
||||||
|
| bgTertiary | `#1A1D24` |
|
||||||
|
| fgPrimary / accent | `#F3F4F6` |
|
||||||
|
| fgSecondary | `#9CA3AF` |
|
||||||
|
| fgInverse | `#111318` |
|
||||||
|
| financial.income | `#34D399`(提亮) |
|
||||||
|
| financial.expense | `#F87171`(提亮) |
|
||||||
|
| financial.transfer | `#60A5FA`(提亮) |
|
||||||
|
| border | `rgba(255,255,255,0.08)` |
|
||||||
|
|
||||||
| Role | Hex | CSS Variable |
|
分类/标签/渠道颜色见 `src/theme/palette.ts`(分类 12 色循环 + 具名映射、TAG_COLORS、渠道品牌色)。
|
||||||
|------|-----|--------------|
|
其余 token(divider / skeleton / progressBg / success / warning / error / info)见 `presets.ts`。
|
||||||
| Primary | `#1E40AF` | `--color-primary` |
|
全工程颜色字面量只允许存在于 `presets.ts` 与 `palette.ts`。
|
||||||
| On Primary | `#FFFFFF` | `--color-on-primary` |
|
|
||||||
| Secondary | `#3B82F6` | `--color-secondary` |
|
|
||||||
| Accent/CTA | `#059669` | `--color-accent` |
|
|
||||||
| Background | `#0F172A` | `--color-background` |
|
|
||||||
| Foreground | `#FFFFFF` | `--color-foreground` |
|
|
||||||
| Muted | `#101A34` | `--color-muted` |
|
|
||||||
| Border | `rgba(255,255,255,0.08)` | `--color-border` |
|
|
||||||
| Destructive | `#DC2626` | `--color-destructive` |
|
|
||||||
| Ring | `#1E40AF` | `--color-ring` |
|
|
||||||
|
|
||||||
**Color Notes:** Trust blue + profit green on dark
|
## 字体
|
||||||
|
|
||||||
### Typography
|
系统字体(iOS SF / Android Roboto),不加载自定义字体。
|
||||||
|
金额数字一律 `fontVariant: ['tabular-nums']` 等宽对齐。
|
||||||
|
|
||||||
- **Heading Font:** Caveat
|
字阶:display 34/800 · h1 28/700 · h2 22/700 · h3 17/600 · body 16/400 · bodySmall 14/400 · caption 12/400。
|
||||||
- **Body Font:** Quicksand
|
|
||||||
- **Mood:** handwritten, personal, friendly, casual, warm, charming
|
|
||||||
- **Google Fonts:** [Caveat + Quicksand](https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap)
|
|
||||||
|
|
||||||
**CSS Import:**
|
## 圆角 / 间距 / 阴影
|
||||||
```css
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap');
|
|
||||||
```
|
|
||||||
|
|
||||||
### Spacing Variables
|
- 圆角:sm 8 · md 12 · lg 16 · **xl 24(弹窗默认;卡片组件 P2 统一升级)** · full
|
||||||
|
- 间距:xs 4 · sm 8 · md 16 · lg 24 · xl 32
|
||||||
|
- 阴影:浅色 4–12px 弥散轻阴影;暗色阴影减重,辅以半透边框
|
||||||
|
|
||||||
| Token | Value | Usage |
|
## 图标
|
||||||
|-------|-------|-------|
|
|
||||||
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
|
|
||||||
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
|
|
||||||
| `--space-md` | `16px` / `1rem` | Standard padding |
|
|
||||||
| `--space-lg` | `24px` / `1.5rem` | Section padding |
|
|
||||||
| `--space-xl` | `32px` / `2rem` | Large gaps |
|
|
||||||
| `--space-2xl` | `48px` / `3rem` | Section margins |
|
|
||||||
| `--space-3xl` | `64px` / `4rem` | Hero padding |
|
|
||||||
|
|
||||||
### Shadow Depths
|
统一 Ionicons(`@expo/vector-icons`)。禁止用 emoji 充当图标;
|
||||||
|
分类图标 = Ionicon + 圆形彩色底(CategoryIcon 组件,P2 落地)。
|
||||||
|
|
||||||
| Level | Value | Usage |
|
## 反模式
|
||||||
|-------|-------|-------|
|
|
||||||
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
|
||||||
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
|
||||||
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
|
||||||
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
|
||||||
|
|
||||||
---
|
- 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token
|
||||||
|
- 禁止低对比文字(< 4.5:1)
|
||||||
## Component Specs
|
- 禁止瞬间状态变化,交互反馈 150–300ms
|
||||||
|
- 禁止绕过 commonStyles 重复造 input/chip/modal 样式
|
||||||
### Buttons
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* Primary Button */
|
|
||||||
.btn-primary {
|
|
||||||
background: #059669;
|
|
||||||
color: white;
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: all 200ms ease;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Secondary Button */
|
|
||||||
.btn-secondary {
|
|
||||||
background: transparent;
|
|
||||||
color: #1E40AF;
|
|
||||||
border: 2px solid #1E40AF;
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: all 200ms ease;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cards
|
|
||||||
|
|
||||||
```css
|
|
||||||
.card {
|
|
||||||
background: #0F172A;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
transition: all 200ms ease;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Inputs
|
|
||||||
|
|
||||||
```css
|
|
||||||
.input {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 1px solid #E2E8F0;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: border-color 200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus {
|
|
||||||
border-color: #1E40AF;
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 3px #1E40AF20;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modals
|
|
||||||
|
|
||||||
```css
|
|
||||||
.modal-overlay {
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 32px;
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
max-width: 500px;
|
|
||||||
width: 90%;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Style Guidelines
|
|
||||||
|
|
||||||
**Style:** Dark Mode (OLED)
|
|
||||||
|
|
||||||
**Keywords:** Dark theme, low light, high contrast, deep black, midnight blue, eye-friendly, OLED, night mode, power efficient
|
|
||||||
|
|
||||||
**Best For:** Night-mode apps, coding platforms, entertainment, eye-strain prevention, OLED devices, low-light
|
|
||||||
|
|
||||||
**Key Effects:** Minimal glow (text-shadow: 0 0 10px), dark-to-light transitions, low white emission, high readability, visible focus
|
|
||||||
|
|
||||||
### Page Pattern
|
|
||||||
|
|
||||||
**Pattern Name:** Interactive Product Demo
|
|
||||||
|
|
||||||
- **CTA Placement:** Above fold
|
|
||||||
- **Section Order:** Hero > Features > CTA
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Anti-Patterns (Do NOT Use)
|
|
||||||
|
|
||||||
- ❌ Pure white backgrounds
|
|
||||||
|
|
||||||
### Additional Forbidden Patterns
|
|
||||||
|
|
||||||
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
|
||||||
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
|
||||||
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
|
||||||
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
|
||||||
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
|
||||||
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pre-Delivery Checklist
|
|
||||||
|
|
||||||
Before delivering any UI code, verify:
|
|
||||||
|
|
||||||
- [ ] No emojis used as icons (use SVG instead)
|
|
||||||
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
|
||||||
- [ ] `cursor-pointer` on all clickable elements
|
|
||||||
- [ ] Hover states with smooth transitions (150-300ms)
|
|
||||||
- [ ] Light mode: text contrast 4.5:1 minimum
|
|
||||||
- [ ] Focus states visible for keyboard navigation
|
|
||||||
- [ ] `prefers-reduced-motion` respected
|
|
||||||
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
|
||||||
- [ ] No content hidden behind fixed navbars
|
|
||||||
- [ ] No horizontal scroll on mobile
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# 无障碍服务与微信文本抓取指南 (Accessibility & WeChat Text Extraction)
|
||||||
|
|
||||||
|
本文记录 DriftLedger 无障碍服务(`plugins/accessibility/`)抓取微信账单页面文本的完整方案,重点沉淀**绕过微信 8.0.52+ 节点混淆**的伪装机制、Config Plugin 的落地细节、调试方法论与踩过的坑。
|
||||||
|
|
||||||
|
> 配套:OCR 管线见 `ocr-pipeline-guide.md`;构建/编译陷阱见 `android-build-guide.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. TL;DR
|
||||||
|
|
||||||
|
- 微信 8.0.52+ 对第三方无障碍服务做**节点混淆**:把页面节点的 `text` / `contentDescription` 用其他节点信息随机替换,导致基于节点文本的解析全部失效。微信内部有**白名单**:系统服务(TalkBack、SelectToSpeak)不受影响。
|
||||||
|
- 绕过方案:**伪装成系统 SelectToSpeak 服务**。微信按 `ComponentName`(`包名/类名`)识别白名单,所以必须**包名 + 类名同时伪装**为 `com.google.android.accessibility.selecttospeak.SelectToSpeakService`,只伪装类名无效。
|
||||||
|
- 落地由 Config Plugin(`plugins/accessibility/app.plugin.js`)在 prebuild 时完成:把 8 个 kt 文件整体复制到 `com/google/android/accessibility/selecttospeak/` 目录,`package` 声明 rewrite 为该包,Manifest `android:name` 写成完整全限定名。三者必须严格一致,否则 `ClassNotFoundException`。
|
||||||
|
- 伪装生效后,`triggerManualExtraction` 成为微信账单页的主识别路径,必须用 `dumpAllTexts()`(覆盖所有窗口含 WebView 子窗口),不能用只读 `rootInActiveWindow` 的简化路径。
|
||||||
|
- OCR 截图兜底链路保留,作为伪装失败或极少数空白帧场景的保底。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景:微信 8.0.52+ 节点混淆
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
微信账单详情页(我-服务-钱包-账单-某笔交易)上,点悬浮球「识别账单」后,`billingDebugNodes` 事件里的 `texts` 数组拿到的是乱码、错位文本或空数组。`parseWechatTexts`(`src/services/automation/accessibilityParser.ts`)依赖「标签 + 下一节点为值」的顺序假设,一旦文本被打乱就完全失效。
|
||||||
|
|
||||||
|
### 混淆原理
|
||||||
|
微信在生成无障碍节点时,用 Map 缓存了页面节点信息,混淆时**从 Map 中随机抽取其他节点的信息填入当前节点**。每次页面刷新混淆结果都不同,所以:
|
||||||
|
- 基于固定 `resource-id` 的 `findAccessibilityNodeInfosByViewId` 失效
|
||||||
|
- 基于文本匹配的 `findAccessibilityNodeInfosByText` 失效
|
||||||
|
- 基于顺序假设的扁平数组解析失效
|
||||||
|
- `className` 也被随机化(EditText/TextView 被换成动态生成类名)
|
||||||
|
|
||||||
|
### 白名单机制
|
||||||
|
微信维护了一份系统服务白名单,对白名单内的服务**不下发混淆**。TalkBack、SelectToSpeak 等系统级无障碍服务在白名单中。判断依据是服务的 `ComponentName`(即 `包名/类名`)。
|
||||||
|
|
||||||
|
> 这是整个绕过方案的支点:只要让微信认为我们的服务是系统 SelectToSpeak,就能拿到未混淆的真实节点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 绕过方案:伪装 ComponentName
|
||||||
|
|
||||||
|
### 关键认知:只伪装类名不够
|
||||||
|
早期实现(本仓库 commit `bf04400` 之前)只把类名伪装成 `SelectToSpeakService`,但 Manifest 服务名写的是 `${appId}.accessibility.SelectToSpeakService`(应用包名 + SelectToSpeak 类名)。**包名没伪装**,所以微信仍将其识别为第三方服务,混淆照常下发。这是「伪装做了但没生效」的典型坑。
|
||||||
|
|
||||||
|
### 正确做法:包名 + 类名全限定伪装
|
||||||
|
微信按 `ComponentName` 全字符串匹配白名单,因此:
|
||||||
|
|
||||||
|
| 字段 | 伪装值 |
|
||||||
|
|---|---|
|
||||||
|
| Manifest `android:name` | `com.google.android.accessibility.selecttospeak.SelectToSpeakService` |
|
||||||
|
| Kotlin `package` | `com.google.android.accessibility.selecttospeak` |
|
||||||
|
| Kotlin `class` | `SelectToSpeakService` |
|
||||||
|
| 文件物理路径 | `app/src/main/java/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt` |
|
||||||
|
|
||||||
|
四者必须严格一致。Android 编译期要求 Manifest 全限定名必须有匹配 `package` + `class` 的真实 Kotlin 源码,否则 `ClassNotFoundException`。
|
||||||
|
|
||||||
|
### 为什么选 SelectToSpeak 而不是 TalkBack
|
||||||
|
早期业界方案伪装的是 `com.google.android.marvin.talkback.TalkBackService`,能成功绕过混淆,但**小米机型会误判 TalkBack 已开启**,屏幕持续显示「TalkBack 已激活」文字,严重干扰用户。SelectToSpeak(随选朗读)没有这个副作用,是更安全的选择。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Config Plugin 落地(`plugins/accessibility/app.plugin.js`)
|
||||||
|
|
||||||
|
伪装不是手动改生成的 `android/` 文件(那会被 `expo prebuild --clean` 冲掉),而是通过 Config Plugin 在 prebuild 时自动完成。
|
||||||
|
|
||||||
|
### 三个核心常量
|
||||||
|
```js
|
||||||
|
const FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
|
||||||
|
const FAKE_SERVICE_NAME = `${FAKE_PACKAGE}.SelectToSpeakService`;
|
||||||
|
const FAKE_TILE_SERVICE_NAME = `${FAKE_PACKAGE}.OcrTileService`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 三个配合改动的环节
|
||||||
|
|
||||||
|
**① 文件复制路径**(`withDangerousMod`)
|
||||||
|
8 个 kt 文件统一复制到 `app/src/main/java/com/google/android/accessibility/selecttospeak/`,而不是应用包名目录。
|
||||||
|
|
||||||
|
**② package 重写**(正则替换)
|
||||||
|
kt 源码里仍写 `package com.beancount.mobile.accessibility`(源码锚点),Config Plugin 用正则替换为 FAKE_PACKAGE:
|
||||||
|
```js
|
||||||
|
content = content.replace(/package\s+com\.beancount\.mobile\.accessibility/g, `package ${FAKE_PACKAGE}`);
|
||||||
|
```
|
||||||
|
⚠️ **关键坑**:正则必须精确匹配到 `.accessibility` 后缀。如果只匹配 `com.beancount.mobile`,替换结果会变成 `com.google.android.accessibility.selecttospeak.accessibility`(多出一段),与 Manifest 不一致 → 编译失败。详见 §5 坑 1。
|
||||||
|
|
||||||
|
**③ Manifest 服务名 + MainApplication import**(`withAndroidManifest` / `withMainApplication`)
|
||||||
|
- Manifest 服务 `android:name` 用 `FAKE_SERVICE_NAME` / `FAKE_TILE_SERVICE_NAME`
|
||||||
|
- MainApplication 里 `import com.google.android.accessibility.selecttospeak.AccessibilityBridgePackage`(注意 import 路径也要用 FAKE_PACKAGE,因为 BridgePackage 跟随其他文件一起伪装了)
|
||||||
|
|
||||||
|
### 「全部跟随伪装」 vs 「仅 Service 伪装」
|
||||||
|
本仓库 8 个 kt 文件(`SelectToSpeakService` / `AccessibilityBridgeModule` / `AccessibilityBridgePackage` / `FloatingHelper` / `FloatingBillView` / `FloatingTip` / `FloatingUiConfigStore` / `OcrTileService`)原本共享同一个 package,彼此通过同 package 直接引用(无显式 import)。
|
||||||
|
|
||||||
|
决策:**全部跟随伪装**到 FAKE_PACKAGE。优点是维持同 package 直接引用的简洁结构,零跨包 import 改动;代价是 `AccessibilityBridgeModule` / `OcrTileService` 这些不暴露给微信的组件也披上了系统服务包名(功能上无影响,它们靠 RN Bridge 和 QS Tile 识别,与微信无关)。
|
||||||
|
|
||||||
|
> 替代方案是只伪装 `SelectToSpeakService`,其余保持在应用包名,但要给 `FloatingHelper` / `OcrTileService` / `AccessibilityBridgeModule` 加跨包 import,改动点和潜在编译错误更多。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 数据流与触发路径
|
||||||
|
|
||||||
|
完整链路(手动识别路径,伪装生效后是主路径):
|
||||||
|
|
||||||
|
```
|
||||||
|
[微信账单详情页前台]
|
||||||
|
→ onAccessibilityEvent (SelectToSpeakService.kt)
|
||||||
|
→ 记录 topPackage/topActivity + 显示悬浮球 (FloatingHelper)
|
||||||
|
[用户点悬浮球「识别账单」]
|
||||||
|
→ triggerManualExtraction() (SelectToSpeakService.kt)
|
||||||
|
→ dumpAllTexts() ← 关键:覆盖所有窗口含 WebView 子窗口
|
||||||
|
→ rootInActiveWindow + dumpNodeTexts (递归 text + contentDescription)
|
||||||
|
→ 若为空,遍历 windows 兜底
|
||||||
|
→ emit "billingDebugNodes" {package, activity, signature, isManual:true, texts[]}
|
||||||
|
[JS 侧 _layout.tsx 监听 billingDebugNodes]
|
||||||
|
→ 仅处理 isManual=true 的消息
|
||||||
|
→ parseAndProcessAccessibilityTexts(texts, pkg) (automationPipeline.ts)
|
||||||
|
→ parseAccessibilityTexts → parseWechatTexts (accessibilityParser.ts)
|
||||||
|
→ 特征词「交易单号」/「退款单号」/「本服务由财付通提供」定位
|
||||||
|
→ 金额正则 ^([+-])?(\d+(\.\d{1,2})?)$ + 商户取金额前一节点
|
||||||
|
→ 命中 → handleIncomingBillEvent → 分类 + 规则匹配 → 生成 draft
|
||||||
|
→ App 前台:Alert 确认 / App 后台:showFloatingBill 原生浮窗
|
||||||
|
```
|
||||||
|
|
||||||
|
降级路径(兜底,伪装失败时):
|
||||||
|
```
|
||||||
|
[文本为空或解析未命中] → bridge.triggerManualOcr()
|
||||||
|
→ takeScreenshot → bitmapToBase64 (JPEG q60) → emit "billingScreenshot"
|
||||||
|
[JS 侧] → OcrProcessor (Layer1 规则 → Layer2 ONNX OCR → Layer3 AI 视觉)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键改造:`triggerManualExtraction` 必须用 `dumpAllTexts`
|
||||||
|
改造前 `triggerManualExtraction` 只读 `rootInActiveWindow`,漏掉 WebView 子窗口。伪装生效后这条路径成为主识别路径,必须保证完整性,所以改用现成的 `dumpAllTexts()`(先试 `rootInActiveWindow`,为空则遍历 `windows`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 踩过的坑
|
||||||
|
|
||||||
|
### 坑 1:package 重写正则不精确,多出 `.accessibility`
|
||||||
|
**现象**:首次 prebuild 后,kt 文件 package 声明变成 `com.google.android.accessibility.selecttospeak.accessibility`(多了 `.accessibility`),与 Manifest 的 `com.google.android.accessibility.selecttospeak.SelectToSpeakService` 不一致 → 编译期 `ClassNotFoundException`。
|
||||||
|
|
||||||
|
**根因**:源码 package 是 `com.beancount.mobile.accessibility`(有 `.accessibility` 后缀),但正则只写了 `com\.beancount\.mobile`,替换后保留了原有的 `.accessibility`。
|
||||||
|
|
||||||
|
**修复**:正则精确匹配到 `.accessibility`:
|
||||||
|
```js
|
||||||
|
// ❌ 错误:会留下 .accessibility 后缀
|
||||||
|
content.replace(/package\s+com\.beancount\.mobile/g, `package ${FAKE_PACKAGE}`)
|
||||||
|
// ✅ 正确:整体替换
|
||||||
|
content.replace(/package\s+com\.beancount\.mobile\.accessibility/g, `package ${FAKE_PACKAGE}`)
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证方法**:prebuild 后用 `head -1` 检查生成的 kt 文件 package 声明,与 Manifest `android:name` 的包名部分逐字对比。
|
||||||
|
|
||||||
|
### 坑 2:只伪装类名不伪装包名
|
||||||
|
早期实现 Manifest 服务名是 `${appId}.accessibility.SelectToSpeakService`,类名伪装了但包名是应用包名。微信按完整 `ComponentName` 判断,包名不在白名单 → 混淆照常下发。详见 §2。
|
||||||
|
|
||||||
|
### 坑 3:升级后服务标识变化,用户需重新启用
|
||||||
|
伪装改变服务的 `ComponentName`,系统无障碍设置里的服务标识随之变化。升级 APK 后,用户原本启用的服务会「失效」(实际是新服务没启用),需要重新在系统设置里启用「浮记-账单识别」。服务 `label` 保持 `浮记-账单识别` 不变(在 Manifest 硬编码),用户可识别。
|
||||||
|
|
||||||
|
> 这是预期行为,发布说明里需告知。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 验证方法论
|
||||||
|
|
||||||
|
代码改动后,按以下顺序验证(沙箱内能做的 vs 真机才能做的分开):
|
||||||
|
|
||||||
|
### 沙箱内(prebuild 后静态检查)
|
||||||
|
1. **kt 文件路径**:`find android/app/src/main/java/com/google/android/accessibility/selecttospeak -type f` 应列出 8 个 kt 文件,且**不应有**应用包名路径下的遗留 kt。
|
||||||
|
2. **package 声明一致性**:`head -1` 每个 kt 文件,都应是 `package com.google.android.accessibility.selecttospeak`。
|
||||||
|
3. **Manifest 服务名**:`grep "android:name=\"com.google.android.accessibility" android/app/src/main/AndroidManifest.xml` 应同时命中 `SelectToSpeakService` 和 `OcrTileService`,且包名部分与 kt 的 package 声明逐字一致。
|
||||||
|
4. **MainApplication import**:`grep "AccessibilityBridgePackage" android/app/src/main/java/*/MainApplication.kt` 应有 `import com.google.android.accessibility.selecttospeak.AccessibilityBridgePackage` 和 `add(AccessibilityBridgePackage())`。
|
||||||
|
5. **typecheck + 测试**:`npm run typecheck` 通过;`npm test` 无新增回归(无障碍改动不涉及 ts,测试应零影响)。
|
||||||
|
|
||||||
|
### 真机(功能验证)
|
||||||
|
1. `npm run android` 构建 APK 安装。
|
||||||
|
2. 系统设置 → 无障碍 → 启用「浮记-账单识别」。
|
||||||
|
3. 打开微信(≥ 8.0.52)→ 账单详情页。
|
||||||
|
4. 点悬浮球「识别账单」。
|
||||||
|
5. 看 Metro 终端的 `billingDebugNodes` 事件:
|
||||||
|
- **成功**:`texts` 数组含真实金额/商户/时间 → `parseWechatTexts` 自动解析入账。
|
||||||
|
- **失败**:`texts` 仍为空或乱码 → 自动降级 OCR 截图(兜底链路未动)。
|
||||||
|
6. 若伪装完全无效(texts 始终空),说明微信检测点不止 ComponentName(可能还查签名/其他特征),需进一步研究;此时 OCR 兜底仍在工作,不影响基础功能。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 合规与风险
|
||||||
|
|
||||||
|
- **侧载路线**:本仓库按 `plan.md` 决策 3 走纯开源侧载,伪装机制仅用于非应用商店分发。伪装系统服务包名、绕过微信反自动化检测,可能违反微信用户协议,存在账号风险。
|
||||||
|
- **TalkBack 副作用**:不要回退到伪装 TalkBack 的方案,小米机型会有持续文字干扰(§2)。
|
||||||
|
- **版本适配**:微信持续更新混淆策略。若某次微信升级后伪装失效,先查 AutoJs6 Issue、CSDN/掘金的最新讨论,确认白名单判断逻辑是否变化。
|
||||||
|
- **回退**:伪装失败时,OCR 截图兜底链路(`_layout.tsx` 的 `billingScreenshot` 监听 + `OcrProcessor`)完整保留,最小代价回退。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 关键文件清单
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `plugins/accessibility/app.plugin.js` | Config Plugin:文件复制、package rewrite、Manifest 注册、MainApplication import |
|
||||||
|
| `plugins/accessibility/android/SelectToSpeakService.kt` | 主服务:事件监听、`dumpAllTexts`/`dumpNodeTexts` 节点遍历、截图、悬浮球管理 |
|
||||||
|
| `plugins/accessibility/android/AccessibilityBridgeModule.kt` | RN Bridge:14 个 `@ReactMethod`,委托 `SelectToSpeakService.instance` |
|
||||||
|
| `plugins/accessibility/android/FloatingHelper.kt` | 悬浮球 UI,点「识别账单」调 `triggerManualExtraction` |
|
||||||
|
| `plugins/accessibility/android/FloatingBillView.kt` | 账单确认浮窗 |
|
||||||
|
| `plugins/accessibility/android/OcrTileService.kt` | 快速设置磁贴 |
|
||||||
|
| `plugins/accessibility/android/res/xml/accessibility_service_config.xml` | 服务能力声明(`canRetrieveWindowContent` / `canTakeScreenshot` / `canRequestEnhancedWebAccessibility`) |
|
||||||
|
| `src/services/automation/accessibilityParser.ts` | 微信/支付宝/银行文本解析(`parseWechatTexts` 等) |
|
||||||
|
| `src/services/automation/automationPipeline.ts` | 管道协调器(`parseAndProcessAccessibilityTexts`) |
|
||||||
|
| `src/app/_layout.tsx` | `DeviceEventEmitter` 监听 `billingDebugNodes` / `billingScreenshot` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 后续优化方向(未实施)
|
||||||
|
|
||||||
|
- **方案 C:`parseWechatTexts` 鲁棒性增强**。即使伪装生效,微信仍可能对部分节点做轻度扰动。可放弃「标签 + 下一节点」的顺序假设,改用 `getBoundsInScreen()` 的坐标和相对位置识别元素,或用 index 在层级中的位置而非属性。工作量大,建议伪装方案稳定后再立项。
|
||||||
|
- **自动监听路径恢复**。当前因节点混淆,自动监听(`processContentChange`)实质失效,仅手动悬浮球路径有效。伪装生效后可评估是否恢复自动监听,但需注意 `isManual` 标志和页面签名白名单的配合。
|
||||||
+288
-18
@@ -6,6 +6,51 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0. TL;DR — 改了原生代码后的完整重建流程
|
||||||
|
|
||||||
|
> 当你修改了 `plugins/*/android/` 下的任何 Kotlin/Java 原生源码、或 `app.plugin.js` 注入逻辑后,`android/` 目录里那份由 prebuild 生成的原生工程**已经过时**,必须重新生成才能让改动生效。这是最常见的「我改了代码但安装后没变化」的根因。
|
||||||
|
|
||||||
|
完整的三步重建命令(Git Bash,工作目录为项目根):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 环境变量(每次新开 shell 都要设;可写入 ~/.bashrc 持久化)
|
||||||
|
export JAVA_HOME="/c/Program Files/Java/jdk-21"
|
||||||
|
export ANDROID_HOME="/c/Users/fmq/AppData/Local/Android/Sdk"
|
||||||
|
export PATH="$ANDROID_HOME/platform-tools:$PATH" # 让 adb 可用
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **不想每次 export `ANDROID_HOME`?** 把 SDK 路径写进 `android/local.properties`(`sdk.dir=C\:\\Users\\fmq\\AppData\\Local\\Android\\Sdk`),gradle 会优先读它,前台/后台 shell 都不再依赖环境变量。该文件在 `.gitignore` 内、不进仓库。**后台/自动化编译尤其需要它**——后台新 shell 不继承交互会话里的 `ANDROID_HOME`,缺 `local.properties` 会直接 `SDK location not found` 失败(详见 §7.6)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ① 删除旧的原生工程,强制重新生成(确保原生改动被注入)
|
||||||
|
rm -rf android
|
||||||
|
npx expo prebuild --platform android
|
||||||
|
|
||||||
|
# ② 清理 Gradle 缓存后编译 Release(默认 arm64-v8a,真机最快)
|
||||||
|
cd android
|
||||||
|
./gradlew.bat clean --offline
|
||||||
|
./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --offline
|
||||||
|
|
||||||
|
# ③ 通过 adb 覆盖安装到已连接的真机
|
||||||
|
adb install -r -d app/build/outputs/apk/release/app-arm64-v8a-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
> **何时需要重新 prebuild?**
|
||||||
|
> - 改了 `plugins/*/android/*.kt`(任何 Config Plugin 的原生源码)
|
||||||
|
> - 改了 `plugins/*/app.plugin.js`(注入逻辑)
|
||||||
|
> - 改了 `app.json`(appId、权限、插件配置)
|
||||||
|
> - 在新机器上首次拉取代码
|
||||||
|
>
|
||||||
|
> **何时只需直接编译(无需 prebuild)?**
|
||||||
|
> - 只改了 `src/` 下的 TS/TSX(JS Bundle 由 Metro/assemble 自动打入)
|
||||||
|
> - 只改了 `android/app/build.gradle`、`gradle.properties` 等已生成的 Gradle 配置
|
||||||
|
>
|
||||||
|
> [!WARNING]
|
||||||
|
> **改了 TS 后,release 包偶尔不会更新**(见 §7):gradle 的 `createBundleReleaseJsAndAssets` 可能因为缓存跳过重新打包,或 Metro 用了 transformer cache。若发现"改了代码但设备上行为没变",**先按 §7.2 验证 bundle 是否真的含新代码**,而不是反复改代码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. 体积优化核心原理
|
## 1. 体积优化核心原理
|
||||||
|
|
||||||
### 1.1 ABI 分包 (ABI Splits)
|
### 1.1 ABI 分包 (ABI Splits)
|
||||||
@@ -31,11 +76,14 @@ reactNativeArchitectures=arm64-v8a
|
|||||||
```
|
```
|
||||||
* **为什么只包含 arm64-v8a**:目前 99% 的主流现代 Android 实体机都是 64 位 ARM 架构(`arm64-v8a`)。在进行日常分发与本地 Release 测试时,默认只编译 arm64-v8a,能够省去编译另外 3 个架构的机器指令时间,**编译速度提升 3~4 倍**,且输出的包体最小。
|
* **为什么只包含 arm64-v8a**:目前 99% 的主流现代 Android 实体机都是 64 位 ARM 架构(`arm64-v8a`)。在进行日常分发与本地 Release 测试时,默认只编译 arm64-v8a,能够省去编译另外 3 个架构的机器指令时间,**编译速度提升 3~4 倍**,且输出的包体最小。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **`-PreactNativeArchitectures` 与 ABI Splits 的关系**:当启用了 §1.1 的 `splits.abi` 后,`assembleRelease` 会**无条件生成所有 `include` 列出的架构分包 + universal 包**,`-PreactNativeArchitectures` 参数只能限制"编译哪几个架构的原生库",并不能减少最终输出的 APK 数量。若想只产出一个 arm64 包、跳过其它架构的编译耗时,最干净的做法是**注释掉 `app/build.gradle` 里的 `splits { abi { ... } }` 块**,让 `reactNativeArchitectures=arm64-v8a` 单独生效。
|
||||||
|
|
||||||
### 1.3 Expo 持续原生生成 (Config Plugin) 的自动应用
|
### 1.3 Expo 持续原生生成 (Config Plugin) 的自动应用
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> 由于 `android/` 目录被 Git 忽略,**不要直接手动在其他设备上提交原生配置变更**。
|
> 由于 `android/` 目录被 Git 忽略(见 `.gitignore`),**不要直接手动修改 `android/` 下的文件并提交**——它们是 prebuild 生成的产物,会在下次重建时丢失。
|
||||||
> 本项目已编写了专门的本地 Config Plugin:[size-optimization](file:///c:/Users/fmq/Documents/work/beancount-mobile/plugins/size-optimization/app.plugin.js)。
|
> 本项目已编写了专门的本地 Config Plugin:[size-optimization](file:///c:/Users/fmq/Documents/work/DriftLedger/plugins/size-optimization/app.plugin.js)。
|
||||||
> 当在新设备上重新 `git clone` 项目后,运行以下指令即可自动拉起 Config Plugin 并在重新生成的 `android/` 目录中完美注入上述所有的体积优化配置(ABI 分包、默认单架构编译):
|
> 当在新设备上重新 `git clone` 项目后,运行以下指令即可自动拉起所有 Config Plugin 并在重新生成的 `android/` 目录中完美注入上述所有的体积优化配置(ABI 分包、默认单架构编译、NDK 版本统一):
|
||||||
> ```bash
|
> ```bash
|
||||||
> npx expo prebuild --platform android
|
> npx expo prebuild --platform android
|
||||||
> ```
|
> ```
|
||||||
@@ -47,15 +95,24 @@ reactNativeArchitectures=arm64-v8a
|
|||||||
请在项目的根目录(若已在 `android/` 目录中则不需要前缀 `cd android`)执行以下指令:
|
请在项目的根目录(若已在 `android/` 目录中则不需要前缀 `cd android`)执行以下指令:
|
||||||
|
|
||||||
### 2.1 本地测试/实体分发(仅编译 arm64-v8a,最快最推荐)
|
### 2.1 本地测试/实体分发(仅编译 arm64-v8a,最快最推荐)
|
||||||
直接运行默认编译,会使用 `gradle.properties` 中配置 of `arm64-v8a`:
|
直接运行默认编译,会使用 `gradle.properties` 中配置的 `arm64-v8a`。
|
||||||
|
|
||||||
|
**Git Bash(推荐,与本文档 §0 的 TL;DR 一致):**
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME="/c/Program Files/Java/jdk-21"
|
||||||
|
export ANDROID_HOME="/c/Users/fmq/AppData/Local/Android/Sdk"
|
||||||
|
cd android
|
||||||
|
./gradlew.bat :app:assembleRelease --offline
|
||||||
|
```
|
||||||
|
|
||||||
|
**PowerShell:**
|
||||||
```powershell
|
```powershell
|
||||||
# 在 Windows Powershell 下执行:
|
|
||||||
$env:JAVA_HOME="C:\Program Files\Java\jdk-21"
|
$env:JAVA_HOME="C:\Program Files\Java\jdk-21"
|
||||||
$env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk"
|
$env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk"
|
||||||
cd android
|
cd android
|
||||||
.\gradlew.bat :app:assembleRelease --offline --no-daemon
|
.\gradlew.bat :app:assembleRelease --offline --no-daemon
|
||||||
```
|
```
|
||||||
编译完成后,可在以下路径找到适合真机安装的轻量版 APK(约 37MB):
|
编译完成后,可在以下路径找到适合真机安装的轻量版 APK(开启混淆后约 **24MB**):
|
||||||
* `android\app\build\outputs\apk\release\app-arm64-v8a-release.apk`
|
* `android\app\build\outputs\apk\release\app-arm64-v8a-release.apk`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -86,15 +143,228 @@ cd android
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 高级优化项:Proguard/R8 与混淆 (选填)
|
## 3. Proguard/R8 混淆(已默认开启)
|
||||||
如需进一步将独立包体积压缩到 25MB 左右,可以考虑在 `gradle.properties` 中开启混淆并做裁剪防御。
|
|
||||||
1. 在 `gradle.properties` 中添加:
|
本项目的 `gradle.properties` 已**默认启用**代码混淆与资源压缩:
|
||||||
```properties
|
```properties
|
||||||
android.enableMinifyInReleaseBuilds=true
|
android.enableMinifyInReleaseBuilds=true
|
||||||
android.enableShrinkResourcesInReleaseBuilds=true
|
android.enableShrinkResourcesInReleaseBuilds=true
|
||||||
```
|
```
|
||||||
2. 注意:由于引入了 `ONNX Runtime` 动态调用,如果运行崩溃,需要在 `android/app/proguard-rules.pro` 中加入如下混淆保留白名单:
|
这使得 arm64-v8a 独立包从 ~37MB 压缩到约 **24MB**。无需手动开启。
|
||||||
```proguard
|
|
||||||
-keep class com.microsoft.onnxruntime.** { *; }
|
> [!WARNING]
|
||||||
-dontwarn com.microsoft.onnxruntime.**
|
> 若新增了依赖反射/动态加载的库(如 `ONNX Runtime` 的 JNI 调用),混淆后可能出现运行时 `ClassNotFoundException`。此时需在 `android/app/proguard-rules.pro`(由 ppocr 等 Config Plugin 注入)中补充保留规则,例如:
|
||||||
```
|
> ```proguard
|
||||||
|
> -keep class com.microsoft.onnxruntime.** { *; }
|
||||||
|
> -dontwarn com.microsoft.onnxruntime.**
|
||||||
|
> ```
|
||||||
|
> 调试混淆问题时,可临时把上述两个属性改为 `false` 排查是否为混淆所致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 通过 adb 安装到真机
|
||||||
|
|
||||||
|
编译产物就绪后,用 adb 覆盖安装到已连接的设备(保留应用数据):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 确认设备已连接(USB 调试已开启)
|
||||||
|
adb devices
|
||||||
|
|
||||||
|
# 覆盖安装:-r 保留数据,-d 允许版本号不升(覆盖安装相同/更低 versionCode 时需要)
|
||||||
|
adb install -r -d android/app/build/outputs/apk/release/app-arm64-v8a-release.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
常见问题:
|
||||||
|
| 现象 | 原因与解决 |
|
||||||
|
|---|---|
|
||||||
|
| `adb: command not found` | adb 不在 PATH。Git Bash 下执行 `export PATH="/c/Users/fmq/AppData/Local/Android/Sdk/platform-tools:$PATH"` |
|
||||||
|
| `device offline` / 列表为空 | 手机未授权 USB 调试,或驱动未装;重新插拔并在手机弹窗点「允许」 |
|
||||||
|
| `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | 签名不一致(如之前装的是 debug 版)。先 `adb uninstall com.example.driftledger` 再装 |
|
||||||
|
| 装完打开白屏/闪退 | 多为混淆误删(见 §3)或原生库架构不匹配(模拟器需 x86_64 包) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 原生插件(Config Plugin)开发避坑指南
|
||||||
|
|
||||||
|
本项目通过 7 个 Expo Config Plugin(`plugins/*/app.plugin.js`)在 prebuild 时注入原生代码与配置。以下是踩过的坑:
|
||||||
|
|
||||||
|
### 5.1 跨插件包名一致性陷阱(无障碍伪装包)
|
||||||
|
|
||||||
|
**背景**:为绕过微信 8.0.52+ 的节点混淆,`accessibility` 插件把 7 个 Kotlin 文件整体迁移到伪装包 `com.google.android.accessibility.selecttospeak`(伪装成系统「随说随读」服务),并在注入时用正则把源码里的 `com.beancount.mobile.accessibility` 改写成这个伪装包名。
|
||||||
|
|
||||||
|
**陷阱**:`notification-listener` / `screenshot-monitor` / `sms-receiver` 这三个插件的 Kotlin 源码**也 import 了** `com.beancount.mobile.accessibility.{SelectToSpeakService, ReactContextHolder}`。它们各自的 `app.plugin.js` 有一条「通用包名替换」规则:
|
||||||
|
```js
|
||||||
|
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||||
|
```
|
||||||
|
这条规则会**无差别**地把 `com.beancount.mobile.accessibility` 也替换成 `appId.accessibility`,导致引用指向一个不存在的包,编译时报:
|
||||||
|
```
|
||||||
|
e: ... BillingNotificationListenerService.kt: Unresolved reference 'accessibility'
|
||||||
|
e: ... BillingNotificationListenerService.kt: Unresolved reference 'SelectToSpeakService'
|
||||||
|
```
|
||||||
|
|
||||||
|
**修复**(已在三个插件中落地):在通用替换**之前**,先把 accessibility 子包引用单独改写到伪装包:
|
||||||
|
```js
|
||||||
|
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
|
||||||
|
// 必须先改写 accessibility 子包引用,再做通用 com.beancount.mobile → appId 替换
|
||||||
|
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
|
||||||
|
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
|
||||||
|
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
> **教训**:任何插件如果要用正则做包名改写,必须**先处理跨插件共享的子包引用**(尤其是被「伪装/重命名」过的包),再做通配替换,否则通用规则会破坏跨插件依赖。
|
||||||
|
|
||||||
|
### 5.2 验证 prebuild 注入是否成功
|
||||||
|
|
||||||
|
prebuild 不会因「源码与注入结果不一致」而报错(它只是文件复制 + 字符串替换),所以注入错误只能在 gradle 编译时暴露。快速自查注入结果:
|
||||||
|
```bash
|
||||||
|
# 检查目标包名/类是否被注入到预期路径
|
||||||
|
find android/app/src/main/java -iname "*SelectToSpeak*" -o -iname "*ReactContext*"
|
||||||
|
# 对比源文件与注入文件的差异(应仅 package 声明行不同)
|
||||||
|
diff plugins/accessibility/android/SelectToSpeakService.kt \
|
||||||
|
android/app/src/main/java/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt
|
||||||
|
```
|
||||||
|
若编译报 `Unresolved reference`,先用上述命令确认类是否被注入、package 是否正确改写。
|
||||||
|
|
||||||
|
### 5.3 编译失败排查清单
|
||||||
|
|
||||||
|
| 错误特征 | 可能原因 | 排查 |
|
||||||
|
|---|---|---|
|
||||||
|
| `Unresolved reference 'XXX'` | 跨插件 import 包名改写不一致(见 §5.1) | 检查注入后文件的 `import` 行 |
|
||||||
|
| `Cannot resolve symbol` / 找不到 R 资源 | res/xml 未随 kt 一起注入 | 检查 `app/src/main/res/xml/` 是否有配置文件 |
|
||||||
|
| 改了 kt 但安装后行为没变 | 忘记重新 prebuild(android/ 是旧的) | `rm -rf android && npx expo prebuild` |
|
||||||
|
| `Execution failed ... mergeReleaseResources` | 资源 ID 冲突 / strings.xml 重复注入 | 检查插件是否做了幂等判断(`if (!content.includes(...))`) |
|
||||||
|
| `Argument type mismatch: 'Float', but 'Double' was expected`(多在 `Math.ceil`/`Math.floor` 处) | `java.lang.Math.ceil`/`floor` **只有 double 重载**,Kotlin 传 Float 不会自动提升 | 改 `x.toDouble()`。注意 `Math.round` 同时有 float/double 两个重载,所以 `Math.round(Float)` 不报错——别误以为 `ceil` 也能直接传 Float |
|
||||||
|
|
||||||
|
### 5.4 只改单个原生源文件时的快速同步(免全量 prebuild)
|
||||||
|
|
||||||
|
§0 的全量重建要 `rm -rf android && prebuild`,慢且扰动整个原生工程。当**只改了某个 `plugins/<x>/android/*.kt` 的内容**(没改 `app.plugin.js` 注入逻辑、没新增/删除文件、没改 assets、没改 app.json)时,可手动把改后的源同步到 android 副本,省去全量 prebuild。Config Plugin 在 prebuild 时对 .kt 做的事 = 复制 + 把 `package`/`import` 里的 `com.beancount.mobile` 替换成 appId,手动等价如下(appId 以 `app.json` 的 `android.package` 为准,本项目为 `com.example.driftledger`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 例:只改了 plugins/ppocr/android/OcrModule.kt
|
||||||
|
python -c "
|
||||||
|
s=open('plugins/ppocr/android/OcrModule.kt',encoding='utf-8').read()
|
||||||
|
s=s.replace('com.beancount.mobile','com.example.driftledger')
|
||||||
|
open('android/app/src/main/java/com/example/driftledger/ppocr/OcrModule.kt','w',encoding='utf-8',newline='\n').write(s)
|
||||||
|
"
|
||||||
|
# 然后直接编译(无需 prebuild;改的是 .kt,Metro bundle 不受影响)
|
||||||
|
cd android && ./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --offline --no-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 这条捷径**只对「改 .kt 内容」成立**。以下情况**必须**走 §0 全量 prebuild,否则 android/ 副本与注入结果不一致:改了 `app.plugin.js` 注入/复制逻辑;新增或删除 `.kt`/资源文件(手动同步不会更新 `MainApplication` 的 `add(...)` 注入或 res 复制);改了 `assets/`(模型/字典)或 `app.json`。同步后务必 `grep` 副本确认 `package` 行已是 appId、且无残留 `com.beancount.mobile`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 常用速查
|
||||||
|
|
||||||
|
| 目标 | 命令 |
|
||||||
|
|---|---|
|
||||||
|
| 改了 TS 后热更新(无需重编) | `npm run start` → 手机摇一摇 Reload |
|
||||||
|
| 改了原生后重建并安装 | 见 §0 TL;DR 三步 |
|
||||||
|
| 只看编译是否通过(不出 APK) | `./gradlew.bat :app:compileReleaseKotlin --offline` |
|
||||||
|
| 查看连接的设备 | `adb devices -l` |
|
||||||
|
| 查看应用日志 | `adb logcat *:S ReactNativeJS:V ReactNative:V` |
|
||||||
|
| 卸载应用 | `adb uninstall com.example.driftledger` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Release 构建陷阱(改了 TS 却没生效?看这里)
|
||||||
|
|
||||||
|
> 这是项目中最坑、最耗时的问题之一。症状:**改了 `src/` 下的 TS/TSX,编译成功,安装到手机,但行为毫无变化**——仿佛代码没改。这几乎总是 JS Bundle 缓存或 Windows 文件锁导致的。
|
||||||
|
|
||||||
|
### 7.1 根因分类
|
||||||
|
|
||||||
|
| 根因 | 机制 | 表现 |
|
||||||
|
|---|---|---|
|
||||||
|
| **gradle bundle task 缓存** | `:app:createBundleReleaseJsAndAssets` 基于输入快照判定 UP-TO-DATE,即使删了 bundle 输出文件,gradle 的 task snapshot 仍认为"最新",跳过打包 | `./gradlew :app:createBundleReleaseJsAndAssets` 显示 `UP-TO-DATE` |
|
||||||
|
| **Metro transformer cache** | Metro 对每个源文件缓存编译结果,命中就用旧版。Windows 下缓存在 `%LOCALAPPDATA%/Temp/metro-cache` 和 `metro-file-map-*` | bundle 时间戳更新了,但内容不含新代码 |
|
||||||
|
| **Windows 文件锁** | apk/打包中间产物被 adb、杀毒软件、Explorer 预览占用,gradle 无法写入/删除 | `packageRelease FAILED` / `externalNativeBuildCleanRelease FAILED` / "另一个程序正在使用此文件" |
|
||||||
|
| **设备跑旧 APK** | `adb install -r` 时 USB 断开/授权失效,实际没装上 | `adb: no devices` 或 `Success` 但应用没更新 |
|
||||||
|
|
||||||
|
### 7.2 验证 bundle 是否含新代码(关键!)
|
||||||
|
|
||||||
|
**遇到"改了没生效",第一步永远是验证 bundle,而不是反复改代码。** 项目用 Hermes 字节码,bundle 是二进制。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BUNDLE="android/app/build/generated/assets/createBundleReleaseJsAndAssets/index.android.bundle"
|
||||||
|
# ⚠️ 必须用 grep -a(强制文本模式),因为 Hermes bundle 是二进制,
|
||||||
|
# 默认 grep 会跳过二进制文件,导致永远匹配 0 → 误判"代码没进去"
|
||||||
|
grep -a -c "你新加的字符串常量" "$BUNDLE"
|
||||||
|
# 例:grep -a -c "keyboardDidShow" "$BUNDLE" → 应 ≥1
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **绝对不要用 `grep -c "..."`(不带 -a)验证 Hermes bundle。** 字符串常量(如事件名 `'keyboardDidShow'`、组件名)在字节码里是明文存的,minify 不会改变,用 `grep -a` 能可靠检测。本项目曾因误用 `grep`(不加 -a)反复重编 5 次,浪费大量时间,根因竟是验证方法错了。
|
||||||
|
|
||||||
|
如果 `grep -a` 确认 bundle 含新代码但设备行为没变 → 是「设备跑旧 APK」问题(重装/清数据)。
|
||||||
|
如果 bundle **不含**新代码 → 是「gradle/Metro 缓存」问题,按 §7.3 处理。
|
||||||
|
|
||||||
|
### 7.3 强制 bundle 重新生成的可靠步骤
|
||||||
|
|
||||||
|
按顺序尝试,通常第 1 步即可:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "/c/Users/fmq/Documents/work/DriftLedger"
|
||||||
|
|
||||||
|
# ① 删除 bundle 输出 + sourcemap,让 gradle 的 bundle task 不再 UP-TO-DATE
|
||||||
|
rm -f android/app/build/generated/assets/createBundleReleaseJsAndAssets/index.android.bundle
|
||||||
|
rm -f android/app/build/generated/sourcemaps/react/release/index.android.bundle.map
|
||||||
|
|
||||||
|
# ② 若 ① 无效(task 仍 UP-TO-DATE):手动删除整个 app/build(绕过 gradle clean,
|
||||||
|
# 因为 clean 常因 CMake/文件锁失败而中断,反而没清掉 bundle)
|
||||||
|
rm -rf android/app/build
|
||||||
|
|
||||||
|
# ③ 若仍无效(bundle 生成但内容旧):清 Metro 缓存(Windows 多处)
|
||||||
|
rm -rf node_modules/.cache .expo
|
||||||
|
rm -rf "$LOCALAPPDATA/Temp/metro-cache" "$LOCALAPPDATA/Temp/metro-file-map-"*
|
||||||
|
rm -rf "$LOCALAPPDATA/Temp/1/metro-cache" "$LOCALAPPDATA/Temp/1/metro-file-map-"*
|
||||||
|
|
||||||
|
# ④ 重新编译(带 --no-daemon 可规避 daemon 缓存与锁)
|
||||||
|
cd android
|
||||||
|
export JAVA_HOME="/c/Program Files/Java/jdk-21"
|
||||||
|
./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --offline --no-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
验证打包成功后 bundle 是否更新(§7.2),再安装。
|
||||||
|
|
||||||
|
### 7.4 Windows 文件锁处理
|
||||||
|
|
||||||
|
`packageRelease FAILED` 或 `clean FAILED`("另一个程序正在使用此文件")时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 杀掉占用进程(adb/Explorer 预览/杀毒扫描最常见)
|
||||||
|
taskkill //F //IM adb.exe
|
||||||
|
taskkill //F //IM java.exe
|
||||||
|
sleep 2
|
||||||
|
# 重试对应的失败 task(不必全量重编)
|
||||||
|
./gradlew.bat :app:packageRelease -PreactNativeArchitectures=arm64-v8a --offline
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> - **不要用 `gradlew clean`**:它依赖 `externalNativeBuildCleanRelease`,该 task 在本项目(含原生 CMake 库)极易因文件锁失败,导致 clean 中断、bundle 也没清掉。改用 `rm -rf app/build` 更可靠。
|
||||||
|
> - **不要并发跑多个 gradle 进程**:会互相锁文件。编译前 `taskkill //F //IM java.exe` 清理。
|
||||||
|
> - **`adb install` 前确认设备在线**:`adb devices -l`,列表为空说明 USB 断开或授权失效,`install` 会失败或装到错误的设备。
|
||||||
|
|
||||||
|
### 7.5 排查决策树
|
||||||
|
|
||||||
|
```
|
||||||
|
改了 TS,编译安装后行为没变
|
||||||
|
├─ grep -a 验证 bundle 含新代码?(§7.2)
|
||||||
|
│ ├─ 不含 → gradle/Metro 缓存 → §7.3 强制重生成
|
||||||
|
│ └─ 含 → 设备跑的是旧 APK
|
||||||
|
│ ├─ adb devices 确认在线 → adb install -r -d 重装
|
||||||
|
│ └─ 仍不行 → 卸载重装:adb uninstall com.example.driftledger && adb install <apk>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.6 后台 / 自动化编译陷阱
|
||||||
|
|
||||||
|
在 CI、IDE 后台任务、或 agent 的后台 shell 里跑 gradle 时,有几个交互会话遇不到的坑:
|
||||||
|
|
||||||
|
| 陷阱 | 现象 | 解决 |
|
||||||
|
|---|---|---|
|
||||||
|
| **后台 shell 不继承 `ANDROID_HOME`** | `Failed to apply plugin 'com.facebook.react.rootproject'` → `SDK location not found ... local.properties` | 写 `android/local.properties` 的 `sdk.dir`(见 §0 TIP),一劳永逸,不依赖 env |
|
||||||
|
| **命令接 `\| tail`/`\| head` 管道掩盖退出码** | gradle 实际 `BUILD FAILED`,但管道让 shell 退出码 = `tail` 的 0,误判成功;APK 时间戳其实是旧的 | 后台编译**不要接管道**,让退出码真实反映 gradle;判断成败靠读日志的 `BUILD SUCCESSFUL/FAILED` + 核对 APK 时间戳,而非 `$?` |
|
||||||
|
| **Git Bash 下 `grep -E` 报 `conflicting matchers specified`** | 用 `-E` 组合多模式直接报错退出 | 该环境 grep 别名冲突;改用 `grep -e A -e B`、`sed -n` 或多次 `grep` 串联 |
|
||||||
|
| **`adb: command not found`** | 后台/新 shell 的 PATH 没有 platform-tools | 用全路径 `$ANDROID_HOME/platform-tools/adb.exe`,或 `export PATH=...` |
|
||||||
|
|
||||||
|
> **验证后台编译是否真成功的三重核对**:① stdout 含 `BUILD SUCCESSFUL`;② stderr 无 `^e: ` 开头的 Kotlin 编译错误(编译错误走 stderr,stdout 往往只有 `> Task :app:compileReleaseKotlin FAILED`);③ APK 文件时间戳晚于本次编译开始时间。三者缺一即视为失败——尤其别被 `| tail` 的假成功骗到。
|
||||||
|
|||||||
@@ -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 字段本地 |
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Beancount Mobile 前端 UI 全面重设计 Spec
|
||||||
|
|
||||||
|
- 日期:2026-07-21
|
||||||
|
- 状态:已确认(经逐节评审)
|
||||||
|
- 范围:`src/app`、`src/components`、`src/theme`、`design-system/`;**不动** `src/domain`、`src/storage`、`src/services`、`plugins/`
|
||||||
|
|
||||||
|
## 1. 背景与问题
|
||||||
|
|
||||||
|
对现有 UI 的摸底发现以下问题(按严重度):
|
||||||
|
|
||||||
|
1. **品牌色混乱**:代码主题 accent = 靛蓝 `#4F46E5`,`design-system/beancount-mobile/MASTER.md` 规定 CTA = 绿 `#059669`、背景深蓝 `#0F172A`(代码暗色实为 OLED 纯黑 `#040508`)。设计文档与实现脱节。
|
||||||
|
2. **图标体系三套并存**:Ionicons(主力)+ emoji 分类图标(CategoryPicker)+ MASTER.md 要求的 Lucide/Heroicons(未落地)。
|
||||||
|
3. **硬编码颜色绕过 token**:首页 Hero 卡片写死白字;`CATEGORY_COLORS`、标签默认色 `#2196F3`、渠道色 `#1677FF/#07C160/#E53935`。
|
||||||
|
4. **样式重复爆炸**:40+ 处 `StyleSheet.create`;`header` 样式在 ~15 个页面重复;8 个管理页(账户/分类/标签/预算/周期/规则/信用卡/备注模板)是同一"列表+FormModal"模式却各自实现。
|
||||||
|
5. **录入流程偏长**:方向 chip → 金额 → 账户 → 分类网格 → 折叠详情,不是金额优先;无 KeyboardAvoidingView;日期靠手输 `YYYY-MM-DD`。
|
||||||
|
6. **信息架构问题**:设置页 13 入口平铺;报表页周/月/年三套独立状态;年报内嵌月报与月 Tab 重复;AI/导出图标无文字标签。
|
||||||
|
7. **Header 策略不统一**:二级页手写返回栏,唯独 transaction/new 用原生 header。
|
||||||
|
|
||||||
|
## 2. 已确认的关键决策
|
||||||
|
|
||||||
|
| 决策点 | 结论 |
|
||||||
|
|---|---|
|
||||||
|
| 重设计深度 | **全面重做**(所有页面) |
|
||||||
|
| 视觉方向 | **明亮 Bento 现代风**:浅色为主 + 大圆角黑白对比,财务语义色点缀 |
|
||||||
|
| 暗色模式 | **完整保留**(OLED 纯黑,与浅色对等) |
|
||||||
|
| 记一笔交互 | **金额优先数字键盘面板**(NumpadSheet) |
|
||||||
|
| 底部导航 | **中央凸起 +**,4 个内容 Tab(首页/交易/报表/我的) |
|
||||||
|
| 实施策略 | **设计系统先行,逐页替换**(5 个阶段) |
|
||||||
|
|
||||||
|
## 3. 视觉语言(Design Tokens)
|
||||||
|
|
||||||
|
### 3.1 色板
|
||||||
|
|
||||||
|
**浅色(默认)**:
|
||||||
|
|
||||||
|
| Token | 值 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| bgPrimary | `#F6F7F9` | 页面背景 |
|
||||||
|
| bgSecondary | `#FFFFFF` | 卡片 |
|
||||||
|
| bgTertiary | `#EFF1F4` | 输入框/chip |
|
||||||
|
| fgPrimary | `#111318` | 主文字 |
|
||||||
|
| fgSecondary | `#6B7280` | 次要文字 |
|
||||||
|
| fgInverse | `#FFFFFF` | 反色文字 |
|
||||||
|
| accent | `#111318` | **近黑**,按钮/选中态 |
|
||||||
|
| accentLight / accentDark | 重定义为中性色阶:accentLight = accent 8% 透明度的底色(选中高亮),accentDark = accent 的按压加深态 | 高亮背景/按压态 |
|
||||||
|
| financial.income | `#10B981` | 收入 |
|
||||||
|
| financial.expense | `#EF4444` | 支出 |
|
||||||
|
| financial.transfer | `#3B82F6` | 转账 |
|
||||||
|
|
||||||
|
**暗色(OLED,完整对等)**:
|
||||||
|
|
||||||
|
| Token | 值 |
|
||||||
|
|---|---|
|
||||||
|
| bgPrimary | `#040508` |
|
||||||
|
| bgSecondary | `#101218` |
|
||||||
|
| bgTertiary | `#1A1D24` |
|
||||||
|
| fgPrimary / accent | `#F3F4F6`(accent 反转为白) |
|
||||||
|
| fgSecondary | `#9CA3AF` |
|
||||||
|
| financial.income | `#34D399`(提亮) |
|
||||||
|
| financial.expense | `#F87171`(提亮) |
|
||||||
|
| financial.transfer | `#60A5FA`(提亮) |
|
||||||
|
|
||||||
|
原则:**accent 从靛蓝改为近黑白单色**,品牌感靠排版与财务语义色表达;对比度 ≥ 4.5:1;暗色下用 1px 半透边框代替阴影。
|
||||||
|
|
||||||
|
### 3.2 字体 / 圆角 / 阴影 / 图标
|
||||||
|
|
||||||
|
- **移除 Caveat/Quicksand 自定义字体**,改系统字体(iOS SF / Android Roboto),消除字体加载失败风险;删除 `_layout.tsx` 字体加载代码。
|
||||||
|
- 金额数字统一 `fontVariant: ['tabular-nums']`。
|
||||||
|
- 字阶:display 34 / h1 28 / h2 22 / h3 17 / body 16 / bodySmall 14 / caption 12。
|
||||||
|
- 圆角:sm 8 / md 12 / lg 16 / **xl 24(卡片默认)** / full。
|
||||||
|
- 阴影减重:浅色 2–12px 弥散阴影;暗色以边框代替。
|
||||||
|
- **图标统一 Ionicons**;全部 emoji 图标替换为 `CategoryIcon`(Ionicon + 圆形彩色底)。
|
||||||
|
- 硬编码分类色收归 `theme.categoryPalette`(12 色循环);渠道色、标签默认色等一并 token 化。
|
||||||
|
- `design-system/beancount-mobile/MASTER.md` 重写以匹配实现,结束"两份宪法"。
|
||||||
|
|
||||||
|
## 4. 核心交互:NumpadSheet 记一笔面板
|
||||||
|
|
||||||
|
### 4.1 结构(一屏完成 90% 记账)
|
||||||
|
|
||||||
|
自上而下:方向 chip(支出/收入/转账) → 大金额显示(等宽数字) → **账户 chip 行** → 分类快捷网格(4 列,常用分类 + "全部") → 内嵌数字键盘(0-9、小数点、⌫、**+/- 连续计算**(如 20+15 直接出 35)、"今天"日期键、完成键)。下滑展开"更多"抽屉:备注、标签、日期、高级模式(PostingEditor)入口。原 SpeedDial 的 OCR/导入入口移入面板顶部工具行。
|
||||||
|
|
||||||
|
### 4.2 两个账户(复式记账的"两条腿")
|
||||||
|
|
||||||
|
| 方向 | 腿 1 | 腿 2 |
|
||||||
|
|---|---|---|
|
||||||
|
| 支出 | 资金来源账户(Assets/Liabilities,账户 chip 行,"从") | 分类账户(Expenses:*,分类网格) |
|
||||||
|
| 收入 | 分类账户(Income:*,分类网格) | 到账账户(Assets,账户 chip 行,"到") |
|
||||||
|
| 转账 | 转出账户(chip 行) | 转入账户(第二 chip 行 + ⇅ 互换按钮),分类网格隐藏 |
|
||||||
|
|
||||||
|
配套规则:
|
||||||
|
|
||||||
|
- 账户 chip 行显示最常用的 3~4 个 Assets/Liabilities 账户(按使用频率排序),**默认选中上次使用的账户**(settingsStore 持久化),分类同理;"⋯"弹出全部账户树。
|
||||||
|
- 转账双方限定 Assets/Liabilities 账户(与 transferRecognizer 校验一致);还信用卡 = 转账到 Liabilities 账户,天然支持。
|
||||||
|
- 落账走 `buildAndSaveTransaction()` → BillPipeline,与手动/自动渠道完全一致,不产生第二套写入逻辑。
|
||||||
|
- 无 Assets 账户时 chip 行显示"去创建账户"引导,不死锁。
|
||||||
|
- 完成键上方可选显示分录预览 `Assets:招行 → Expenses:餐饮 ¥35`(可在设置关闭)。
|
||||||
|
- 编辑模式按 posting 方向回填两条腿。
|
||||||
|
- P3 阶段先在设置加"新版录入"开关灰度,稳定后默认开启。
|
||||||
|
|
||||||
|
## 5. 导航
|
||||||
|
|
||||||
|
自定义 **AppTabBar**(替换 expo-router 默认 tabBar):4 个内容 Tab(首页/交易/报表/我的)+ 中央凸起 +。+ **不是路由**,在任何页面唤起全局 Modal(NumpadSheet),不丢失上下文。**SpeedDial 退役**。
|
||||||
|
|
||||||
|
## 6. 核心组件库(P2 交付物)
|
||||||
|
|
||||||
|
新增:
|
||||||
|
|
||||||
|
- **NumpadSheet** — 见第 4 节
|
||||||
|
- **AppTabBar** — 见第 5 节
|
||||||
|
- **ScreenHeader** — 统一二级页头(返回 + 标题 + 右操作位),替换十几份手写返回栏;transaction/new 的原生 header 一并撤掉
|
||||||
|
- **StatCard** — "标题 + 大数字 + caption"统计卡(首页/报表/年报共用)
|
||||||
|
- **ManagementScreen** — 管理页模板(ScreenHeader++ / 可选分组 Tab / FlatList / 删除确认 / FormModal),8 个管理页共用,各页只声明字段配置 + 数据读写 hooks
|
||||||
|
- **DatePickerField** — 日历选择器,终结手输 `YYYY-MM-DD`
|
||||||
|
- **FilterSheet** — 底部弹层高级筛选(账户/日期/金额区间)
|
||||||
|
- **CategoryIcon** — Ionicon + 彩色圆底,替换 emoji
|
||||||
|
|
||||||
|
改造/退役:Button/Card/Chip/SearchBar 按新 token 重刷;CategoryPicker 改底部弹层网格并 Ionicon 化;FormModal 的 "✕" 字符换 Ionicons;transaction/new 整页重写为 NumpadSheet 宿主(编辑模式复用同面板)。
|
||||||
|
|
||||||
|
## 7. 逐页重设计要点
|
||||||
|
|
||||||
|
### 7.1 首页:从"数据墙"到"今日视角"
|
||||||
|
|
||||||
|
- 顶部:日期 + 问候语(替代应用名标题)。
|
||||||
|
- 净资产卡:去 accent 底色改白卡 + tabular 大数字,下方一行小字:本月支出 / 收入 / 预算剩余。
|
||||||
|
- 新增**待办条**:周期记账到期、信用卡还款提醒、未确认自动账单——首页回答"今天我要做什么"。
|
||||||
|
- 最近 5 条交易 + "查看全部 →"。
|
||||||
|
- 月度趋势卡(TrendLine 重刷新色板)。
|
||||||
|
- 账户余额树下沉到「我的」页。
|
||||||
|
|
||||||
|
### 7.2 交易页:搜索优先 + 分组时间线
|
||||||
|
|
||||||
|
- 搜索框常驻;方向筛选 chip 保留;高级筛选收进 FilterSheet。
|
||||||
|
- 列表**按日期分组**(今天/昨天/具体日期),组头显示当日收支小计。
|
||||||
|
- TransactionCard 重刷:CategoryIcon 圆底图标 + 商户/备注 + 账户小字 + 右侧等宽金额(支出黑色、收入绿色带 + 号,降低色彩噪音)。
|
||||||
|
- 左滑卡片:快捷"再记一笔(复制)/ 删除"。
|
||||||
|
- 解析诊断从页面底部移入设置 → 数据组。
|
||||||
|
|
||||||
|
### 7.3 报表页:统一时间导航 + 去重
|
||||||
|
|
||||||
|
- 周/月/年 Tab 保留;三套独立状态(viewYear/viewMonth/viewWeekDate)合并为**单一 anchor 日期 + 周期类型**,左右箭头统一切换。
|
||||||
|
- 删除年报内嵌的 MonthlyReport(与月 Tab 重复);年报只留:年度收支汇总、月度节奏迷你图、Top 分类。
|
||||||
|
- AI 总结/导出入口加文字标签,收进 "⋯" 菜单。
|
||||||
|
- CalendarView 保留在月 Tab,配色收归 token;热力图用 expense 色 5 级透明度。
|
||||||
|
- CategoryPie / StatCard / NetWorthChart 统一新 token。
|
||||||
|
|
||||||
|
### 7.4 设置 →「我的」:4 分组重构
|
||||||
|
|
||||||
|
- **账户与分类**:账户树(含余额,从首页移来)、分类、标签、信用卡、备注模板。
|
||||||
|
- **记账自动化**:规则、周期记账、自动记账通道(无障碍/通知/短信)、导入。
|
||||||
|
- **数据**:同步(WebDAV/Git/iCloud)、备份恢复、导出、解析诊断。
|
||||||
|
- **偏好**:主题、语言、应用锁、AI 设置、每日提醒、关于。
|
||||||
|
- 每组一张 Bento 卡,条目 = 图标 + 名称 + 右箭头;所有二级页用统一 ScreenHeader。
|
||||||
|
|
||||||
|
### 7.5 管理页模板化
|
||||||
|
|
||||||
|
账户/分类/标签/预算/周期/规则/信用卡/备注模板 8 页统一套 ManagementScreen,预计删除上千行重复代码。
|
||||||
|
|
||||||
|
## 8. 阶段计划
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 验收 |
|
||||||
|
|---|---|---|
|
||||||
|
| P1 设计系统 | 新 tokens → 双主题 presets → 移除自定义字体 → categoryPalette → 重写 MASTER.md | typecheck 通过;token 名不变只改值,旧组件接口兼容 |
|
||||||
|
| P2 组件库 | ScreenHeader / StatCard / AppTabBar / CategoryIcon / DatePickerField / FilterSheet / ManagementScreen | 组件调试页可逐个查看;纯逻辑单测 |
|
||||||
|
| P3 录入闭环 | NumpadSheet + 双腿账户选择 + 全局+ + transaction/new 重写 + SpeedDial 退役;"新版录入"开关灰度 | 手动记账/编辑/转账/还款全走面板;pipeline 集成测试不回归 |
|
||||||
|
| P4 四个 Tab | 首页(待办条)/ 交易(时间线+左滑)/ 报表(anchor 统一)/ 我的(4 分组) | 逐页替换,每页替换后全量测试 |
|
||||||
|
| P5 管理页+收尾 | 8 页套模板;图表重刷;emoji 清零;硬编码色清零(grep 审计);无障碍标签补全 | `#[0-9A-Fa-f]{6}` 在 src/ 下只剩 presets.ts 与 categoryPalette |
|
||||||
|
|
||||||
|
## 9. 测试策略
|
||||||
|
|
||||||
|
- **不动 domain 层**:billPipeline/dedup/rules 等纯逻辑零改动,现有 30+ 单测是安全网,必须保持全绿。
|
||||||
|
- 新增纯逻辑单测:numpad 表达式求值(+/- 连续计算)、双腿账户解析(方向 → posting 映射)、anchor 日期导航(周/月/年加减)。
|
||||||
|
- 每阶段结束跑 `npm test` + `npm run typecheck`;UI 层靠深浅双主题手工走查清单。
|
||||||
|
- 硬编码色审计:grep 全量扫描。
|
||||||
|
|
||||||
|
## 10. 风险与 YAGNI
|
||||||
|
|
||||||
|
风险对策:
|
||||||
|
|
||||||
|
- 主题切换过渡期"半新半旧" → P1 保持 token 名不变只改值,组件接口向后兼容。
|
||||||
|
- NumpadSheet 全新交互 → 设置开关灰度后再默认开启。
|
||||||
|
- 移除字体无数据迁移,删加载代码即可。
|
||||||
|
|
||||||
|
明确不做:自定义主题编辑器(保留 light/dark/system 三档);迁移图标库到 Lucide;改 domain/存储/同步层;平板/桌面布局适配。
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
# UI 重设计 P1:设计系统 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 将主题系统切换到「明亮 Bento 现代风」新设计令牌(双主题),移除自定义字体,硬编码颜色收归 palette,并重写 MASTER.md 使设计文档与实现一致。
|
||||||
|
|
||||||
|
**Architecture:** 只改 `src/theme/`、`src/app/_layout.tsx`(字体加载)、`src/domain/channelConfig.ts`(品牌色引用)、`src/components/CategoryPicker.tsx`(色板引用)、`design-system/`。Token 名不变只改值,组件接口向后兼容,现有页面自动"粗换皮"。
|
||||||
|
|
||||||
|
**Tech Stack:** React Native + Expo + TypeScript + Vitest。
|
||||||
|
|
||||||
|
**Spec:** `docs/ui-redesign-design.md` §3(视觉语言)、§10(P1 验收)。
|
||||||
|
|
||||||
|
**验收标准:** `npm test` 全绿;`npm run typecheck` 通过;`src/` 下不再引用 `@expo-google-fonts/*`;`presets.ts` 的 typography 无 `fontFamily`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 新设计令牌的失败测试
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/theme.test.ts`
|
||||||
|
- Create: `tests/palette.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 重写 tests/theme.test.ts 的断言以匹配新令牌**
|
||||||
|
|
||||||
|
完整替换文件内容为:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { lightTheme, darkTheme, presetThemes } from '../src/theme/presets';
|
||||||
|
import { createTheme } from '../src/theme/createTheme';
|
||||||
|
import type { ThemeTokens } from '../src/theme/tokens';
|
||||||
|
|
||||||
|
describe('预置主题(明亮 Bento 设计系统)', () => {
|
||||||
|
it('lightTheme 与 darkTheme 关键色彩不同', () => {
|
||||||
|
expect(lightTheme.colors.bgPrimary).not.toBe(darkTheme.colors.bgPrimary);
|
||||||
|
expect(lightTheme.colors.fgPrimary).not.toBe(darkTheme.colors.fgPrimary);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('浅色背景为 #F6F7F9,暗色为 OLED 纯黑 #040508', () => {
|
||||||
|
expect(lightTheme.colors.bgPrimary).toBe('#F6F7F9');
|
||||||
|
expect(darkTheme.colors.bgPrimary).toBe('#040508');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accent 为近黑白单色:浅色 #111318,暗色反转为 #F3F4F6', () => {
|
||||||
|
expect(lightTheme.colors.accent).toBe('#111318');
|
||||||
|
expect(darkTheme.colors.accent).toBe('#F3F4F6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accentLight 为 accent 的半透明底色(不再是靛蓝族)', () => {
|
||||||
|
expect(lightTheme.colors.accentLight).toBe('rgba(17,19,24,0.08)');
|
||||||
|
expect(darkTheme.colors.accentLight).toBe('rgba(243,244,246,0.10)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('财务语义色:暗色整体提亮一档', () => {
|
||||||
|
expect(lightTheme.colors.financial.income).toBe('#10B981');
|
||||||
|
expect(lightTheme.colors.financial.expense).toBe('#EF4444');
|
||||||
|
expect(lightTheme.colors.financial.transfer).toBe('#3B82F6');
|
||||||
|
expect(darkTheme.colors.financial.income).toBe('#34D399');
|
||||||
|
expect(darkTheme.colors.financial.expense).toBe('#F87171');
|
||||||
|
expect(darkTheme.colors.financial.transfer).toBe('#60A5FA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('圆角含 xl(24),卡片默认大圆角', () => {
|
||||||
|
for (const theme of [lightTheme, darkTheme]) {
|
||||||
|
expect(theme.radii.sm).toBe(8);
|
||||||
|
expect(theme.radii.md).toBe(12);
|
||||||
|
expect(theme.radii.lg).toBe(16);
|
||||||
|
expect(theme.radii.xl).toBe(24);
|
||||||
|
expect(theme.radii.full).toBe(9999);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('字阶含 display(34),且不指定 fontFamily(系统字体)', () => {
|
||||||
|
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
|
||||||
|
expect(theme.typography.display.fontSize).toBe(34);
|
||||||
|
expect(theme.typography.h1.fontSize).toBe(28);
|
||||||
|
expect(theme.typography.h2.fontSize).toBe(22);
|
||||||
|
expect(theme.typography.h3.fontSize).toBe(17);
|
||||||
|
for (const key of ['display', 'h1', 'h2', 'h3', 'body', 'bodySmall', 'caption'] as const) {
|
||||||
|
expect(theme.typography[key].fontFamily).toBeUndefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('presetThemes 注册表含 light 与 dark', () => {
|
||||||
|
expect(presetThemes.light).toBe(lightTheme);
|
||||||
|
expect(presetThemes.dark).toBe(darkTheme);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spacing/shadows 结构完整', () => {
|
||||||
|
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
|
||||||
|
expect(theme.spacing).toHaveProperty('xs');
|
||||||
|
expect(theme.spacing).toHaveProperty('xl');
|
||||||
|
expect(theme.shadows).toHaveProperty('sm');
|
||||||
|
expect(theme.shadows).toHaveProperty('lg');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createTheme 自定义主题工厂', () => {
|
||||||
|
it('未覆盖时等价于 lightTheme', () => {
|
||||||
|
const custom = createTheme({});
|
||||||
|
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
|
||||||
|
expect(custom.spacing).toEqual(lightTheme.spacing);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('覆盖 accent 颜色时其他颜色保留', () => {
|
||||||
|
const custom = createTheme({ colors: { ...lightTheme.colors, accent: '#FF5722' } });
|
||||||
|
expect(custom.colors.accent).toBe('#FF5722');
|
||||||
|
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
|
||||||
|
expect(custom.colors.success).toBe(lightTheme.colors.success);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('覆盖 financial 子对象时深度合并', () => {
|
||||||
|
const custom = createTheme({ colors: { ...lightTheme.colors, financial: { ...lightTheme.colors.financial, income: '#000' } } });
|
||||||
|
expect(custom.colors.financial.income).toBe('#000');
|
||||||
|
expect(custom.colors.financial.expense).toBe(lightTheme.colors.financial.expense);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('覆盖 spacing 部分键时其他键保留', () => {
|
||||||
|
const custom = createTheme({ spacing: { ...lightTheme.spacing, lg: 32 } });
|
||||||
|
expect(custom.spacing.lg).toBe(32);
|
||||||
|
expect(custom.spacing.md).toBe(lightTheme.spacing.md);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 新建 tests/palette.test.ts(分类色板 + 渠道品牌色)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CATEGORY_PALETTE, getCategoryColor, CHANNEL_BRAND_COLORS } from '../src/theme/palette';
|
||||||
|
|
||||||
|
describe('categoryPalette', () => {
|
||||||
|
it('色板为 12 色循环', () => {
|
||||||
|
expect(CATEGORY_PALETTE).toHaveLength(12);
|
||||||
|
for (const c of CATEGORY_PALETTE) expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('内置分类有具名颜色(与原 CategoryPicker 一致)', () => {
|
||||||
|
expect(getCategoryColor('food')).toBe('#F59E0B');
|
||||||
|
expect(getCategoryColor('transport')).toBe('#3B82F6');
|
||||||
|
expect(getCategoryColor('salary')).toBe('#22C55E');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未知分类 id 走哈希循环,结果确定且在色板内', () => {
|
||||||
|
const a = getCategoryColor('user_custom_abc');
|
||||||
|
expect(CATEGORY_PALETTE).toContain(a);
|
||||||
|
expect(getCategoryColor('user_custom_abc')).toBe(a); // 幂等
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空字符串 id 不崩溃', () => {
|
||||||
|
expect(CATEGORY_PALETTE).toContain(getCategoryColor(''));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('渠道品牌色常量', () => {
|
||||||
|
expect(CHANNEL_BRAND_COLORS.alipay).toBe('#1677FF');
|
||||||
|
expect(CHANNEL_BRAND_COLORS.wechat).toBe('#07C160');
|
||||||
|
expect(CHANNEL_BRAND_COLORS.bank).toBe('#E53935');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 运行测试确认失败**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/theme.test.ts tests/palette.test.ts`
|
||||||
|
Expected: FAIL —— `Cannot find module '../src/theme/palette'`,且 theme 断言多项不通过(旧色板)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/theme.test.ts tests/palette.test.ts
|
||||||
|
git commit -m "test: P1 设计系统新令牌与色板的失败测试"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: tokens.ts — display 字阶 + radii.xl
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/theme/tokens.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 修改 ThemeRadii 与 ThemeTypography**
|
||||||
|
|
||||||
|
`ThemeRadii` 改为(加 `xl`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ThemeRadii {
|
||||||
|
sm: number;
|
||||||
|
md: number;
|
||||||
|
lg: number;
|
||||||
|
xl: number; // 卡片默认大圆角(24)
|
||||||
|
full: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ThemeTypographyEntry` 的 `fontFamily` 标记废弃(保留字段以兼容现存 `theme.typography.X.fontFamily` 引用,后续阶段逐页清理):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ThemeTypographyEntry {
|
||||||
|
fontSize: number;
|
||||||
|
fontWeight: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||||
|
lineHeight: number;
|
||||||
|
/** @deprecated 设计系统已切换为系统字体,预置主题不再设置该字段;引用处将在 P4/P5 逐页移除。 */
|
||||||
|
fontFamily?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ThemeTypography` 加 `display`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ThemeTypography {
|
||||||
|
display: ThemeTypographyEntry; // 34/800,净资产等大数字
|
||||||
|
h1: ThemeTypographyEntry;
|
||||||
|
h2: ThemeTypographyEntry;
|
||||||
|
h3: ThemeTypographyEntry;
|
||||||
|
body: ThemeTypographyEntry;
|
||||||
|
bodySmall: ThemeTypographyEntry;
|
||||||
|
caption: ThemeTypographyEntry;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行 typecheck 确认报错点**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: FAIL —— `presets.ts` 缺少 `display` 与 `radii.xl`(Task 3 修复)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 暂不 commit(与 Task 3 一起)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: presets.ts — 新双主题色板
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/theme/presets.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 完整替换 presets.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { ThemeTokens } from './tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预置主题 —— 「明亮 Bento 现代风」(docs/ui-redesign-design.md §3)。
|
||||||
|
* 浅色为默认;暗色为 OLED 纯黑完整对等主题。
|
||||||
|
* accent 为近黑白单色:品牌感靠排版与财务语义色表达。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const lightTheme: ThemeTokens = {
|
||||||
|
colors: {
|
||||||
|
bgPrimary: '#F6F7F9',
|
||||||
|
bgSecondary: '#FFFFFF',
|
||||||
|
bgTertiary: '#EFF1F4',
|
||||||
|
fgPrimary: '#111318',
|
||||||
|
fgSecondary: '#6B7280',
|
||||||
|
fgInverse: '#FFFFFF',
|
||||||
|
accent: '#111318', // 近黑:按钮/选中态
|
||||||
|
accentLight: 'rgba(17,19,24,0.08)', // accent 8% 底色(选中高亮)
|
||||||
|
accentDark: '#000000', // 按压加深态
|
||||||
|
success: '#10B981',
|
||||||
|
warning: '#F59E0B',
|
||||||
|
error: '#EF4444',
|
||||||
|
info: '#3B82F6',
|
||||||
|
financial: {
|
||||||
|
income: '#10B981',
|
||||||
|
expense: '#EF4444',
|
||||||
|
transfer: '#3B82F6',
|
||||||
|
},
|
||||||
|
border: '#E5E7EB',
|
||||||
|
divider: '#F0F1F3',
|
||||||
|
overlay: 'rgba(17,19,24,0.4)',
|
||||||
|
skeleton: '#E5E7EB',
|
||||||
|
progressBg: 'rgba(17,19,24,0.06)',
|
||||||
|
},
|
||||||
|
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
|
||||||
|
radii: { sm: 8, md: 12, lg: 16, xl: 24, full: 9999 },
|
||||||
|
typography: {
|
||||||
|
display: { fontSize: 34, fontWeight: '800', lineHeight: 42 },
|
||||||
|
h1: { fontSize: 28, fontWeight: '700', lineHeight: 36 },
|
||||||
|
h2: { fontSize: 22, fontWeight: '700', lineHeight: 30 },
|
||||||
|
h3: { fontSize: 17, fontWeight: '600', lineHeight: 24 },
|
||||||
|
body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
|
||||||
|
bodySmall: { fontSize: 14, fontWeight: '400', lineHeight: 20 },
|
||||||
|
caption: { fontSize: 12, fontWeight: '400', lineHeight: 16 },
|
||||||
|
},
|
||||||
|
shadows: {
|
||||||
|
sm: { shadowColor: '#111318', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4, elevation: 1 },
|
||||||
|
md: { shadowColor: '#111318', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 8, elevation: 2 },
|
||||||
|
lg: { shadowColor: '#111318', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.10, shadowRadius: 12, elevation: 4 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const darkTheme: ThemeTokens = {
|
||||||
|
...lightTheme,
|
||||||
|
colors: {
|
||||||
|
bgPrimary: '#040508', // OLED 纯黑
|
||||||
|
bgSecondary: '#101218',
|
||||||
|
bgTertiary: '#1A1D24',
|
||||||
|
fgPrimary: '#F3F4F6',
|
||||||
|
fgSecondary: '#9CA3AF',
|
||||||
|
fgInverse: '#111318',
|
||||||
|
accent: '#F3F4F6', // 反转为白
|
||||||
|
accentLight: 'rgba(243,244,246,0.10)',
|
||||||
|
accentDark: '#FFFFFF',
|
||||||
|
success: '#34D399',
|
||||||
|
warning: '#FBBF24',
|
||||||
|
error: '#F87171',
|
||||||
|
info: '#60A5FA',
|
||||||
|
financial: {
|
||||||
|
income: '#34D399', // 提亮一档保证对比度
|
||||||
|
expense: '#F87171',
|
||||||
|
transfer: '#60A5FA',
|
||||||
|
},
|
||||||
|
border: 'rgba(255,255,255,0.08)',
|
||||||
|
divider: 'rgba(255,255,255,0.04)',
|
||||||
|
overlay: 'rgba(0,0,0,0.7)',
|
||||||
|
skeleton: '#1A1D24',
|
||||||
|
progressBg: 'rgba(243,244,246,0.08)',
|
||||||
|
},
|
||||||
|
shadows: {
|
||||||
|
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.4, shadowRadius: 2, elevation: 1 },
|
||||||
|
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.5, shadowRadius: 4, elevation: 3 },
|
||||||
|
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.6, shadowRadius: 8, elevation: 5 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 预置主题注册表。 */
|
||||||
|
export const presetThemes: Record<string, ThemeTokens> = {
|
||||||
|
light: lightTheme,
|
||||||
|
dark: darkTheme,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行 Task 1 的测试**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/theme.test.ts`
|
||||||
|
Expected: PASS(palette.test.ts 仍 FAIL,Task 4 修复)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 运行 typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS。
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/theme/tokens.ts src/theme/presets.ts
|
||||||
|
git commit -m "feat(theme): P1 明亮 Bento 令牌 —— 近黑 accent、xl 圆角、display 字阶、系统字体"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: theme/palette.ts — 分类色板 + 渠道品牌色
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/theme/palette.ts`
|
||||||
|
- Modify: `src/domain/channelConfig.ts:32,40,48`
|
||||||
|
- Modify: `src/components/CategoryPicker.tsx:31-48,59`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 src/theme/palette.ts(纯 TS,无 RN/domain 依赖,可被双方向引用)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 分类/渠道颜色统一色板(docs/ui-redesign-design.md §3.2)。
|
||||||
|
*
|
||||||
|
* 纯 TS 模块,不依赖 React Native 与 domain 层,因此:
|
||||||
|
* - components/(CategoryPicker 等)用它给分类配色;
|
||||||
|
* - domain/channelConfig.ts 用它引用渠道品牌色;
|
||||||
|
* 全工程的颜色字面量只允许存在于 presets.ts 与本文件(P5 grep 审计)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 12 色循环色板:未知/用户自定义分类按 id 哈希取色。 */
|
||||||
|
export const CATEGORY_PALETTE = [
|
||||||
|
'#F59E0B', // Amber
|
||||||
|
'#3B82F6', // Blue
|
||||||
|
'#EC4899', // Pink
|
||||||
|
'#06B6D4', // Cyan
|
||||||
|
'#6366F1', // Indigo
|
||||||
|
'#8B5CF6', // Purple
|
||||||
|
'#10B981', // Emerald
|
||||||
|
'#64748B', // Slate
|
||||||
|
'#F43F5E', // Rose
|
||||||
|
'#14B8A6', // Teal
|
||||||
|
'#EF4444', // Red
|
||||||
|
'#84CC16', // Lime
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 内置分类的具名颜色(沿用原 CategoryPicker CATEGORY_COLORS 的映射,视觉不变)。 */
|
||||||
|
const NAMED_CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
food: '#F59E0B',
|
||||||
|
transport: '#3B82F6',
|
||||||
|
shopping: '#EC4899',
|
||||||
|
housing_utility: '#06B6D4',
|
||||||
|
housing_rent: '#6366F1',
|
||||||
|
housing_communication: '#8B5CF6',
|
||||||
|
entertainment: '#10B981',
|
||||||
|
services: '#64748B',
|
||||||
|
personal_care: '#F43F5E',
|
||||||
|
clothing: '#14B8A6',
|
||||||
|
health: '#EF4444',
|
||||||
|
learning: '#84CC16',
|
||||||
|
salary: '#22C55E',
|
||||||
|
income_activity: '#EF4444',
|
||||||
|
income_investment: '#F59E0B',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地 FNV-1a 哈希。
|
||||||
|
* 注:domain/ledger.ts 也有 hash(),但 theme 层不反向依赖 domain 解析器,故保留这份 8 行实现。
|
||||||
|
*/
|
||||||
|
function fnv1a(input: string): number {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
h ^= input.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193);
|
||||||
|
}
|
||||||
|
return h >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取分类颜色:具名映射优先,否则按 id 哈希在 12 色板内确定性循环。 */
|
||||||
|
export function getCategoryColor(categoryId: string): string {
|
||||||
|
const named = NAMED_CATEGORY_COLORS[categoryId];
|
||||||
|
if (named) return named;
|
||||||
|
return CATEGORY_PALETTE[fnv1a(categoryId) % CATEGORY_PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渠道品牌色(供 domain/channelConfig.ts 引用)。 */
|
||||||
|
export const CHANNEL_BRAND_COLORS = {
|
||||||
|
alipay: '#1677FF',
|
||||||
|
wechat: '#07C160',
|
||||||
|
bank: '#E53935',
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: channelConfig.ts 改用品牌色常量**
|
||||||
|
|
||||||
|
文件顶部加 import:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CHANNEL_BRAND_COLORS } from '../theme/palette';
|
||||||
|
```
|
||||||
|
|
||||||
|
三处字面量替换:`color: '#1677FF'` → `color: CHANNEL_BRAND_COLORS.alipay`,`color: '#07C160'` → `color: CHANNEL_BRAND_COLORS.wechat`,`color: '#E53935'` → `color: CHANNEL_BRAND_COLORS.bank`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: CategoryPicker.tsx 改用 getCategoryColor**
|
||||||
|
|
||||||
|
删除第 31–48 行的 `CATEGORY_COLORS` 常量(含 `fallback`),文件顶部加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getCategoryColor } from '../theme/palette';
|
||||||
|
```
|
||||||
|
|
||||||
|
第 59 行 `const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;` 改为:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const color = getCategoryColor(cat.id);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/palette.test.ts tests/channelConfig.test.ts`
|
||||||
|
Expected: PASS(渠道色值未变,channelConfig 测试不受影响)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/theme/palette.ts src/domain/channelConfig.ts src/components/CategoryPicker.tsx
|
||||||
|
git commit -m "feat(theme): 分类/渠道颜色收归 theme/palette,消除组件与 domain 硬编码色"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 移除自定义字体(Caveat/Quicksand)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/app/_layout.tsx:27-39,70-79,370`
|
||||||
|
- Modify: `package.json:14-15`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 删除 _layout.tsx 的字体导入**
|
||||||
|
|
||||||
|
删除第 27–39 行两个 `import { useFonts, ... } from '@expo-google-fonts/...'` 块。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 删除 useFonts 调用**
|
||||||
|
|
||||||
|
删除第 70–79 行的 `const [fontsLoaded, fontError] = useFonts({...});`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 简化加载门禁**
|
||||||
|
|
||||||
|
第 370 行:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (phase === 'loading' || (!fontsLoaded && !fontError)) {
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (phase === 'loading') {
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 移除 package.json 依赖并重装**
|
||||||
|
|
||||||
|
删除 `package.json` 中 `"@expo-google-fonts/caveat"` 与 `"@expo-google-fonts/quicksand"` 两行,然后:
|
||||||
|
|
||||||
|
Run: `npm install --legacy-peer-deps`
|
||||||
|
Expected: lockfile 更新,无字体包残留。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 验证无残留引用**
|
||||||
|
|
||||||
|
Run: `grep -rn "expo-google-fonts\|Caveat_\|Quicksand_" src/ package.json`
|
||||||
|
Expected: 无输出(typography 中 `theme.typography.X.fontFamily` 的运行时引用返回 undefined,RN 回退系统字体,无需在 P1 清理)。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 全量测试 + typecheck**
|
||||||
|
|
||||||
|
Run: `npm test && npm run typecheck`
|
||||||
|
Expected: 全绿。
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app/_layout.tsx package.json package-lock.json
|
||||||
|
git commit -m "feat(theme): 移除 Caveat/Quicksand 自定义字体,切换系统字体"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: commonStyles 适配新圆角
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/theme/commonStyles.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: modalCard 圆角 lg → xl**
|
||||||
|
|
||||||
|
`modalCard` 中 `borderRadius: theme.radii.lg, // 弹窗使用大圆角 lg (16px)` 改为:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
borderRadius: theme.radii.xl, // 弹窗使用卡片级大圆角 xl (24px)
|
||||||
|
```
|
||||||
|
|
||||||
|
(`input` 的 `theme.radii.md` 现在即 12px,无需改动,仅更新注释 `// 输入框标准 radii 为 md (12px)`。)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS。
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/theme/commonStyles.ts
|
||||||
|
git commit -m "feat(theme): commonStyles 弹窗圆角升级为 xl(24)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 重写 MASTER.md 使其与实现一致
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `design-system/beancount-mobile/MASTER.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 完整替换 MASTER.md**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Beancount Mobile 设计系统(MASTER)
|
||||||
|
|
||||||
|
> 本文件与 `src/theme/presets.ts` 一一对应,是实现的事实描述而非平行标准。
|
||||||
|
> 修改配色/字阶/圆角时必须先改 presets.ts,再同步本文件。
|
||||||
|
|
||||||
|
## 设计方向:明亮 Bento 现代风
|
||||||
|
|
||||||
|
浅色为主、大圆角卡片(Bento)、近黑白单色 + 财务语义色点缀。
|
||||||
|
品牌感靠排版与财务色表达,不依赖彩色 accent。暗色为 OLED 纯黑完整对等主题。
|
||||||
|
|
||||||
|
## 色板(= presets.ts)
|
||||||
|
|
||||||
|
### 浅色(默认)
|
||||||
|
| Token | 值 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| bgPrimary | `#F6F7F9` | 页面背景 |
|
||||||
|
| bgSecondary | `#FFFFFF` | 卡片 |
|
||||||
|
| bgTertiary | `#EFF1F4` | 输入框 / chip |
|
||||||
|
| fgPrimary | `#111318` | 主文字 |
|
||||||
|
| fgSecondary | `#6B7280` | 次要文字 |
|
||||||
|
| fgInverse | `#FFFFFF` | 反色文字 |
|
||||||
|
| accent | `#111318` | 按钮 / 选中态(近黑) |
|
||||||
|
| accentLight | `rgba(17,19,24,0.08)` | 选中高亮底色 |
|
||||||
|
| accentDark | `#000000` | 按压态 |
|
||||||
|
| financial.income | `#10B981` | 收入 |
|
||||||
|
| financial.expense | `#EF4444` | 支出 |
|
||||||
|
| financial.transfer | `#3B82F6` | 转账 |
|
||||||
|
| border | `#E5E7EB` | 边框 |
|
||||||
|
| overlay | `rgba(17,19,24,0.4)` | 遮罩 |
|
||||||
|
|
||||||
|
### 暗色(OLED)
|
||||||
|
| Token | 值 |
|
||||||
|
|---|---|
|
||||||
|
| bgPrimary | `#040508` |
|
||||||
|
| bgSecondary | `#101218` |
|
||||||
|
| bgTertiary | `#1A1D24` |
|
||||||
|
| fgPrimary / accent | `#F3F4F6` |
|
||||||
|
| fgSecondary | `#9CA3AF` |
|
||||||
|
| fgInverse | `#111318` |
|
||||||
|
| financial.income | `#34D399`(提亮) |
|
||||||
|
| financial.expense | `#F87171`(提亮) |
|
||||||
|
| financial.transfer | `#60A5FA`(提亮) |
|
||||||
|
| border | `rgba(255,255,255,0.08)` |
|
||||||
|
|
||||||
|
分类/渠道颜色见 `src/theme/palette.ts`(12 色循环 + 具名映射 + 渠道品牌色)。
|
||||||
|
全工程颜色字面量只允许存在于 `presets.ts` 与 `palette.ts`。
|
||||||
|
|
||||||
|
## 字体
|
||||||
|
|
||||||
|
系统字体(iOS SF / Android Roboto),不加载自定义字体。
|
||||||
|
金额数字一律 `fontVariant: ['tabular-nums']` 等宽对齐。
|
||||||
|
|
||||||
|
字阶:display 34/800 · h1 28/700 · h2 22/700 · h3 17/600 · body 16/400 · bodySmall 14/400 · caption 12/400。
|
||||||
|
|
||||||
|
## 圆角 / 间距 / 阴影
|
||||||
|
|
||||||
|
- 圆角:sm 8 · md 12 · lg 16 · **xl 24(卡片与弹窗默认)** · full
|
||||||
|
- 间距:xs 4 · sm 8 · md 16 · lg 24 · xl 32
|
||||||
|
- 阴影:浅色 4–12px 弥散轻阴影;暗色以 1px 半透边框代替阴影
|
||||||
|
|
||||||
|
## 图标
|
||||||
|
|
||||||
|
统一 Ionicons(`@expo/vector-icons`)。禁止用 emoji 充当图标;
|
||||||
|
分类图标 = Ionicon + 圆形彩色底(CategoryIcon 组件,P2 落地)。
|
||||||
|
|
||||||
|
## 反模式
|
||||||
|
|
||||||
|
- 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token
|
||||||
|
- 禁止低对比文字(< 4.5:1)
|
||||||
|
- 禁止瞬间状态变化,交互反馈 150–300ms
|
||||||
|
- 禁止绕过 commonStyles 重复造 input/chip/modal 样式
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add design-system/beancount-mobile/MASTER.md
|
||||||
|
git commit -m "docs: MASTER.md 重写为与实现一致的明亮 Bento 设计系统"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: P1 全量验收
|
||||||
|
|
||||||
|
- [ ] **Step 1: 全量测试**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: 全部通过(30+ 文件,含新 theme/palette 测试)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: 无错误。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 审计**
|
||||||
|
|
||||||
|
Run: `grep -rn "expo-google-fonts" src/ package.json` → 无输出
|
||||||
|
Run: `grep -rn "4F46E5\|EEF2FF\|3730A3" src/` → 无输出(靛蓝族清零)
|
||||||
|
|
||||||
|
已知遗留(不在 P1 处理,已在后续阶段计划内):`src/app/transaction/new.tsx:219` 的 `#2196F3`(P3 重写 new.tsx 时消除);`src/app/(tabs)/index.tsx` Hero 卡硬编码白字(P4 首页重写时消除);组件内 `fontFamily: theme.typography.X.fontFamily` 引用(P4/P5 逐页清理)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 手工走查(需要设备/模拟器)**
|
||||||
|
|
||||||
|
Run: `npm run android`(或 expo start)
|
||||||
|
走查清单:浅色/暗色各过一遍四个 Tab —— 页面背景为米白/纯黑;chip 选中态为黑底白字/白底黑字;卡片圆角变大;无字体加载等待。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后续计划(不在本文件)
|
||||||
|
|
||||||
|
- **P2 组件库**:ScreenHeader / StatCard / AppTabBar / CategoryIcon / DatePickerField / FilterSheet / ManagementScreen
|
||||||
|
- **P3 录入闭环**:NumpadSheet + 双腿账户选择 + 全局+ + transaction/new 重写 + SpeedDial 退役
|
||||||
|
- **P4 四个 Tab 页**:首页待办条 / 交易时间线 / 报表 anchor 统一 / 我的 4 分组
|
||||||
|
- **P5 管理页模板化 + 清零审计**
|
||||||
|
|
||||||
|
每阶段完成后基于实际代码编写下一阶段计划。
|
||||||
@@ -0,0 +1,934 @@
|
|||||||
|
# UI 重设计 P2:核心组件库 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。
|
||||||
|
|
||||||
|
**Goal:** 建立新设计系统的核心组件库:CategoryIcon(去 emoji)、BottomSheet、DatePickerField、ScreenHeader、StatCard、AppTabBar(中央凸起+)、ManagementScreen 模板(tag 页试点),并将 Button/Card/SearchBar/FormModal 重刷到新 token。
|
||||||
|
|
||||||
|
**Architecture:** 全部为 `src/components/` 下的新组件或既有组件改造;纯逻辑(图标映射、月历网格)抽成无 RN 依赖的独立模块以便 Vitest(node) 测试。AppTabBar 通过 expo-router Tabs 的 `tabBar` 插槽接入;+按钮 P2 暂跳 `/transaction/new`,P3 换 NumpadSheet。
|
||||||
|
|
||||||
|
**Tech Stack:** React Native + Expo Router + Ionicons + Vitest。
|
||||||
|
|
||||||
|
**Spec:** `docs/ui-redesign-design.md` §5(导航)、§6(组件库)。**与 spec 的偏差**:FilterSheet 移至 P4(其形态依赖交易页筛选状态,P4 一并设计);CategoryPicker 的"改底部弹层"移至 P3(随 NumpadSheet 一起设计,P2 仅完成 Ionicon 化);FormModal 增加 `children` 支持(ManagementScreen 的颜色选择器需要)。
|
||||||
|
|
||||||
|
**前置状态:** P1 已完成(新 tokens/presets/palette 已生效,系统字体,538 测试全绿)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 组件纯逻辑的失败测试
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/components-p2.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 新建 tests/components-p2.test.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getCategoryIcon, CATEGORY_ICON_NAMES } from '../src/components/categoryIcons';
|
||||||
|
import { buildMonthGrid } from '../src/components/dateGrid';
|
||||||
|
|
||||||
|
describe('getCategoryIcon', () => {
|
||||||
|
it('15 个内置分类都有具名图标', () => {
|
||||||
|
const builtin = [
|
||||||
|
'food', 'transport', 'shopping', 'housing_utility', 'housing_rent',
|
||||||
|
'housing_communication', 'entertainment', 'services', 'personal_care',
|
||||||
|
'clothing', 'health', 'learning', 'salary', 'income_activity', 'income_investment',
|
||||||
|
];
|
||||||
|
for (const id of builtin) {
|
||||||
|
const icon = getCategoryIcon(id);
|
||||||
|
expect(typeof icon).toBe('string');
|
||||||
|
expect(icon.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未知分类返回 fallback 图标', () => {
|
||||||
|
expect(getCategoryIcon('whatever_custom')).toBe('pricetag-outline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('原型链属性名 id 不穿透(返回 fallback)', () => {
|
||||||
|
expect(getCategoryIcon('constructor')).toBe('pricetag-outline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('所有图标名以 -outline 结尾(风格统一)', () => {
|
||||||
|
for (const name of Object.values(CATEGORY_ICON_NAMES)) {
|
||||||
|
expect(name).toMatch(/-outline$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildMonthGrid', () => {
|
||||||
|
it('固定 6 行 × 7 列', () => {
|
||||||
|
const grid = buildMonthGrid(2026, 7);
|
||||||
|
expect(grid).toHaveLength(6);
|
||||||
|
for (const week of grid) expect(week).toHaveLength(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2026-07:首日周三,周一开头填充 6 月末两天', () => {
|
||||||
|
const grid = buildMonthGrid(2026, 7);
|
||||||
|
expect(grid[0][0]).toEqual({ date: '2026-06-29', inMonth: false });
|
||||||
|
expect(grid[0][1]).toEqual({ date: '2026-06-30', inMonth: false });
|
||||||
|
expect(grid[0][2]).toEqual({ date: '2026-07-01', inMonth: true });
|
||||||
|
expect(grid[5][6]).toEqual({ date: '2026-08-09', inMonth: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('2026-02:首日周日,填充 1 月最后 6 天', () => {
|
||||||
|
const grid = buildMonthGrid(2026, 2);
|
||||||
|
expect(grid[0][0].date).toBe('2026-01-26');
|
||||||
|
expect(grid[0][6]).toEqual({ date: '2026-02-01', inMonth: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('首日恰为周一时无跨月填充(2026-06)', () => {
|
||||||
|
const grid = buildMonthGrid(2026, 6);
|
||||||
|
expect(grid[0][0]).toEqual({ date: '2026-06-01', inMonth: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inMonth 标记与当月天数一致(2026-07 共 31 天)', () => {
|
||||||
|
const grid = buildMonthGrid(2026, 7);
|
||||||
|
expect(grid.flat().filter(c => c.inMonth)).toHaveLength(31);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行确认失败**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/components-p2.test.ts`
|
||||||
|
Expected: FAIL —— `Cannot find module '../src/components/categoryIcons'`。**若意外通过,报告 BLOCKED。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: categoryIcons + CategoryIcon + CategoryPicker 去 emoji
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/categoryIcons.ts`
|
||||||
|
- Create: `src/components/CategoryIcon.tsx`
|
||||||
|
- Modify: `src/components/CategoryPicker.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 src/components/categoryIcons.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 分类 id → Ionicons 图标名映射(替换原 CategoryPicker 的 emoji 表)。
|
||||||
|
* 纯数据模块:仅含类型导入(运行时零依赖),可在 Vitest(node) 中直接测试。
|
||||||
|
*/
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
import type { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
export type IoniconName = ComponentProps<typeof Ionicons>['name'];
|
||||||
|
|
||||||
|
export const CATEGORY_ICON_NAMES: Record<string, IoniconName> = {
|
||||||
|
food: 'fast-food-outline',
|
||||||
|
transport: 'bus-outline',
|
||||||
|
shopping: 'bag-handle-outline',
|
||||||
|
housing_utility: 'water-outline',
|
||||||
|
housing_rent: 'home-outline',
|
||||||
|
housing_communication: 'call-outline',
|
||||||
|
entertainment: 'game-controller-outline',
|
||||||
|
services: 'construct-outline',
|
||||||
|
personal_care: 'sparkles-outline',
|
||||||
|
clothing: 'shirt-outline',
|
||||||
|
health: 'medkit-outline',
|
||||||
|
learning: 'book-outline',
|
||||||
|
salary: 'wallet-outline',
|
||||||
|
income_activity: 'gift-outline',
|
||||||
|
income_investment: 'trending-up-outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_ICON: IoniconName = 'pricetag-outline';
|
||||||
|
|
||||||
|
/** 取分类图标:具名映射,否则 fallback(hasOwn 防原型链穿透)。 */
|
||||||
|
export function getCategoryIcon(categoryId: string): IoniconName {
|
||||||
|
return Object.hasOwn(CATEGORY_ICON_NAMES, categoryId)
|
||||||
|
? CATEGORY_ICON_NAMES[categoryId]
|
||||||
|
: FALLBACK_ICON;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 创建 src/components/CategoryIcon.tsx**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { getCategoryColor } from '../theme/palette';
|
||||||
|
import { getCategoryIcon } from './categoryIcons';
|
||||||
|
|
||||||
|
interface CategoryIconProps {
|
||||||
|
categoryId: string;
|
||||||
|
/** 圆形底色直径,默认 36。 */
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分类图标:Ionicon + 分类色圆形浅底(设计系统 §图标:替换 emoji)。 */
|
||||||
|
export function CategoryIcon({ categoryId, size = 36 }: CategoryIconProps) {
|
||||||
|
const color = getCategoryColor(categoryId);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.circle, {
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size / 2,
|
||||||
|
backgroundColor: color + '1A',
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
<Ionicons name={getCategoryIcon(categoryId)} size={size * 0.55} color={color} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
circle: { alignItems: 'center', justifyContent: 'center' },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: CategoryPicker.tsx 接入 CategoryIcon**
|
||||||
|
|
||||||
|
- 删除整个 `CATEGORY_ICONS` 常量(第 13–30 行)。
|
||||||
|
- 顶部加 `import { CategoryIcon } from './CategoryIcon';`(`getCategoryColor` 的 import 保留)。
|
||||||
|
- 删除 `const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback;` 一行。
|
||||||
|
- JSX 中 `<Text style={[styles.icon, { color }]}>{icon}</Text>` 替换为 `<CategoryIcon categoryId={cat.id} size={32} />`。
|
||||||
|
- 删除 label 样式里的 `fontFamily: theme.typography.caption.fontFamily,` 一行。
|
||||||
|
- 删除 styles 中的 `icon` 条目。
|
||||||
|
- 注意:`const color = getCategoryColor(cat.id);` 保留(active 底色仍用)。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 验证**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/components-p2.test.ts` → categoryIcons 相关 4 条 PASS(dateGrid 仍 FAIL,Task 3 修复)
|
||||||
|
Run: `npm run typecheck` → Expected: 无错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: dateGrid 月历网格纯逻辑
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/dateGrid.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 src/components/dateGrid.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 月历网格生成(周一开头,固定 6 行×7 列,含前后月填充)。
|
||||||
|
* 纯 TS,无 RN 依赖,供 DatePickerField / CalendarView 复用。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DayCell {
|
||||||
|
/** YYYY-MM-DD(本地时区)。 */
|
||||||
|
date: string;
|
||||||
|
inMonth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return n < 10 ? `0${n}` : `${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成指定年月的月历网格。month 为 1-12。 */
|
||||||
|
export function buildMonthGrid(year: number, month: number): DayCell[][] {
|
||||||
|
const first = new Date(year, month - 1, 1);
|
||||||
|
// 周一为一周起点:getDay() 周日=0 → 偏移 (day+6)%7
|
||||||
|
const offset = (first.getDay() + 6) % 7;
|
||||||
|
const cursor = new Date(year, month - 1, 1 - offset);
|
||||||
|
const weeks: DayCell[][] = [];
|
||||||
|
for (let w = 0; w < 6; w++) {
|
||||||
|
const week: DayCell[] = [];
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
week.push({ date: fmt(cursor), inMonth: cursor.getMonth() === month - 1 });
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
weeks.push(week);
|
||||||
|
}
|
||||||
|
return weeks;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验证**
|
||||||
|
|
||||||
|
Run: `npx vitest run tests/components-p2.test.ts` → Expected: 全部 9 条 PASS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: BottomSheet 通用底部弹层
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/BottomSheet.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 src/components/BottomSheet.tsx**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useTheme } from '../theme';
|
||||||
|
|
||||||
|
interface BottomSheetProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通用底部弹层容器:顶部 xl 圆角 + 把手、遮罩点击关闭、Android 返回键关闭。 */
|
||||||
|
export function BottomSheet({ visible, onClose, title, children }: BottomSheetProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||||
|
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onClose}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.sheet, {
|
||||||
|
backgroundColor: theme.colors.bgSecondary,
|
||||||
|
borderTopLeftRadius: theme.radii.xl,
|
||||||
|
borderTopRightRadius: theme.radii.xl,
|
||||||
|
}]}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<View style={[styles.handle, { backgroundColor: theme.colors.border }]} />
|
||||||
|
{title ? (
|
||||||
|
<Text style={[theme.typography.h3, styles.title, { color: theme.colors.fgPrimary }]}>{title}</Text>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: { flex: 1, justifyContent: 'flex-end' },
|
||||||
|
sheet: { maxHeight: '85%', paddingHorizontal: 20, paddingBottom: 36 },
|
||||||
|
handle: { width: 40, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 8, marginBottom: 4 },
|
||||||
|
title: { marginTop: 8, marginBottom: 12 },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck` → Expected: 无错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: DatePickerField 日期选择字段
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/DatePickerField.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 src/components/DatePickerField.tsx**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useTheme, createCommonStyles } from '../theme';
|
||||||
|
import { BottomSheet } from './BottomSheet';
|
||||||
|
import { buildMonthGrid } from './dateGrid';
|
||||||
|
|
||||||
|
interface DatePickerFieldProps {
|
||||||
|
/** YYYY-MM-DD,空串表示未选。 */
|
||||||
|
value: string;
|
||||||
|
onChange: (date: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
|
||||||
|
|
||||||
|
/** 日期选择字段:输入框外观 + 底部弹层月历(终结手输 YYYY-MM-DD)。 */
|
||||||
|
export function DatePickerField({ value, onChange, placeholder }: DatePickerFieldProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [viewYear, setViewYear] = useState(new Date().getFullYear());
|
||||||
|
const [viewMonth, setViewMonth] = useState(new Date().getMonth() + 1);
|
||||||
|
|
||||||
|
const grid = useMemo(() => buildMonthGrid(viewYear, viewMonth), [viewYear, viewMonth]);
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
const y = Number(value.slice(0, 4));
|
||||||
|
const m = Number(value.slice(5, 7));
|
||||||
|
if (y) {
|
||||||
|
setViewYear(y);
|
||||||
|
setViewMonth(m || 1);
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shiftMonth = (delta: number) => {
|
||||||
|
const m = viewMonth - 1 + delta;
|
||||||
|
setViewYear(viewYear + Math.floor(m / 12));
|
||||||
|
setViewMonth(((m % 12) + 12) % 12 + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = (date: string) => {
|
||||||
|
onChange(date);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Pressable style={commonStyles.input} onPress={openPicker} accessibilityRole="button">
|
||||||
|
<Text style={{ color: value ? theme.colors.fgPrimary : theme.colors.fgSecondary }}>
|
||||||
|
{value || placeholder || 'YYYY-MM-DD'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<BottomSheet visible={open} onClose={() => setOpen(false)}>
|
||||||
|
{/* 月份导航 */}
|
||||||
|
<View style={styles.navRow}>
|
||||||
|
<Pressable onPress={() => shiftMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月">
|
||||||
|
<Ionicons name="chevron-back" size={22} color={theme.colors.fgPrimary} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'] }]}>
|
||||||
|
{viewYear}-{String(viewMonth).padStart(2, '0')}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={() => shiftMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月">
|
||||||
|
<Ionicons name="chevron-forward" size={22} color={theme.colors.fgPrimary} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
{/* 星期头 */}
|
||||||
|
<View style={styles.weekRow}>
|
||||||
|
{WEEKDAYS.map(w => (
|
||||||
|
<Text key={w} style={[styles.weekCell, theme.typography.caption, { color: theme.colors.fgSecondary }]}>{w}</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{/* 日期网格 */}
|
||||||
|
{grid.map((week, wi) => (
|
||||||
|
<View key={wi} style={styles.weekRow}>
|
||||||
|
{week.map(cell => {
|
||||||
|
const selected = cell.date === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={cell.date}
|
||||||
|
onPress={() => pick(cell.date)}
|
||||||
|
style={[styles.dayCell, {
|
||||||
|
backgroundColor: selected ? theme.colors.accent : 'transparent',
|
||||||
|
borderRadius: theme.radii.full,
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
<Text style={[theme.typography.bodySmall, {
|
||||||
|
color: selected
|
||||||
|
? theme.colors.fgInverse
|
||||||
|
: cell.inMonth ? theme.colors.fgPrimary : theme.colors.fgSecondary,
|
||||||
|
opacity: cell.inMonth ? 1 : 0.5,
|
||||||
|
fontVariant: ['tabular-nums'],
|
||||||
|
}]}>
|
||||||
|
{Number(cell.date.slice(8, 10))}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</BottomSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
navRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
|
||||||
|
weekRow: { flexDirection: 'row' },
|
||||||
|
weekCell: { flex: 1, textAlign: 'center', paddingVertical: 6 },
|
||||||
|
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', margin: 1 },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck` → Expected: 无错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: ScreenHeader + StatCard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/ScreenHeader.tsx`
|
||||||
|
- Create: `src/components/StatCard.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 src/components/ScreenHeader.tsx**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useTheme } from '../theme';
|
||||||
|
|
||||||
|
interface ScreenHeaderProps {
|
||||||
|
title: string;
|
||||||
|
/** 右侧操作位(图标按钮/文本按钮)。 */
|
||||||
|
right?: React.ReactNode;
|
||||||
|
/** 自定义返回行为;默认 router.back()。 */
|
||||||
|
onBack?: () => void;
|
||||||
|
/** 返回按钮的无障碍标签。 */
|
||||||
|
backLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一二级页头:返回 + 标题 + 右操作位(替换各页手写 header)。 */
|
||||||
|
export function ScreenHeader({ title, right, onBack, backLabel = '返回' }: ScreenHeaderProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onBack ?? (() => router.back())}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={backLabel}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={[theme.typography.h2, styles.title, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{right ?? <View style={styles.rightPlaceholder} />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, gap: 8 },
|
||||||
|
title: { flex: 1 },
|
||||||
|
rightPlaceholder: { width: 24 },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 创建 src/components/StatCard.tsx**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useTheme } from '../theme';
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
caption?: string;
|
||||||
|
/** 默认 fgPrimary;收支场景传 financial.income/expense。 */
|
||||||
|
valueColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计数字卡:标题 + 大数字(等宽)+ 说明(首页/报表/年报共用)。 */
|
||||||
|
export function StatCard({ label, value, caption, valueColor }: StatCardProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.card, {
|
||||||
|
backgroundColor: theme.colors.bgSecondary,
|
||||||
|
borderRadius: theme.radii.xl,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
}, theme.shadows.sm]}
|
||||||
|
>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{label}</Text>
|
||||||
|
<Text
|
||||||
|
style={[theme.typography.h2, styles.value, { color: valueColor ?? theme.colors.fgPrimary }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
{caption ? (
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{caption}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
card: { padding: 16, gap: 4, borderWidth: StyleSheet.hairlineWidth },
|
||||||
|
value: { fontWeight: '800', fontVariant: ['tabular-nums'] },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck` → Expected: 无错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: AppTabBar 中央凸起+导航
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/AppTabBar.tsx`
|
||||||
|
- Modify: `src/app/(tabs)/_layout.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 创建 src/components/AppTabBar.tsx**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
|
||||||
|
import { useTheme } from '../theme';
|
||||||
|
import type { IoniconName } from './categoryIcons';
|
||||||
|
|
||||||
|
/** 路由名 → 图标(与 (tabs)/_layout.tsx 的 4 个 Screen 对应)。 */
|
||||||
|
const TAB_ICONS: Record<string, IoniconName> = {
|
||||||
|
index: 'home-outline',
|
||||||
|
transactions: 'list-outline',
|
||||||
|
report: 'pie-chart-outline',
|
||||||
|
settings: 'settings-outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义底部导航:4 个内容 Tab + 中央凸起+。
|
||||||
|
* +不是路由——P2 暂跳转 /transaction/new,P3 改为唤起全局 NumpadSheet。
|
||||||
|
*/
|
||||||
|
export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const renderTab = (route: (typeof state.routes)[number], index: number) => {
|
||||||
|
const focused = state.index === index;
|
||||||
|
const options = descriptors[route.key].options;
|
||||||
|
const label = (options.title ?? route.name) as string;
|
||||||
|
const color = focused ? theme.colors.accent : theme.colors.fgSecondary;
|
||||||
|
const onPress = () => {
|
||||||
|
const event = navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true });
|
||||||
|
if (!focused && !event.defaultPrevented) navigation.navigate(route.name);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={route.key}
|
||||||
|
onPress={onPress}
|
||||||
|
style={styles.tab}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ selected: focused }}
|
||||||
|
accessibilityLabel={label}
|
||||||
|
>
|
||||||
|
<Ionicons name={TAB_ICONS[route.name] ?? 'ellipse-outline'} size={22} color={color} />
|
||||||
|
<Text style={[styles.tabLabel, { color }]}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.bar, {
|
||||||
|
backgroundColor: theme.colors.bgSecondary,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
paddingBottom: Math.max(insets.bottom, 8),
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
{state.routes.slice(0, 2).map(renderTab)}
|
||||||
|
<View style={styles.plusSlot}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.push('/transaction/new')}
|
||||||
|
style={[styles.plus, {
|
||||||
|
backgroundColor: theme.colors.accent,
|
||||||
|
borderColor: theme.colors.bgPrimary,
|
||||||
|
}, theme.shadows.lg]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="记一笔"
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={30} color={theme.colors.fgInverse} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
{state.routes.slice(2).map(renderTab)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
bar: { flexDirection: 'row', alignItems: 'center', borderTopWidth: StyleSheet.hairlineWidth },
|
||||||
|
tab: { flex: 1, alignItems: 'center', paddingTop: 8, gap: 2 },
|
||||||
|
tabLabel: { fontSize: 10, fontWeight: '600' },
|
||||||
|
plusSlot: { width: 64, alignItems: 'center' },
|
||||||
|
plus: { width: 52, height: 52, borderRadius: 26, alignItems: 'center', justifyContent: 'center', marginTop: -24, borderWidth: 4 },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 接入 (tabs)/_layout.tsx**
|
||||||
|
|
||||||
|
- 顶部加 `import { AppTabBar } from '../../components/AppTabBar';`,删除 `import { Ionicons } from '@expo/vector-icons';`(不再需要)。
|
||||||
|
- 第 7 行注释改为 `/** 底部 Tab 导航:首页/交易/报表/设置 + 中央+(AppTabBar)。 */`
|
||||||
|
- `Tabs` 组件加 prop:`tabBar={(props) => <AppTabBar {...props} />}`。
|
||||||
|
- 删除 4 个 `Tabs.Screen` 里的 `tabBarIcon` 属性(图标由 AppTabBar 的 TAB_ICONS 决定),`title` 保留。
|
||||||
|
- screenOptions 中 `tabBarActiveTintColor` / `tabBarInactiveTintColor` / `tabBarStyle` 可删除(自定义 tabBar 不用),仅保留 `headerShown: false`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck` → Expected: 无错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: FormModal children 支持 + ManagementScreen + tag 页试点
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/FormModal.tsx`
|
||||||
|
- Create: `src/components/ManagementScreen.tsx`
|
||||||
|
- Modify: `src/app/tag/index.tsx`(完整重写)
|
||||||
|
|
||||||
|
- [ ] **Step 1: FormModal 增加 children + ✕ 换图标 + 圆角 xl**
|
||||||
|
|
||||||
|
- `FormModalProps` 加字段:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
```
|
||||||
|
|
||||||
|
- 函数签名解构加 `children`;在 `</ScrollView>` 之后、`<View style={styles.actions}>` 之前插入 `{children}`。
|
||||||
|
- 顶部加 `import { Ionicons } from '@expo/vector-icons';`;把关闭按钮的 `<Text ...>✕</Text>` 替换为 `<Ionicons name="close" size={20} color={theme.colors.fgSecondary} />`。
|
||||||
|
- sheet 的 `borderRadius: theme.radii.lg` 改为 `theme.radii.xl`。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 创建 src/components/ManagementScreen.tsx**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useTheme } from '../theme';
|
||||||
|
import { useT } from '../i18n';
|
||||||
|
import { ScreenHeader } from './ScreenHeader';
|
||||||
|
import { FormModal, type FormField } from './FormModal';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理页模板(P2):统一「列表 + +新增 + 点按编辑 + 长按删除 + FormModal」模式。
|
||||||
|
* 各管理页只声明字段配置与数据读写,不再复制 header/Alert/Modal 样板。
|
||||||
|
*/
|
||||||
|
interface ManagementScreenProps<T> {
|
||||||
|
title: string;
|
||||||
|
items: T[];
|
||||||
|
keyExtractor: (item: T) => string;
|
||||||
|
/** 渲染单个条目;handlers.openEdit 打开编辑弹窗,handlers.confirmDelete 弹删除确认。 */
|
||||||
|
renderItem: (item: T, handlers: { openEdit: () => void; confirmDelete: () => void }) => React.ReactNode;
|
||||||
|
addLabel: string;
|
||||||
|
emptyText?: string;
|
||||||
|
formTitle: (editing: T | null) => string;
|
||||||
|
formFields: (editing: T | null) => FormField[];
|
||||||
|
/** 返回 true 关闭弹窗(校验失败 Alert 后返回 false 保持打开)。 */
|
||||||
|
onSubmit: (values: Record<string, string>, editing: T | null) => boolean;
|
||||||
|
onDelete: (item: T) => void;
|
||||||
|
deleteConfirmText: (item: T) => string;
|
||||||
|
/** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */
|
||||||
|
formExtra?: (editing: T | null) => React.ReactNode;
|
||||||
|
/** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */
|
||||||
|
onOpenForm?: (editing: T | null) => void;
|
||||||
|
/** 列表上方的额外内容(分组 Tab 等)。 */
|
||||||
|
headerContent?: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const t = useT();
|
||||||
|
const [editing, setEditing] = useState<T | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const openForm = (item: T | null) => {
|
||||||
|
setEditing(item);
|
||||||
|
props.onOpenForm?.(item);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (item: T) => {
|
||||||
|
Alert.alert(t('common.delete'), props.deleteConfirmText(item), [
|
||||||
|
{ text: t('common.cancel'), style: 'cancel' },
|
||||||
|
{ text: t('common.delete'), style: 'destructive', onPress: () => props.onDelete(item) },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (values: Record<string, string>) => {
|
||||||
|
if (props.onSubmit(values, editing)) setModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||||
|
<ScreenHeader
|
||||||
|
title={props.title}
|
||||||
|
right={
|
||||||
|
<Pressable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}>
|
||||||
|
<Ionicons name="add" size={26} color={theme.colors.accent} />
|
||||||
|
</Pressable>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
{props.headerContent}
|
||||||
|
{props.items.length === 0 && props.emptyText ? (
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{props.emptyText}</Text>
|
||||||
|
) : null}
|
||||||
|
{props.items.map(item => (
|
||||||
|
<View key={props.keyExtractor(item)}>
|
||||||
|
{props.renderItem(item, {
|
||||||
|
openEdit: () => openForm(item),
|
||||||
|
confirmDelete: () => confirmDelete(item),
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{props.footer}
|
||||||
|
</ScrollView>
|
||||||
|
<FormModal
|
||||||
|
visible={modalOpen}
|
||||||
|
title={props.formTitle(editing)}
|
||||||
|
fields={props.formFields(editing)}
|
||||||
|
onConfirm={handleSubmit}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
>
|
||||||
|
{props.formExtra?.(editing)}
|
||||||
|
</FormModal>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: { flex: 1 },
|
||||||
|
content: { padding: 16, gap: 12 },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 完整重写 src/app/tag/index.tsx(ManagementScreen 试点)**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
/**
|
||||||
|
* 标签管理页面 —— ManagementScreen 模板试点(P2)。
|
||||||
|
*
|
||||||
|
* 功能:标签列表(彩色芯片) + 添加/编辑/删除。
|
||||||
|
* 标签名写入 .bean 的 #tag 语法,需符合 [\w-]。
|
||||||
|
*/
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useTheme } from '../../theme';
|
||||||
|
import { TAG_COLORS } from '../../theme/palette';
|
||||||
|
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||||
|
import { isValidTagName } from '../../domain/tags';
|
||||||
|
import type { Tag } from '../../domain/tags';
|
||||||
|
|
||||||
|
export default function TagScreen() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
const tags = useMetadataStore(s => s.tags);
|
||||||
|
const addTag = useMetadataStore(s => s.addTag);
|
||||||
|
const updateTag = useMetadataStore(s => s.updateTag);
|
||||||
|
const removeTag = useMetadataStore(s => s.removeTag);
|
||||||
|
|
||||||
|
const [selectedColor, setSelectedColor] = useState<string>(TAG_COLORS[0]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ManagementScreen<Tag>
|
||||||
|
title={t('tag.title')}
|
||||||
|
items={tags}
|
||||||
|
keyExtractor={tag => tag.id}
|
||||||
|
addLabel={t('tag.add')}
|
||||||
|
emptyText={t('tag.empty')}
|
||||||
|
onOpenForm={editing => setSelectedColor(editing?.color ?? TAG_COLORS[0])}
|
||||||
|
renderItem={(tag, { openEdit, confirmDelete }) => (
|
||||||
|
<Pressable
|
||||||
|
onPress={openEdit}
|
||||||
|
onLongPress={confirmDelete}
|
||||||
|
style={[styles.chip, { backgroundColor: tag.color }]}
|
||||||
|
>
|
||||||
|
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))}
|
||||||
|
formFields={editing => [
|
||||||
|
{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name },
|
||||||
|
]}
|
||||||
|
formExtra={() => (
|
||||||
|
<View style={styles.colorRow}>
|
||||||
|
{TAG_COLORS.map(c => (
|
||||||
|
<Pressable
|
||||||
|
key={c}
|
||||||
|
onPress={() => setSelectedColor(c)}
|
||||||
|
style={[styles.colorDot, {
|
||||||
|
backgroundColor: c,
|
||||||
|
borderWidth: selectedColor === c ? 3 : 0,
|
||||||
|
borderColor: theme.colors.fgPrimary,
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
onSubmit={(values, editing) => {
|
||||||
|
const name = values.name?.trim() ?? '';
|
||||||
|
if (!isValidTagName(name)) {
|
||||||
|
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (editing) updateTag(editing.id, { name, color: selectedColor });
|
||||||
|
else addTag({ id: generateId('tag'), name, color: selectedColor });
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
onDelete={tag => removeTag(tag.id)}
|
||||||
|
deleteConfirmText={tag => t('tag.deleteConfirm', { name: tag.name })}
|
||||||
|
footer={
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
||||||
|
{t('common.clickEditLongDelete')}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
chip: { alignSelf: 'flex-start', paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
|
||||||
|
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
|
||||||
|
colorDot: { width: 36, height: 36, borderRadius: 18 },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 验证**
|
||||||
|
|
||||||
|
Run: `npm test` → Expected: 全部通过(tag 页逻辑变化不影响单测,但全量跑一遍防回归)
|
||||||
|
Run: `npm run typecheck` → Expected: 无错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Button / Card / SearchBar 重刷
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/Button.tsx`
|
||||||
|
- Modify: `src/components/Card.tsx`
|
||||||
|
- Modify: `src/components/SearchBar.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Button.tsx**
|
||||||
|
|
||||||
|
- `borderRadius: theme.radii.lg, // 使用 radii.lg 实现现代大圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`
|
||||||
|
- 删除 Text 样式中的 `fontFamily: theme.typography.body.fontFamily`(系统字体)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: Card.tsx**
|
||||||
|
|
||||||
|
- cardStyle 中 `borderRadius: theme.radii.lg,` 改为 `borderRadius: theme.radii.xl,`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: SearchBar.tsx**
|
||||||
|
|
||||||
|
- `borderRadius: theme.radii.lg, // 升级为 lg 圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm test` → Expected: 全部通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: P2 全量验收
|
||||||
|
|
||||||
|
- [ ] **Step 1: 全量测试 + typecheck**
|
||||||
|
|
||||||
|
Run: `npm test` → Expected: 全部通过(含 components-p2 的 9 条新测试)
|
||||||
|
Run: `npm run typecheck` → Expected: 无错误
|
||||||
|
|
||||||
|
- [ ] **Step 2: 审计**
|
||||||
|
|
||||||
|
Run: `grep -rn "🍔\|🚗\|🛍\|💧\|🏠\|📞\|🎮\|⚙️\|💅\|👕\|🏥\|📚\|💰\|🧧\|📈\|🏷" src/` → Expected: 无输出(分类 emoji 清零)
|
||||||
|
Run: `grep -n "fontFamily" src/components/Button.tsx src/components/CategoryPicker.tsx` → Expected: 无输出
|
||||||
|
Run: `grep -n "✕" src/components/` → Expected: 无输出
|
||||||
|
|
||||||
|
- [ ] **Step 3: 手工走查(需设备/模拟器)**
|
||||||
|
|
||||||
|
Run: `npm run android`
|
||||||
|
走查清单(浅/暗双主题):
|
||||||
|
1. 底部导航为自定义 bar:中央黑色凸起+,4 个 tab 可切换
|
||||||
|
2. + → 跳转记一笔页(P2 占位行为)
|
||||||
|
3. 记一笔页分类网格:图标为 Ionicons 圆底彩色,无 emoji
|
||||||
|
4. 标签管理页:新 ScreenHeader +号新增、点按编辑、长按删除、颜色选择器在弹窗内正常工作
|
||||||
|
5. 任意 FormModal:关闭按钮为 Ionicons 图标而非 ✕ 字符
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后续计划(不在本文件)
|
||||||
|
|
||||||
|
- **P3 录入闭环**:NumpadSheet + 双腿账户选择 + AppTabBar + 改全局唤起 + transaction/new 重写 + SpeedDial 退役
|
||||||
|
- **P4 四个 Tab 页**:首页待办条 / 交易时间线(FilterSheet 在此设计)/ 报表 anchor 统一 / 我的 4 分组
|
||||||
|
- **P5 管理页全面模板化**(tag 页为已验证模板)+ 清零审计
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
|||||||
|
# UI 重设计 P5:管理页模板化 + 清零收尾 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。
|
||||||
|
|
||||||
|
**Goal:** 7 个管理页套 ManagementScreen 模板、account 页与 3 个设置二级页统一 ScreenHeader、图表/fontFamily/emoji/星期头清理、`numpadGlobalEntry` 翻默认(含 modal 未保存守卫)、清零审计验收。
|
||||||
|
|
||||||
|
**Spec:** `docs/ui-redesign-design.md` §7.5/§8。**已确认现状**(2026-07-22 审计):hex 硬编码已清零(`#[0-9A-Fa-f]{6}` 在 src/ 仅剩 presets.ts/palette.ts);fontFamily 残留 14 处;emoji 5 处(monthlySummary.ts 4 + ai/chat.tsx 1);tag 页已是模板试点。
|
||||||
|
|
||||||
|
**与 spec 的偏差**:①account 页是「账户浏览器」(类型 Tab + 开户/调余额/关户三个交互),不套 ManagementScreen,只做 ScreenHeader 统一;②`numpadGlobalEntry` 翻默认只影响新安装(已持久化的 false 不覆盖,尊重用户选择)。
|
||||||
|
|
||||||
|
**模板适配决策**(已通读 7 页源码):
|
||||||
|
|
||||||
|
- budget/rules/remark-template → 直套(T1)
|
||||||
|
- category → `headerContent` 放支出/收入 chips,新增按当前类型(T2)
|
||||||
|
- recurring → 卡片保留显式编辑/删除按钮(用 handlers.openEdit/confirmDelete),不用长按(T2)
|
||||||
|
- credit-card → 富 renderItem(账单盒)直套(T2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 管理页模板化批次 1(budget / rules / remark-template)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/app/budget/index.tsx`、`src/app/rules/index.tsx`、`src/app/remark-template/index.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 三页套 ManagementScreen**(参照试点 `src/app/tag/index.tsx` 与模板 `src/components/ManagementScreen.tsx`)
|
||||||
|
|
||||||
|
通用映射:页面 state 消失(modal/editing 由模板持有);手写 header/addBtn/Alert/FormModal 全删;`footer` 传 `common.clickEditLongDelete` 提示;`deleteConfirmTitle` 传各页 `t('xxx.deleteTitle')`。
|
||||||
|
|
||||||
|
**budget/index.tsx**:
|
||||||
|
|
||||||
|
- `items={budgets}`、`keyExtractor={b => b.id}`、`addLabel={t('budget.add')}`、`emptyText={t('budget.empty')}`
|
||||||
|
- renderItem:保留现有 Card + 进度条结构(Pressable onPress={openEdit} onLongPress={confirmDelete} 包裹)。`today` 改用 `toDateString(new Date())`(修掉 `toISOString().slice(0,10)` 的 UTC 偏移)
|
||||||
|
- formFields:5 字段(name/amount decimal-pad/period/categoryAccount/startDate),defaultValue 从 editing 取
|
||||||
|
- onSubmit:保留校验(amount parseFloat ≤ 0 → Alert + return false);add/edit 分支调 addBudget/updateBudget
|
||||||
|
- onDelete:`removeBudget(budget.id)`;deleteConfirmText:`t('budget.deleteConfirm', { name: budget.name })`
|
||||||
|
|
||||||
|
**rules/index.tsx**:
|
||||||
|
|
||||||
|
- `items={[...rules].sort((a, b) => b.priority - a.priority)}`
|
||||||
|
- renderItem:保留规则行(narration/P 徽标/条件/目标账户+命中次数);**删除 `fontFamily: theme.typography.caption.fontFamily`**(审计项)
|
||||||
|
- formFields:7 字段(priority numeric/counterpartyContains/memoContains/sourceAccount/categoryAccount/narration/tags)
|
||||||
|
- onSubmit:保留现有 Rule 组装逻辑;addRule `{...rule, id: generateId('rule'), hits: 0}` / updateRule
|
||||||
|
|
||||||
|
**remark-template/index.tsx**:
|
||||||
|
|
||||||
|
- 2 字段(name/template);renderItem 的模板文本 `fontFamily: 'monospace'` 改 `fontVariant: ['tabular-nums']`(审计项)
|
||||||
|
- onSubmit:name 空回退 `t('common.untitled')`
|
||||||
|
|
||||||
|
- [ ] **Step 2: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm test` → 全绿
|
||||||
|
Run: `grep -ln "FormModal\|arrow-back" src/app/budget/index.tsx src/app/rules/index.tsx src/app/remark-template/index.tsx` → 无输出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 管理页模板化批次 2(category / recurring / credit-card)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/app/category/index.tsx`、`src/app/recurring/index.tsx`、`src/app/credit-card/index.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: category/index.tsx — headerContent 类型 chips**
|
||||||
|
|
||||||
|
- 保留一个本地 state:`const [catType, setCatType] = useState<'expense' | 'income'>('expense')`
|
||||||
|
- `headerContent`:两个 chip(支出/收入,commonStyles.chip/chipActive),切换 setCatType
|
||||||
|
- `items={categories.filter(c => c.type === catType)}`
|
||||||
|
- `formTitle`:editing ? `t('category.editTitle')` : `t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') })`(插值语法以现有键为准,先 grep category.addTitle)
|
||||||
|
- formFields:3 字段(name/linkedAccount/keywords),编辑时带 defaultValue
|
||||||
|
- onSubmit:add 时 `type: catType`,linkedAccount 空回退按类型(Income:/Expenses:Uncategorized),keywords 拆分逻辑保留;edit 保留原逻辑
|
||||||
|
- renderItem:保留名称/linkedAccount/关键词行
|
||||||
|
|
||||||
|
- [ ] **Step 2: recurring/index.tsx — 显式按钮用 handlers**
|
||||||
|
|
||||||
|
- renderItem:保留信息行 Card;卡片底部两个按钮(编辑/删除)分别调 `handlers.openEdit()` / `handlers.confirmDelete()`(**不**用长按,交互更显式);外层 Pressable 去掉
|
||||||
|
- formFields:6 字段;`new Date().toISOString().slice(0, 10)` 两处 → `toDateString(new Date())`
|
||||||
|
- onSubmit:保留校验/draft 组装/成功 Alert,返回 true;校验失败 Alert 后 return false
|
||||||
|
- 成功 Alert 在 onSubmit 内(setModal(null) 由模板接管——删除手动 setModal 调用)
|
||||||
|
|
||||||
|
- [ ] **Step 3: credit-card/index.tsx — 富 renderItem 直套**
|
||||||
|
|
||||||
|
- accountBalances useMemo 保留在页面顶层
|
||||||
|
- renderItem:保留整张卡(银行/账单日/还款日/额度/账单盒),Pressable onPress={openEdit} onLongPress={confirmDelete}
|
||||||
|
- formFields:8 字段;onSubmit 保留组装逻辑
|
||||||
|
|
||||||
|
- [ ] **Step 4: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm test` → 全绿
|
||||||
|
Run: `grep -ln "FormModal\|arrow-back" src/app/category/index.tsx src/app/recurring/index.tsx src/app/credit-card/index.tsx` → 无输出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: ScreenHeader 统一(account + settings 三页)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/app/account/index.tsx`、`src/app/settings/sync.tsx`、`src/app/settings/ai.tsx`、`src/app/settings/preferences.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: account/index.tsx**
|
||||||
|
|
||||||
|
手写 header(arrow-back + title)替换为 `<ScreenHeader title={t('account.title')} />`(组件保持其余逻辑不变:类型 Tab/开户 FormModal/调余额 FormModal/关户 Alert);删除 styles.header。调余额表单的 date 默认值 `new Date().toISOString().slice(0, 10)` → `toDateString(new Date())`。
|
||||||
|
|
||||||
|
- [ ] **Step 2: settings/sync.tsx、ai.tsx、preferences.tsx**
|
||||||
|
|
||||||
|
三页手写 header(约在 sync.tsx:460、ai.tsx:51、preferences.tsx:44)替换为 ScreenHeader,标题沿用原文案键;删除对应 styles.header 与不再使用的 import(Ionicons/router 若仅 header 使用)。sync.tsx 546 行,只动 header 块,其余不动。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm test` → 全绿
|
||||||
|
Run: `grep -rn "arrow-back" src/app/ | grep -v ScreenHeader` → 无输出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 图表与杂项清理(fontFamily / emoji / 星期头 i18n)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/components/charts/CategoryPie.tsx`、`src/components/charts/TrendLine.tsx`、`src/components/charts/NetWorthChart.tsx`、`src/components/CalendarView.tsx`、`src/components/charts/CalendarHeatmap.tsx`、`src/components/DatePickerField.tsx`、`src/components/NumpadKeyboard.tsx`、`src/components/NumpadSheet.tsx`、`src/ai/monthlySummary.ts`、`src/app/ai/chat.tsx`、`src/app/transaction/[id].tsx`、`src/app/_error.tsx`、`src/app/import/index.tsx`、`src/components/PostingEditor.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: fontFamily 清零**
|
||||||
|
|
||||||
|
- `theme.typography.X.fontFamily` 引用(CategoryPie 2 处、TrendLine 4 处、ai/chat.tsx 1 处):直接删除该属性(P1 后 typography 无 fontFamily 字段,这些是无效引用)
|
||||||
|
- `fontFamily: 'monospace'`:
|
||||||
|
- 金额/数字场景(CategoryPie:35、transaction/[id].tsx:314、PostingEditor:63、import/index.tsx:391)→ `fontVariant: ['tabular-nums']`
|
||||||
|
- 纯文本场景(_error.tsx:70 堆栈文本)→ 保留 monospace(堆栈对齐需要)或删除——保留,加注释说明
|
||||||
|
- NetWorthChart/CalendarHeatmap 顺便检查一遍(grep 未命中但过一眼 token 合规)
|
||||||
|
|
||||||
|
- [ ] **Step 2: emoji 清零**
|
||||||
|
|
||||||
|
- `src/ai/monthlySummary.ts:81-85`:剥离 `📊/💰/💸/📝` 前缀,保留纯文本行(如 `'总收入:...'`)
|
||||||
|
- `src/app/ai/chat.tsx:123`:方向 emoji(💰/🔄/💸)替换为符号文本:income `'+'`、transfer `'⇄'`、expense `'-'`(或直接金额上色——读上下文选与周边一致的处理;金额色用 theme.colors.financial.*)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 星期头 i18n(3 处硬编码中文)**
|
||||||
|
|
||||||
|
- i18n 加键(zh/en 对等,单键逗号分隔):`'datepicker.weekdays': '一,二,三,四,五,六,日'` / `'Mo,Tu,We,Th,Fr,Sa,Su'`(周一起,DatePickerField 用);`'calendar.weekdays': '日,一,二,三,四,五,六'` / `'Su,Mo,Tu,We,Th,Fr,Sa'`(周日起,CalendarView/CalendarHeatmap 用)
|
||||||
|
- `DatePickerField.tsx`、`CalendarView.tsx`、`CalendarHeatmap.tsx` 的 WEEKDAYS 常量改为 `t('datepicker.weekdays').split(',')` / `t('calendar.weekdays').split(',')`(组件内 useT;注意保持周一/周日起始顺序与各自网格一致,不要换序)
|
||||||
|
|
||||||
|
- [ ] **Step 4: NumpadKeyboard backspace 标签 i18n**
|
||||||
|
|
||||||
|
- i18n 加键:`'numpad.backspace': '退格'` / `'Backspace'`
|
||||||
|
- NumpadKeyboard props 加 `backspaceLabel: string`,backspace 键 accessibilityLabel 用它;`NumpadSheet.tsx` 调用处传 `t('numpad.backspace')`
|
||||||
|
|
||||||
|
- [ ] **Step 5: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm test` → 全绿(i18n 对等校验覆盖新键)
|
||||||
|
Run: `grep -rn "fontFamily" src/ --include="*.tsx" | grep -v "_error.tsx"` → 无输出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 收尾(翻默认 + modal 守卫 + MonthlyReport 删除 + budgets 时区修复 + duplicate 增强)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/store/settingsStore.ts`、`src/components/NumpadSheet.tsx`、`src/components/NumpadSheetHost.tsx`、`src/domain/budgets.ts`、`src/app/(tabs)/transactions.tsx`、`tests/budgets.test.ts`(或现有 budget 测试文件)
|
||||||
|
- Delete: `src/components/charts/MonthlyReport.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: numpadGlobalEntry 默认翻 true**
|
||||||
|
|
||||||
|
`src/store/settingsStore.ts` DEFAULTS 中 `numpadGlobalEntry: false` → `true`。**注意**:已持久化 false 的用户不受影响(hydrate 合并优先级),仅新安装生效——在代码注释中写明。
|
||||||
|
|
||||||
|
- [ ] **Step 2: modal 模式未保存守卫(翻默认的前置)**
|
||||||
|
|
||||||
|
- `NumpadSheet.tsx`:加可选 prop `onDirtyChange?: (dirty: boolean) => void`;把 beforeRemove 守卫里的 dirty 计算抽成 `computeDirty()`(读 formRef),加 `useEffect(() => { onDirtyChange?.(computeDirty()); })`(每次渲染后上报,轻量)
|
||||||
|
- `NumpadSheetHost.tsx`:`const dirtyRef = useRef(false)`;NumpadSheet 传 `onDirtyChange={d => { dirtyRef.current = d; }}`;overlay onPress 与 Modal onRequestClose 改调 `requestClose`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const requestClose = () => {
|
||||||
|
if (!dirtyRef.current) { close(); return; }
|
||||||
|
Alert.alert(t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [
|
||||||
|
{ text: t('common.cancel'), style: 'cancel' },
|
||||||
|
{ text: t('common.discard'), style: 'destructive', onPress: close },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
(Host 需 useT + Alert import;common.discard 键先 grep 确认存在。)
|
||||||
|
|
||||||
|
- [ ] **Step 3: 删除 MonthlyReport.tsx**(P4 确认无引用的死文件)
|
||||||
|
|
||||||
|
Run: `rm src/components/charts/MonthlyReport.tsx`,再 `grep -rn "MonthlyReport" src/` → 无输出
|
||||||
|
|
||||||
|
- [ ] **Step 4: budgets.ts getPeriodRange 本地时区修复**
|
||||||
|
|
||||||
|
读 `src/domain/budgets.ts` 的 `getPeriodRange`(约 93 行):`new Date('YYYY-MM-DD')` 是 UTC 解析,负时区会把边界日算到前一天。改为 split 构造本地 Date(`const [y, m, d] = dateStr.split('-').map(Number); new Date(y, m - 1, d)`),与 periodNav 的 parse 一致。在现有 budget 测试文件(`ls tests/ | grep -i budget`)追加边界用例:`getPeriodRange('monthly', '2026-07-01')` 的 start 必须是 `'2026-07-01'`、end `'2026-07-31'`;`getPeriodRange('weekly', ...)`/`yearly` 各补一例(先读现有测试风格与函数实际行为写期望值——若修复改变了现有行为导致旧测试失败,停止报告 BLOCKED)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: handleDuplicate 带 tags**
|
||||||
|
|
||||||
|
`src/app/(tabs)/transactions.tsx` 的 handleDuplicate draftJson 加 `tags: tx.tags`;同时检查 `NumpadSheet.tsx` 的 draftJson 预填 effect——目前不解析 tags,参照 editId 预填的 tags 回填逻辑(tags store 查名 → fallback TAG_COLORS[3])补上 draft.tags 解析(`Array.isArray(draft.tags)` 时)。cost/price 不带(hasExtras 逻辑已保证含 cost 交易从编辑入口进高级模式;复制是新建,丢 cost 属可接受简化,注释说明)。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 验证**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm test` → 全绿
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: P5 清零审计 + 全项目验收
|
||||||
|
|
||||||
|
- [ ] **Step 1: 清零审计(全部应无输出或仅剩允许项)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
|
||||||
|
grep -rnE "#[0-9A-Fa-f]{3}\b" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
|
||||||
|
grep -rn "fontFamily" src/ --include="*.tsx" # 仅 _error.tsx monospace
|
||||||
|
grep -rn "📊\|💰\|💸\|📝\|🔄\|🎉\|✨" src/ --include="*.ts" --include="*.tsx" # 无输出
|
||||||
|
grep -rn "MonthlyReport\|SpeedDial" src/ # 无输出
|
||||||
|
grep -rn "arrow-back" src/app/ # 无输出(ScreenHeader 内部除外)
|
||||||
|
grep -rn "WEEKDAYS" src/components/ # 无输出(全部走 i18n)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 全量测试 + typecheck**
|
||||||
|
|
||||||
|
Run: `npm test` → 全绿
|
||||||
|
Run: `npm run typecheck` → 无错误
|
||||||
|
|
||||||
|
- [ ] **Step 3: 手工走查(需设备/模拟器)**
|
||||||
|
|
||||||
|
Run: `npm run android`
|
||||||
|
走查清单(浅/暗双主题):
|
||||||
|
|
||||||
|
1. 6 个管理页(分类/预算/规则/周期/信用卡/备注模板):+新增、点按编辑、删除(recurring 为显式按钮,其余长按)、空态文案
|
||||||
|
2. account 页:类型 Tab、开户、调余额、关户
|
||||||
|
3. settings/sync、ai、preferences:header 统一有返回箭头
|
||||||
|
4. **+按钮默认开全局面板**(新装/清数据后):任意页面弹 modal;输入金额后点遮罩/Android 返回 → 弹「放弃修改」确认;无输入直接关
|
||||||
|
5. 设置→偏好里开关关闭 → +回到跳整页
|
||||||
|
6. 交易页左滑复制带标签的交易 → 标签预填
|
||||||
|
7. 报表月 Tab 日历、年报节奏图、TrendLine/CategoryPie 显示正常
|
||||||
|
8. 英文语言:日期选择器/日历星期头为英文缩写
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完成后的项目状态
|
||||||
|
|
||||||
|
P1–P5 全部交付,spec §8 验收标准全达成。剩余已知非阻塞项(不修,记录):TransactionCard memo 被内联 onPress 击穿(性能);todayStr useMemo 跨午夜不刷新;NumpadSheet page 模式无可见返回按钮(spec 允许);_error.tsx monospace 保留(堆栈对齐)。
|
||||||
@@ -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` 保留为语义色(扫描红光),不受主题控制。
|
||||||
@@ -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,反射是兼容方案。
|
||||||
@@ -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` 作为安全网。
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# 弹窗与键盘避让设计指南 (Modal & Keyboard Avoidance)
|
||||||
|
|
||||||
|
本项目(React Native + Expo)所有底部弹窗(FormModal / BottomSheet / NumpadSheet)都基于 RN 原生 `<Modal>`。Modal 会带来两个棘手问题:**底部安全区丢失** 和 **键盘遮挡**。本篇记录反复踩坑后总结的正确模式,避免重蹈覆辙。
|
||||||
|
|
||||||
|
参考实现:`reference_project/BeeCount`(Flutter,`AnimatedPadding + viewInsets.bottom` 方案)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 核心认知:`<Modal>` 脱离主窗口 context
|
||||||
|
|
||||||
|
> **这是所有问题的总根源,必须牢记。**
|
||||||
|
|
||||||
|
RN 的 `<Modal>` 会在原生层创建一个**独立的新窗口**,它**脱离了主窗口的 React 树**。两个直接后果:
|
||||||
|
|
||||||
|
1. **`react-native-safe-area-context` 失效**:主窗口里 expo-router 自动注入的 `SafeAreaProvider` 不会延伸到 Modal 内部。在 Modal 内直接调 `useSafeAreaInsets()` 会返回 `{ top: 0, bottom: 0, ... }`(全零),拿不到手势条高度。
|
||||||
|
2. **`KeyboardAvoidingView` 不可靠**:在 Modal 的独立窗口里,`behavior="height"` 在 Android 上键盘关闭后可能残留 padding(表现为"关闭键盘后弹窗底部有留白");`behavior="padding"` 对 Modal 内的布局响应也不稳定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 正确模式:Modal 内补 SafeAreaProvider + 响应式 paddingBottom
|
||||||
|
|
||||||
|
### 2.1 SafeAreaProvider 补救(解决底部安全区丢失)
|
||||||
|
|
||||||
|
每个 `<Modal>` 内部**重新包一层 `<SafeAreaProvider>`**,让 context 在弹窗内恢复有效:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 错误:Modal 内直接用 useSafeAreaInsets(),返回全零
|
||||||
|
export function FormModal(props) {
|
||||||
|
const insets = useSafeAreaInsets(); // bottom === 0 永远
|
||||||
|
return <Modal>...</Modal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 正确:Modal 内补 SafeAreaProvider,拆成外层 + Content
|
||||||
|
export function FormModal(props) {
|
||||||
|
return (
|
||||||
|
<Modal visible={props.visible} transparent animationType="slide">
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<FormModalContent {...props} />
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function FormModalContent(props) {
|
||||||
|
const insets = useSafeAreaInsets(); // 现在 bottom 是真实手势条高度
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **为什么必须拆两个组件?** `useSafeAreaInsets()` 必须在 `<SafeAreaProvider>` 的**子组件**里调用。同一个组件既渲染 Provider 又调 hook 会拿到全零(hook 在 Provider 上方执行)。
|
||||||
|
|
||||||
|
### 2.2 响应式 paddingBottom(解决键盘遮挡,绝不残留)
|
||||||
|
|
||||||
|
**核心思路(学 BeeCount 的 `AnimatedPadding + viewInsets.bottom`):键盘高度做成 paddingBottom,而不是位移 sheet。**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// useKeyboardHeight hook(见 src/hooks/useKeyboardAvoiding.ts)
|
||||||
|
const kbHeight = useKeyboardHeight();
|
||||||
|
|
||||||
|
// sheet 的 paddingBottom = 基础留白 + 键盘高度
|
||||||
|
<Pressable style={{
|
||||||
|
paddingBottom: Math.max(insets.bottom, 24) + kbHeight,
|
||||||
|
}}>
|
||||||
|
{/* 内容(含确认/取消按钮)会被 padding 顶起,始终在键盘上方 */}
|
||||||
|
</Pressable>
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么这个模式正确:**
|
||||||
|
- 键盘弹出 → `paddingBottom` 增大 → 内容被推到键盘上方,**且 sheet 仍贴底,不会露出遮罩缝**
|
||||||
|
- 键盘关闭 → `paddingBottom` 精确回到 `Math.max(insets.bottom, 24)` → **无残留**
|
||||||
|
- 初始(键盘未弹)→ 只有基础留白 → **不会一打开就有大留白**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ❌ 已验证的失败方案(不要再尝试)
|
||||||
|
|
||||||
|
### 3.1 `KeyboardAvoidingView` 包裹 sheet
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ Modal 内 KAV 不可靠
|
||||||
|
<Modal>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
|
<Sheet/>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</Modal>
|
||||||
|
```
|
||||||
|
**问题**:Android `behavior="height"` 键盘关闭后残留 padding("关闭键盘后有留白");`behavior="position"` 在 Modal 内也不稳定。
|
||||||
|
|
||||||
|
### 3.2 `Animated` translateY 位移整个 sheet
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 位移会露出遮罩缝 + Modal 内键盘事件易误触发
|
||||||
|
const translateY = useKeyboardAvoiding(); // 返回 Animated.Value
|
||||||
|
<Animated.View style={{ transform: [{ translateY }] }}>
|
||||||
|
<Sheet/>
|
||||||
|
</Animated.View>
|
||||||
|
```
|
||||||
|
**两个致命问题**:
|
||||||
|
1. 位移后 sheet 原位置空出来,露出遮罩色 → **"键盘和弹窗之间有留白"**
|
||||||
|
2. Modal 首次渲染时若 `keyboardDidShow` 被残留焦点事件误触发,translateY 变负 → **"一打开就有留白"**
|
||||||
|
|
||||||
|
> 这正是本项目踩过的坑:translateY 方案让用户看到"所有弹窗底部都有留白"。改用响应式 padding 后彻底解决。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 底部留白值的正确计算
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 错误:安全区 + 固定值叠加(会过大)
|
||||||
|
paddingBottom: 36 + insets.bottom // 全面屏手势机 bottom≈48 → 总 84px,太多
|
||||||
|
|
||||||
|
// ✅ 正确:取较大值,不叠加
|
||||||
|
paddingBottom: Math.max(insets.bottom, 24)
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么不能叠加?** 安全区(`insets.bottom`)本身已经覆盖了手势条区域。再叠加一个固定的 36,会在底部堆出 80+px 的空白,视觉上是"一大块留白"。两者应取**较大值**——安全区大时取安全区,安全区为 0(如 MIUI 全面屏手势机)时取最小视觉留白。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 项目中三个公共弹窗的实现
|
||||||
|
|
||||||
|
| 组件 | 文件 | 键盘避让 | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `FormModal` | `src/components/form/FormModal.tsx` | 响应式 paddingBottom(含 TextInput) | 所有管理页 CRUD 复用 |
|
||||||
|
| `BottomSheet` | `src/components/ui/BottomSheet.tsx` | 响应式 paddingBottom + 手势下滑 Animated | 手势 `translateY` 仅用于下滑关闭,与键盘 padding 独立 |
|
||||||
|
| `NumpadSheet` | `src/components/form/NumpadSheet.tsx` | 内部 ScrollView | 记一笔主面板,主要用自定义 NumpadKeyboard,系统键盘场景少 |
|
||||||
|
|
||||||
|
`BottomSheet` 的特殊点:它同时有**手势下滑关闭**(用 `Animated.Value` 做 translateY)和**键盘避让**(用响应式 paddingBottom)。两者必须独立——手势位移走 Animated,键盘避让走 padding,不要合并成一个位移值(`Animated.add` 合并会导致手势和键盘互相干扰)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 居中弹窗(ConfirmDialog)无需处理
|
||||||
|
|
||||||
|
`ConfirmDialog`(删除确认)是居中浮层(`justifyContent: 'center'`),无 TextInput、不贴底、无键盘,**不受安全区/键盘影响,不需要上述任何处理**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 验证清单(修弹窗后必测)
|
||||||
|
|
||||||
|
1. **底部留白**:打开弹窗(不点输入框),底部只有正常小留白,无"一大块空白"。
|
||||||
|
2. **键盘上移**:点输入框唤起键盘 → 确认/取消按钮在键盘上方可见,且弹窗与键盘之间无缝隙。
|
||||||
|
3. **关闭键盘无残留**:收起键盘 → 弹窗精确回到初始状态,底部留白和打开时一致。
|
||||||
|
4. **全面屏手势机**:在 MIUI 等全面屏手势设备上(`insets.bottom = 0`)也正常。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 经验教训
|
||||||
|
|
||||||
|
1. **RN `<Modal>` 是独立窗口**——这是 RN 的设计,不是 bug。任何依赖主窗口 context 的 API(SafeArea、某些 Keyboard 行为)在 Modal 内都要重新建立。
|
||||||
|
2. **键盘避让用 padding,不用位移**——这是 Flutter/RN 通用共识(BeeCount 的 `AnimatedPadding + viewInsets`)。位移方案虽然直觉上像"弹窗上移",但会露出原位置,制造视觉缝隙。
|
||||||
|
3. **Hermes bundle 是二进制**——验证 release 包代码时必须 `grep -a`,否则永远误判"代码没进去"。详见 `android-build-guide.md §7.2`。
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# OCR 推理管线指南 (OCR Pipeline Guide)
|
||||||
|
|
||||||
|
本文记录原生 OCR 引擎(`plugins/ppocr/android/OcrModule.kt`,PP-OCRv6 / ONNX Runtime)的推理管线设计、一次「金额丢小数点」问题的根因与修复,以及**与 PaddleOCR 官方管线的差异和裁剪理由**。同时沉淀一套可复用的「OCR 识别错误诊断方法论」。
|
||||||
|
|
||||||
|
> 配套:构建/编译陷阱见 `android-build-guide.md`;Modal/键盘见 `modal-keyboard-guide.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. TL;DR
|
||||||
|
|
||||||
|
- 管线:`整图 cap 长边 → det 检测文本框 → 逐框 crop → rec 识别 → CTC 解码`,全部在 Kotlin 原生侧,JS 层只透传 base64。
|
||||||
|
- **det 后处理必须用「连通域法」**(4-连通 BFS + 官方 unclip 外扩 + box_score_fast 过滤)。**禁止回退到旧的「水平/垂直投影切行」**——它会把基线上的孤立小数点切到文本框外,导致 `¥143.97` 被识别成 `¥14397`。
|
||||||
|
- 怀疑「模型识别能力不足」之前,**先用官方 PaddleOCR 跑同一对模型做对照**:官方能认出来 → 是我们的预处理/后处理代码问题;官方也认不出 → 才是模型边界。这条纪律避免误判。
|
||||||
|
- 我们用 Python + onnxruntime **逐行镜像** Kotlin 管线做离线验证(同一对 onnx 模型 + 真实截图),算法正确性在移植到 Kotlin 前就锁死,原生改动只是「翻译」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 管线总览与参数
|
||||||
|
|
||||||
|
实现位置:`plugins/ppocr/android/OcrModule.kt`。常量在 `companion object`。
|
||||||
|
|
||||||
|
| 环节 | 函数 | 关键参数 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 整图缩放 | `capLongEdge` | `CAP_LONG_EDGE=3000` | 仅超大图降采样防 OOM;手机截图(≤2400)不预压,使 rec 的 crop 源为高清原图 |
|
||||||
|
| det resize | `resizeForDet` | `DET_LIMIT_MAX_SIDE=1600`,half-up,**无条件对齐 32** | 旧值 960 会让小数点仅 1–2px 而糊掉;无条件对齐 32 防止非法尺寸喂入 det 触发广播报错 |
|
||||||
|
| det 前处理 | `preprocessDet` | mean/std=ImageNet,**通道序 BGR** | `(px shr (8*c)) and 0xFF`(c=0→B),对齐官方训练约定 |
|
||||||
|
| det 后处理 | `dbPostprocess` | `DET_THRESH=0.2`、`BOX_THRESH=0.6`、`UNCLIP_RATIO=1.5`、`MIN_SIZE=5` | 连通域法,见 §3 |
|
||||||
|
| crop | `cropBox` | paddingX=4/paddingY=2,**h≥1.5w 时 rot90** | 竖排文本旋转,对齐官方 `get_rotate_crop_image` |
|
||||||
|
| rec 前处理 | `preprocessRec` | 高 `REC_IMAGE_HEIGHT=48`,宽 **ceil** 上限 `REC_MAX_WIDTH=1280`,**不 pad** | 旧宽上限 320 把长商户名压扁 5×+;不 pad 见 §6 |
|
||||||
|
| 解码 | `ctcGreedyDecode` | blank=0,去重,置信度=非 blank 步均值 | 与官方 `CTCLabelDecode` 逻辑一致 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 丢小数点根因:投影切行把基线点切到框外
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
招行一笔 `GOOGLE*ChatGPT` 交易,金额 `¥143.97` 被 OCR 识别成 `¥14397`;而同一页的 `交易地金额 21.19` 小数点却正常。
|
||||||
|
|
||||||
|
### 根因(已用官方同模型对照坐实)
|
||||||
|
旧的 `dbPostprocess` 用**水平投影切行**:一行活跃像素 ≥ `w/20` 才算文本行。小数点是基线上孤立的 1–2 像素宽,它所在那一行的水平投影**远低于阈值**,被判成「非文本行」→ `rowRange` 的 `y1` 停在数字主体底部,**基线行被排除** → `cropBox` 裁出的图**不含小数点** → rec 看不到点 → `14397`。
|
||||||
|
|
||||||
|
官方 PaddleOCR 的 det 后处理是**轮廓/连通域法**:连通域把「数字 + 基线点」归为同一个域,外接框天然含基线,所以点保住了。
|
||||||
|
|
||||||
|
> 关键证据:把旧管线切出的金额框在原图上**纵向下扩 10px** 再送 rec,小数点立刻以 0.99 置信度回来——证明点一直在二值图里,只是被切行排除在 crop 之外。
|
||||||
|
|
||||||
|
### 为什么前两轮误判(教训)
|
||||||
|
- 第一轮怀疑 **JPEG q60 / scaleDown 降采样**抹掉了点 → 用**原图**裁剪送 rec 仍 `14397`,证伪。
|
||||||
|
- 第二轮怀疑**模型能力边界**(大字号下点太小,rec 不认)→ 用**官方 PaddleOCR 跑同一对模型**,官方正确输出 `¥143.97`,**反转结论**:模型完全能认,问题 100% 在我们的后处理代码。
|
||||||
|
|
||||||
|
**纪律**:在归咎「模型不行」或「图像预处理丢信息」之前,先做官方同模型对照。它能一锤定音区分「模型边界」与「我们的代码 bug」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 修复:连通域法 + 官方 unclip + box_score
|
||||||
|
|
||||||
|
`dbPostprocess` 重写为(与官方轮廓法等价、但纯 Kotlin 零新依赖):
|
||||||
|
|
||||||
|
1. **二值化**:`sig > DET_THRESH(0.2)`,同时保留 `sigMap`(概率图)供后续 box_score 用。状态栏/导航栏(顶/底 8%)清零 + 垂直干扰线(列活跃 >30%)清零保留。
|
||||||
|
2. **连通域标记**:4-连通、**迭代 BFS**(用 `ArrayDeque`,防递归栈溢出),每域记录 bbox `(x0,y0,x1,y1)`、真实像素面积 `area`、域内 `sig` 累加 `sum`。
|
||||||
|
3. **官方 unclip 外扩**:`d = area × UNCLIP_RATIO(1.5) / 周长`,`d` 下限 1,bbox 四向各扩 `d`(half-up 取整)。水平文本下这与官方多边形外扩在结果上几乎等价,把基线标点纳入框。
|
||||||
|
4. **过滤**:扩后短边 `< MIN_SIZE(5)` 丢弃;**box_score_fast** = `sum / area`(**域内文本像素 prob 均值**,见 §5 口径)`< BOX_THRESH(0.6)` 丢弃。
|
||||||
|
5. 映射回原图坐标(×ratioX/ratioY)。
|
||||||
|
|
||||||
|
> 旧的「水平投影切行 + 垂直投影切列 + 临时纵向膨胀」已**整体删除**,不要复活。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 诊断方法论(可复用范式)
|
||||||
|
|
||||||
|
当某张图 OCR 结果错误时,按以下顺序定位,**不要直接改 Kotlin 猜**:
|
||||||
|
|
||||||
|
1. **Python 复现管线**:用 onnxruntime 加载同一对 `ppocrv6_det.onnx` / `ppocrv6_rec.onnx` + `ppocrv6_dict.txt`,**逐行镜像** Kotlin 的缩放/前处理/后处理/crop/解码(脚本见仓库根 `.ocr-test/`,未跟踪,验证完可删)。先确认能复现错误。
|
||||||
|
2. **逐环节隔离**:对出错文本框做对照实验——分别用「降采样图 / 原图」裁剪、放开 rec 宽上限、纵向放大 crop 等,看哪一步改变结果,缩小嫌疑环。
|
||||||
|
3. **官方同模型对照**:`pip install paddleocr` 后用官方 `PaddleOCR(... engine="onnxruntime")` 跑同一张图。**官方能认 → 我们代码 bug;官方也认不出 → 模型边界。** 这一步是判读的分水岭。
|
||||||
|
4. **dump 逐时间步 logits**:对 rec 输出看「出错字符位置」那个时间步,目标字符类的概率是多少、argmax 是什么。能区分「crop 没含该字符(概率≈0)」还是「含了但模型判错」。
|
||||||
|
5. **修复先在 Python 验证台改对、端到端跑通**,再 1:1 翻译到 Kotlin——原生端跑不了 onnx 离线验证,靠这层把算法正确性前置锁死。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 跨语言复现的两个对齐坑
|
||||||
|
|
||||||
|
### 5.1 舍入语义必须一致
|
||||||
|
det 尺寸对齐 32 时,Python 内置 `round` 是**银行家舍入**(22.5→22),Kotlin/Java `Math.round` 是 **half-up**(22.5→23)。两者会让 det 输入差 32 像素列。若验证台用银行家、Kotlin 用 half-up,则 Kotlin 实际跑的是**验证台没验证过的尺寸**,成为盲点。
|
||||||
|
|
||||||
|
**做法**:验证台显式用 half-up(`int(math.floor(x+0.5))`)镜像 `Math.round`,使两边输入逐像素一致;Kotlin 侧 `Math.ceil` 注意只有 `double` 重载(传 Float 编译失败,需 `toDouble()`,详见 `android-build-guide.md §5.3`)。
|
||||||
|
|
||||||
|
### 5.2 box_score_fast 的口径
|
||||||
|
`box_score_fast` 是**文本像素(连通域 mask 内)的 prob 均值**,**不是整个 bbox 矩形的均值**。后者含大量 0 值背景,会把分数稀释到阈值以下、把所有框误杀(实测曾出现「连通域 24 个、保留 0 个」)。用 `scipy.ndimage.mean(sig, labels)` / Kotlin 的 `sum/area` 取域内均值才对。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 与官方差异的裁剪清单(为什么不做某些官方能力)
|
||||||
|
|
||||||
|
两条标尺贯穿全部判断:① 我们是 **Android ONNX CPU**,无 GPU、无 OpenCV、包体/内存敏感;② 输入几乎全是 **App 截图**——水平文本、无旋转、无镜头畸变、无弯曲。官方很多重型能力是为「拍照/扫描/任意文档」的宽分布设计的,对我们的窄分布是 over-engineering。
|
||||||
|
|
||||||
|
| 项 | 类别 | 不做/保留现状的主因 | 重新评估的触发条件 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 透视矫正 `warpPerspective` | P2 | 截图无透视;需引 OpenCV(~30MB so) 或自写 warp | 上「拍照记账」 |
|
||||||
|
| det 通道序 BGR | **已做** | 零成本对齐训练约定,顺手做了 | — |
|
||||||
|
| 顶/底 8% + 列 30% 硬清零 | 保留 | 截图利>弊;无贴边/JS 预裁需求 | 出现贴边误删 或 JS 预裁状态栏 |
|
||||||
|
| `textline_orientation` 行方向模型 | 不做 | 截图恒 0°,每框白跑一次 ONNX 推理 | 上「拍照记账」 |
|
||||||
|
| `doc_orientation` + `UVDoc` 去弯 | 不做 | 截图无旋转/弯曲;移动端最贵的两个模型 | 产品转向拍纸质账本(基本不会) |
|
||||||
|
| rec 右侧 pad 到 320 | 不做 | 逐行 + ONNX 动态宽,pad 只增算力无收益(**少数「与官方不同但我们更对」的点**) | 改做 rec 批推理(大概率不做) |
|
||||||
|
| pyclipper 精确多边形膨胀 | 不做 | 水平文本 bbox 近似等价;需引 Clipper2 新依赖 | 支持倾斜/拍照文本 |
|
||||||
|
|
||||||
|
> 一句话:官方「完整管线」为宽分布 + 强算力调;我们为窄分布 + 弱算力,正确做法是**按输入分布裁掉用不上的重型环节**,只移植真正提升截图质量的部分(连通域取框、提分辨率、rec 放宽、参数对齐)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 参数对照表(旧 → 新 → 官方)
|
||||||
|
|
||||||
|
| 参数 | 旧 | 新 | 官方(OCR 管线生效值) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 整图缩放 | 短边压 720 | 长边 cap 3000 | 不降采样(max_side_limit=4000) |
|
||||||
|
| det 长边 | 960 / floor | **1600** / half-up / 无条件对齐 32 | 不降采样 / round 对齐 32 |
|
||||||
|
| det thresh | 0.3 | **0.2** | 0.3(模型 yml 0.2) |
|
||||||
|
| det box_thresh | 无 | **0.6** | 0.6(模型 yml 0.45) |
|
||||||
|
| det unclip_ratio | 无(临时 0.4 行高) | **1.5**(area×ratio/周长) | 1.5 |
|
||||||
|
| det 取框法 | 投影切行 | **连通域** | 轮廓 findContours+unclip |
|
||||||
|
| rec 宽 cap | 320 / floor | **1280** / ceil | 3200 / ceil |
|
||||||
|
| det 通道序 | RGB | **BGR** | BGR |
|
||||||
|
| crop 竖排 | 无 | **rot90** | rot90 + 透视 |
|
||||||
|
|
||||||
|
> 移动端折中说明:det 长边取 1600(非官方全分辨率)是为 CPU 性能;box_thresh 取 0.6(非模型 yml 的 0.45)是实测 0.45 仅多收噪声无额外召回。两者都是「在官方语义下向移动端性能倾斜」的有意识折中,非疏漏。
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# 硅基流动与智谱 AI 账单 OCR 及 JSON 结构化提取实测报告
|
||||||
|
|
||||||
|
本报告针对测试图片(`20260725-142945.jpg` 信用卡账单截图),对 **硅基流动 (SiliconFlow)** 与 **智谱 AI (BigModel.cn)** 平台的视觉大模型、OCR 引擎及多模型组合进行了多轮实测基准对比。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [测试结论与终极选型建议](#一-测试结论与终极选型建议)
|
||||||
|
2. [实测性能对比总表](#二-实测性能对比总表)
|
||||||
|
3. [硅基流动 (SiliconFlow) 平台测试详解](#三-硅基流动-siliconflow-平台测试详解)
|
||||||
|
4. [智谱 AI (BigModel.cn) 平台测试详解](#四-智谱-ai-bigmodelcn-平台测试详解)
|
||||||
|
5. [免费模型配额与 Rate Limits 规则](#五-免费模型配额与-rate-limits-规则)
|
||||||
|
6. [DriftLedger (浮记) 架构与账户分配流程](#六-driftledger-浮记-架构与账户分配流程)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、 测试结论与终极选型建议
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **最佳单 API 直出方案**:智谱 **`glm-4v-flash`**
|
||||||
|
> - **耗时仅 3.22 秒**,单次 API 请求即可直接输出包含商户、金额、日期、卡号、原币的**完美 JSON**,且 100% 免费。
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **最佳双阶段流水线方案**:硅基流动 **`DeepSeek-OCR` + `THUDM/GLM-4-9B-0414`**
|
||||||
|
> - **总耗时仅 4.36 秒**(阶段一 OCR 1.17s + 阶段二 文本提 JSON 3.19s),全免费,OCR 文字识别准确率 100%。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **最佳跨国/外币高精度方案**:**`DeepSeek-OCR` + `deepseek-ai/DeepSeek-V3`**
|
||||||
|
> - **总耗时 5.39 秒**,具备强大的语义推理能力,不仅提取出原币数字 `21.19`,还智能推断并补充了单位 `USD`。每次调用费用仅约 0.0008 分钱。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、 实测性能对比总表
|
||||||
|
|
||||||
|
测试图片:`20260725-142945.jpg`(招商银行信用卡消费通知,含商户 `GOOGLE *ChatGPT`,金额 `¥143.97`,信用卡 `3315`,原币 `21.19`,时间 `2026-07-23 00:00:00`)。
|
||||||
|
|
||||||
|
| 平台 | 模型 / 组合名称 | 架构类型 | 阶段1 耗时 | 阶段2 耗时 | **总耗时** | 提取准确度与 JSON 质量 | 资费类型 |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| **智谱 AI** | **`glm-4v-flash`** | 单阶段 VLM | - | - | **3.22s** | **100% 完美** (提取极精准,结构清晰) | **100% 免费** |
|
||||||
|
| **硅基流动** | **`DeepSeek-OCR` + `GLM-4-9B-0414`** | 双阶段流水线 | 1.17s | 3.19s | **4.36s** | **100% 准确** (结构化规范,无幻觉) | **100% 免费** |
|
||||||
|
| **硅基流动** | **`DeepSeek-OCR` + `DeepSeek-V3`** | 双阶段流水线 | 1.17s | 4.22s | **5.39s** | **智能推导** (自动补全原币单位 `USD`) | 低成本计费 (~0.0008分/次) |
|
||||||
|
| **智谱 AI** | **`glm-4.1v-thinking-flash`** | 思维链 VLM | - | - | **7.03s** | 带有 `<think>` 推理链,易超长截断 | **100% 免费** |
|
||||||
|
| **硅基流动** | **`Qwen/Qwen3-VL-8B-Instruct`** | 单阶段 VLM | - | - | **10.34s** | **100% 准确** (一次生成完成) | **100% 免费** |
|
||||||
|
| **硅基流动** | **`PaddleOCR-VL-1.5`** | 坐标类 OCR | - | - | **62s ~ 114s** | 包含大量 `<\|LOC_xxx\|>` 点位标签与杂音 | **100% 免费** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、 硅基流动 (SiliconFlow) 平台测试详解
|
||||||
|
|
||||||
|
### 1. `deepseek-ai/DeepSeek-OCR`
|
||||||
|
- **定位**:端到端纯文档/图片至 Markdown 识别模型。
|
||||||
|
- **优点**:速度极快(**1.17s ~ 1.48s**),完美还原表格与富文本结构。
|
||||||
|
- **限制**:不支持通用大语言模型的指令遵循(无法直接提示词输出 JSON,强制开启 `json_object` 会陷入空格生成死循环)。
|
||||||
|
|
||||||
|
### 2. `PaddlePaddle/PaddleOCR-VL-1.5`
|
||||||
|
- **定位**:带坐标识别的文档/版面分析大模型。
|
||||||
|
- **缺点**:缺少张量并行加速,单次生成生成耗时高达 60~110 秒,输出结果混杂大量的点位 Token。
|
||||||
|
|
||||||
|
### 3. 双阶段流水线提取结果(实际返回 JSON)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"occurredAt": "2026-07-23 00:00:00",
|
||||||
|
"amount": 143.97,
|
||||||
|
"currency": "CNY",
|
||||||
|
"direction": "expense",
|
||||||
|
"counterparty": "GOOGLE *ChatGPT",
|
||||||
|
"memo": "信用卡尾号: 3315, 原币金额: 21.19 USD"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、 智谱 AI (BigModel.cn) 平台测试详解
|
||||||
|
|
||||||
|
### 1. 智谱免费 Flash 模型分类与特性
|
||||||
|
|
||||||
|
- **`glm-4v-flash`**:智谱基础免费视觉模型,**实测表现最稳定、速度最快 (3.22s)**,原生支持 `json_object`。
|
||||||
|
- **`glm-4.6v-flash`**:最新轻量多模态模型,支持原生工具调用(Tool Calling),但免费接口频控较严(并发时易触发 HTTP 429)。
|
||||||
|
- **`glm-4.1v-thinking-flash`**:具备 Thinking 思维链机制,回答前会在 `<think>` 中展开多步骤思考逻辑。
|
||||||
|
- **`glm-4-flash-250414`**:纯文本/代码轻量旗舰模型,适合放在双阶段流水线的 Stage 2。
|
||||||
|
- **`CogView-3-Flash` / `CogVideoX-Flash`**:分别用于文生图与视频生成。
|
||||||
|
|
||||||
|
### 2. `glm-4v-flash` 实测输出数据
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"occurredAt": "2026-07-23 00:00:00",
|
||||||
|
"amount": 143.97,
|
||||||
|
"currency": "CNY",
|
||||||
|
"direction": "expense",
|
||||||
|
"counterparty": "GOOGLE *ChatGPT",
|
||||||
|
"memo": {
|
||||||
|
"card_last_4_digits": "3315",
|
||||||
|
"original_amount": 21.19,
|
||||||
|
"country_or_region": "美国"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、 免费模型配额与 Rate Limits 规则
|
||||||
|
|
||||||
|
### 1. 硅基流动 (SiliconFlow)
|
||||||
|
- **门槛**:需完成账户实名认证。
|
||||||
|
- **配额**:
|
||||||
|
- **RPM (Requests Per Minute)**:100 ~ 1,000 RPM(中小模型 500~1000 RPM)。
|
||||||
|
- **TPM (Tokens Per Minute)**:50,000 ~ 100,000 TPM。
|
||||||
|
- **Pro/ 专线**:带 `Pro/` 前缀的模型(如 `Pro/deepseek-ai/DeepSeek-V3`)为付费独占集群,无免费版的固定并发上限。
|
||||||
|
|
||||||
|
### 2. 智谱开放平台 (BigModel.cn)
|
||||||
|
- **免费规则**:所有的 Flash 命名系列(`GLM-4-Flash`、`GLM-4V-Flash` 等)API 均免费开放。
|
||||||
|
- **并发控制**:对高频连续调用设置了 RPM 阈值(触发时返回 `HTTP 429 Too Many Requests`),代码中需配置指数退避或 1~2 秒重试间隔。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、 DriftLedger (浮记) 架构与账户分配流程
|
||||||
|
|
||||||
|
针对识别结果中“为什么只包含时间、金额、商户名,而没有 Beancount 账户”的说明:
|
||||||
|
|
||||||
|
### 1. 职责解耦设计
|
||||||
|
识图/OCR 模块只负责提取客观的**原始交易事件 (`ImportedEvent`)**,定义于 [types.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/core/types.ts#L31-L38)。由于每个用户的 Beancount 账户名(如 `Assets:招商银行:信用卡3315`)是高度个性化的,模型无法预知用户本地账本结构。
|
||||||
|
|
||||||
|
### 2. 账本账户决定流程
|
||||||
|
账户映射是在 **`BillPipeline` 责任链** 中完成的:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[账单截图]
|
||||||
|
│
|
||||||
|
▼ 1. 图像解析 (AiVisionProcessor.ts)
|
||||||
|
[ImportedEvent] ─── (仅含时间、金额、商户 GOOGLE *ChatGPT、备注 3315)
|
||||||
|
│
|
||||||
|
▼ 2. 进入流水线 BillPipeline.process() ─── [billPipeline.ts]
|
||||||
|
├──> ① 转账识别 (recognizeTransfers)
|
||||||
|
├──> ② 批次去重与历史去重 (dedup)
|
||||||
|
└──> ③ 规则匹配与账户分类 (rules.ts)
|
||||||
|
├── 匹配商户/卡号 "3315" ──> 资金来源账户 (sourceAccount): Assets:招商银行:信用卡3315
|
||||||
|
└── 匹配商户 "GOOGLE *ChatGPT" ──> 支出分类账户 (categoryAccount): Expenses:订阅服务:AI
|
||||||
|
│
|
||||||
|
▼ 3. 输出标准交易草稿 (TransactionDraft)
|
||||||
|
[TransactionDraft] ─── 包含标准的双式记账 Postings 分录
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 相关代码位置
|
||||||
|
- **原始事件类型定义**:[src/domain/core/types.ts:L31-L38](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/core/types.ts#L31-L38)
|
||||||
|
- **账单流水线责任链**:[src/domain/pipeline/billPipeline.ts:L94-L180](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/pipeline/billPipeline.ts#L94-L180)
|
||||||
|
- **规则分类与账户映射引擎**:[src/domain/rules/rules.ts:L1-L60](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/rules/rules.ts#L1-L60)
|
||||||
|
- **AI 识图处理器服务**:[src/services/ocr/AiVisionProcessor.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/services/ocr/AiVisionProcessor.ts)
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# 账单识别与 Beancount 账户分类:合并 vs 解耦架构对比文档
|
||||||
|
|
||||||
|
在双式记账(Beancount / DriftLedger)离线移动客户端开发中,**“原始账单识别(Bill Recognition)”** 与 **“Beancount 账户分类(Account Classification)”** 是两个核心处理阶段。
|
||||||
|
|
||||||
|
本文档深度对比分析将这两个阶段进行 **合并(单步一体求值)** 与 **解耦(双阶段责任链)** 的架构差异,并结合 DriftLedger 源码实现,分别涵盖 **传统规则渠道(Rule-based)** 与 **大模型/AI 渠道(LLM/VLM)** 的表现。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [架构定义与处理模式](#一-架构定义与处理模式)
|
||||||
|
2. [传统规则渠道(Rule-based Channel)对比](#二-传统规则渠道-rule-based-channel-对比)
|
||||||
|
3. [大模型/AI 渠道(AI/LLM Channel)对比](#三-大模型ai-渠道-aillm-channel-对比)
|
||||||
|
4. [多维性能与工程指标全景对比表](#四-多维性能与工程指标全景对比表)
|
||||||
|
5. [DriftLedger (浮记) 混合架构落地推荐](#五-driftledger-浮记-混合架构落地推荐)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、 架构定义与处理模式
|
||||||
|
|
||||||
|
在多通道系统架构中,无论采用合并还是解耦,**多通道原始内容采集(Ingestion Adapters)** 始终是前置的:
|
||||||
|
- **通道 1:拍照/图库 OCR**(获取账单图像或 OCR Markdown 文本)
|
||||||
|
- **通道 2:无障碍服务 UI 节点提取**(解析微信/支付宝支付成功页的 node 文本,参见 [accessibilityParser.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/services/automation/accessibilityParser.ts))
|
||||||
|
- **通道 3:短信与系统通知监控**(解析银行/支付软件推送通知字符串)
|
||||||
|
|
||||||
|
合并与解耦的核心区别,在于拿到**账单原始内容 (Raw Bill Text)** 之后,**“事实字段识别”** 与 **“Beancount 账户分配”** 是在单次操作中完成,还是拆分为多个阶段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
========================================================================================
|
||||||
|
【多通道多源输入】
|
||||||
|
(通道A: 拍照OCR文本 / 通道B: 无障碍UI节点文本 / 通道C: 短信通知文本)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
【合并架构 (Merged Mode / 一体化文本求值)】
|
||||||
|
单次求值 (AI Prompt 或 规则引擎):
|
||||||
|
输入: 账单原始内容 + 用户 Beancount 动态账户列表
|
||||||
|
输出: 直接一步得到【Beancount 交易草稿 TransactionDraft】(含时间、金额、商户、资金账户、支出账户)
|
||||||
|
========================================================================================
|
||||||
|
|
||||||
|
========================================================================================
|
||||||
|
【多通道多源输入】
|
||||||
|
(通道A: 拍照OCR文本 / 通道B: 无障碍UI节点文本 / 通道C: 短信通知文本)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
【解耦架构 (Decoupled Mode / 责任链流水线)】
|
||||||
|
阶段 1(事实识别): 仅识别事实,输出无账户关联的【ImportedEvent】(时间、金额、商户、备注)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
阶段 2(账户分类): 送入 BillPipeline,由规则引擎 RuleEngine 或轻量 AI 匹配 Beancount 账户
|
||||||
|
========================================================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、 传统规则渠道(Rule-based Channel)对比
|
||||||
|
|
||||||
|
在规则匹配渠道下,账户名均来自用户动态配置的规则库 (`Rule[]`),绝非代码硬编码:
|
||||||
|
|
||||||
|
### 1. 动态规则合并模式(Single-Pass Rule Evaluation / 一体化动态匹配)
|
||||||
|
- **处理逻辑**:
|
||||||
|
拿到多通道的原始内容后,解析器直接调用用户配置的动态规则库 `Rule[]`。一条规则同时包含了**事实判定条件**与**双向账户分配**:
|
||||||
|
```typescript
|
||||||
|
// 用户在 APP 中配置的动态规则对象(非代码硬编码)
|
||||||
|
const rule: Rule = {
|
||||||
|
id: "rule-101",
|
||||||
|
counterpartyContains: "星巴克",
|
||||||
|
sourceAccount: "Assets:Alipay:Balance", // 动态资金来源账户
|
||||||
|
categoryAccount: "Expenses:Food:Coffee", // 动态分类支出账户
|
||||||
|
narration: "星巴克咖啡"
|
||||||
|
};
|
||||||
|
|
||||||
|
// 单次求值:一步构造出带有账户的完整 TransactionDraft 草稿
|
||||||
|
if (matches(rawText, rule)) {
|
||||||
|
return createDraftDirectly(rawText, rule.sourceAccount, rule.categoryAccount);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **特点**:
|
||||||
|
单次求值即产生最终 `TransactionDraft`。如果入口预填了 `sourceAccount` / `categoryAccount`(参考 DriftLedger 代码 [billPipeline.ts:L169-L180](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/pipeline/billPipeline.ts#L169-L180)),流水线直接接受该草稿。
|
||||||
|
|
||||||
|
### 2. 规则解耦模式(Two-Stage Evaluation / 责任链流水线匹配)
|
||||||
|
- **处理逻辑**:
|
||||||
|
1. **阶段 1 (事实化)**:所有通道(通知、无障碍、短信、账单 CSV)统一输出仅包含事实的 `ImportedEvent`(无账户关联)。
|
||||||
|
2. **阶段 2 (责任链评估)**:`BillPipeline` 依次执行:转账识别 ➔ 批次去重 ➔ 历史去重 ➔ 送入 `RuleEngine` 匹配规则库中的账户。
|
||||||
|
- **特点**:
|
||||||
|
解析器只需关心文本事实提取,账户分配归口给 `BillPipeline` 与 `RuleEngine` 统一调度。在分配账户前可先过滤重复事件,避免无效计算。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、 大模型/AI 渠道(AI/LLM Channel)对比
|
||||||
|
|
||||||
|
利用大语言模型(LLM)或多模态视觉模型(VLM)处理多通道获取的原始内容:
|
||||||
|
|
||||||
|
### 1. AI 文本级合并模式(单次 LLM 提示词一步直出草稿)
|
||||||
|
- **处理逻辑**:
|
||||||
|
多通道(拍照 OCR / 无障碍节点文本 / 短信通知)提取到**账单原始内容字符串**后,**在单次 LLM 请求中**将“账单原始内容”与“用户本地 Beancount 候选账户列表”一同作为 Prompt 提交给大模型(如 `glm-4-flash-250414` 或 `Qwen2.5-7B-Instruct`)。
|
||||||
|
- **流程**:
|
||||||
|
```text
|
||||||
|
[多通道原始内容文本] + [用户动态账户列表] ───(单次 LLM 求值)───> [Beancount TransactionDraft]
|
||||||
|
```
|
||||||
|
- **优势**:
|
||||||
|
- **通道统一**:不管是图片 OCR、微信支付成功的节点文本、还是银行扣款短信,都使用同一种“文本级合并 Prompt”,直接返回填充好 `sourceAccount` 和 `categoryAccount` 的 JSON 草稿。
|
||||||
|
- **响应极快**:纯文本大模型(如 `glm-4-flash-250414`)处理文本合并请求耗时仅 **~2.2 秒**。
|
||||||
|
- **劣势**:
|
||||||
|
- **Token 开销**:每次请求都需携带用户账户列表。
|
||||||
|
- **确定性风险**:可能偶发生成不存在的账户名。
|
||||||
|
|
||||||
|
### 2. AI 解耦模式(内容识别提取事实 ➔ 独立分类器)
|
||||||
|
- **处理逻辑**:
|
||||||
|
1. **阶段 1(事实识别)**:模型仅负责解析多通道原始内容,输出不含账户的 `ImportedEvent`(时间、金额、商户、备注)。
|
||||||
|
2. **阶段 2(账户分类)**:优先走本地 `RuleEngine`;若未命中,再调用轻量 LLM(耗时 ~2.2 秒)或向量 Embedding 模型(如 `bge-m3`,耗时 **0.3 秒**)在单独的 Prompt / 向量空间中挑选账户。
|
||||||
|
- **优势**:
|
||||||
|
- **绝对确定性**:已知商户 100% 走本地规则引擎(0 延迟、0 错误率)。
|
||||||
|
- **离线优先 (Offline-first)**:本地识别出事实后,断网状态下本地规则引擎依然能完成账户分类。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、 多维性能与工程指标全景对比表
|
||||||
|
|
||||||
|
| 评估维度 | **规则合并模式 (单次一体匹配)** | **规则解耦模式 (责任链流水线)** | **AI 文本级合并模式 (单次LLM求值)** | **AI 解耦模式 (双阶段链式)** |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| **原始内容来源** | 多通道 (OCR/无障碍/短信) | 多通道 (OCR/无障碍/短信) | 多通道 (OCR/无障碍/短信) | 多通道 (OCR/无障碍/短信) |
|
||||||
|
| **识别与分类点** | 文本解析时同步求值 | 分两阶段:解析事实 ➔ 匹配账户 | **单次 LLM 同时提取事实+账户** | 分两阶段:提取事实 ➔ LLM/向量选账户 |
|
||||||
|
| **响应延迟 (Latency)** | **< 1ms** | **< 1ms** | **~2.2s** (`glm-4-flash-250414`) | **~4.3s** (OCR + 分类) |
|
||||||
|
| **API 调用次数** | 0 次 | 0 次 | **1 次** | 1 ~ 2 次 |
|
||||||
|
| **断网/离线鲁棒性** | 完全支持 | 完全支持 | 不支持 (需在线 LLM) | **半支持** (文本提取后离线规则分类) |
|
||||||
|
| **准确率与确定性** | **100%** | **100%** | 高 (约 95%,受 Prompt 引导) | **100%** (已知规则优先覆写) |
|
||||||
|
| **代码可维护性** | 良好 | **极佳** (领域分层极清) | 优秀 (通道无缝复用 Prompt) | **极佳** (管道可随意组装) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、 DriftLedger (浮记) 混合架构落地推荐
|
||||||
|
|
||||||
|
结合多通道采集(OCR / 无障碍 / 短信通知)与 DriftLedger 的代码实现 [billPipeline.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/pipeline/billPipeline.ts#L169-L180),推荐采取 **“多通道输入 ➔ AI 文本级合并直出 ➔ 本地流水线预填覆写”** 的最佳落地架构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
【通道 A: 拍照 OCR 文本】 【通道 B: 无障碍 UI 文本】 【通道 C: 短信通知文本】
|
||||||
|
│ │ │
|
||||||
|
└────────────────────────────┼────────────────────────────┘
|
||||||
|
▼
|
||||||
|
【AI 文本级合并求值 (glm-4-flash-250414)】
|
||||||
|
单次 LLM 传入多通道文本 + 用户 Beancount 动态账户列表
|
||||||
|
2.2 秒内一步生成带预填账户的 `ImportedEvent` (含 sourceAccount/categoryAccount)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
【领域核心层 / BillPipeline 责任链】
|
||||||
|
┌────────────────────────────────┴────────────────────────────────┐
|
||||||
|
▼ ▼
|
||||||
|
【1: 本地规则覆写 (RuleEngine)】 【2: 多通道去重 (Dedup)】
|
||||||
|
若匹配到用户定义的 100% 精确 Rule 规则, 自动过滤历史账本已存在的重复交易,
|
||||||
|
本地规则强行覆写 AI 预测的账户,保障零差错。 避免多次拍照或无障碍重复记账。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 总结:
|
||||||
|
1. **多通道采集(OCR / 无障碍 / 短信)** 是解耦的前置适配层。
|
||||||
|
2. **AI 合并模式** 指的是在获取到账单文本后,**用单次 LLM 请求同时完成事实提取与账户选择**(2.2 秒极速返回)。
|
||||||
|
3. **架构落地**:多通道文本输入 ➔ AI 文本级合并一步预填 ➔ 本地流水线校验覆写。
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# 硅基流动与智谱 AI 账单 OCR、JSON 提取及 AI 账户自动分类实测报告
|
||||||
|
|
||||||
|
本报告针对测试图片(`20260725-142945.jpg` 信用卡账单截图),对 **硅基流动 (SiliconFlow)** 与 **智谱 AI (BigModel.cn)** 平台的视觉大模型、OCR 引擎、免费文本大模型及向量 Embedding 模型进行了全流程实测基准对比。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
1. [测试结论与终极选型建议](#一-测试结论与终极选型建议)
|
||||||
|
2. [实测性能对比总表](#二-实测性能对比总表)
|
||||||
|
3. [硅基流动 (SiliconFlow) 平台测试详解](#三-硅基流动-siliconflow-平台测试详解)
|
||||||
|
4. [智谱 AI (BigModel.cn) 平台测试详解](#四-智谱-ai-bigmodelcn-平台测试详解)
|
||||||
|
5. [免费模型配额与 Rate Limits 规则](#五-免费模型配额与-rate-limits-规则)
|
||||||
|
6. [DriftLedger (浮记) 架构与账户分配流程](#六-driftledger-浮记-架构与账户分配流程)
|
||||||
|
7. [AI 账户自动分类实测 (免费 LLM vs 路径 B 向量检索)](#七-ai-账户自动分类实测-免费-llm-vs-路径-b-向量检索)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、 测试结论与终极选型建议
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **最佳单 API 识图直出方案**:智谱 **`glm-4v-flash`**
|
||||||
|
> - **耗时仅 3.22 秒**,单次 API 请求即可直接输出包含商户、金额、日期、卡号、原币的**完美 JSON**,且 100% 免费。
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **最佳双阶段识图流水线方案**:硅基流动 **`DeepSeek-OCR` + `THUDM/GLM-4-9B-0414`**
|
||||||
|
> - **总耗时仅 4.36 秒**(阶段一 OCR 1.17s + 阶段二 文本提 JSON 3.19s),全免费,OCR 文字识别准确率 100%。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> **最佳 AI 账户自动分类方案**:智谱 **`glm-4-flash-250414`** (免费 LLM) / 硅基 **`BAAI/bge-m3`** (向量路径 B)
|
||||||
|
> - **免费 LLM 直选 (`glm-4-flash-250414`)**:耗时仅 **2.22 秒**,100% 精确推导出 `Liabilities:CreditCard:CMB:3315` 与 `Expenses:Software:Subscription`。
|
||||||
|
> - **向量路径 B (`bge-m3`)**:耗时仅 **0.32 秒 (320 毫秒)**,亚秒级定位匹配目标卡片。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、 实测性能对比总表
|
||||||
|
|
||||||
|
测试图片:`20260725-142945.jpg`(招商银行信用卡消费通知,含商户 `GOOGLE *ChatGPT`,金额 `¥143.97`,信用卡 `3315`,原币 `21.19`,时间 `2026-07-23 00:00:00`)。
|
||||||
|
|
||||||
|
| 阶段 / 任务 | 平台 | 模型 / 组合名称 | 架构类型 | 阶段耗时 | **总耗时** | 提取准确度与 JSON 质量 | 资费类型 |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| **识图 JSON 提取** | **智谱 AI** | **`glm-4v-flash`** | 单阶段 VLM | - | **3.22s** | **100% 完美** (提取极精准,结构清晰) | **100% 免费** |
|
||||||
|
| **识图 JSON 提取** | **硅基流动** | **`DeepSeek-OCR` + `GLM-4-9B-0414`** | 双阶段流水线 | 1.17s + 3.19s | **4.36s** | **100% 准确** (结构化规范,无幻觉) | **100% 免费** |
|
||||||
|
| **识图 JSON 提取** | **硅基流动** | **`DeepSeek-OCR` + `DeepSeek-V3`** | 双阶段流水线 | 1.17s + 4.22s | **5.39s** | **智能推导** (自动补全原币单位 `USD`) | 低成本 (~0.0008分/次) |
|
||||||
|
| **识图 JSON 提取** | **智谱 AI** | **`glm-4.1v-thinking-flash`** | 思维链 VLM | - | **7.03s** | 带有 `<think>` 推理链,易超长截断 | **100% 免费** |
|
||||||
|
| **账户智能分类** | **智谱 AI** | **`glm-4-flash-250414`** | 免费 LLM 分类 | - | **2.22s** | **100% 精确** (同时给出资金与支出账户及理由) | **100% 免费** |
|
||||||
|
| **账户智能分类** | **硅基流动** | **`THUDM/GLM-4-9B-0414`** | 免费 LLM 分类 | - | **3.45s** | **100% 精确** | **100% 免费** |
|
||||||
|
| **账户智能分类** | **硅基流动** | **`BAAI/bge-m3`** | 向量路径 B 检索 | - | **0.32s** | **亚秒级最快** (Top 1 相似度 0.5662 精准命中) | **100% 免费** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、 硅基流动 (SiliconFlow) 平台测试详解
|
||||||
|
|
||||||
|
### 1. `deepseek-ai/DeepSeek-OCR`
|
||||||
|
- **定位**:端到端纯文档/图片至 Markdown 识别模型。
|
||||||
|
- **优点**:速度极快(**1.17s ~ 1.48s**),完美还原表格与富文本结构。
|
||||||
|
- **限制**:不支持通用大语言模型的指令遵循(无法直接提示词输出 JSON,强制开启 `json_object` 会陷入空格生成死循环)。
|
||||||
|
|
||||||
|
### 2. `PaddlePaddle/PaddleOCR-VL-1.5`
|
||||||
|
- **定位**:带坐标识别的文档/版面分析大模型。
|
||||||
|
- **缺点**:缺少张量并行加速,单次生成生成耗时高达 60~110 秒,输出结果混杂大量的点位 Token。
|
||||||
|
|
||||||
|
### 3. 双阶段流水线提取结果(实际返回 JSON)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"occurredAt": "2026-07-23 00:00:00",
|
||||||
|
"amount": 143.97,
|
||||||
|
"currency": "CNY",
|
||||||
|
"direction": "expense",
|
||||||
|
"counterparty": "GOOGLE *ChatGPT",
|
||||||
|
"memo": "信用卡尾号: 3315, 原币金额: 21.19 USD"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、 智谱 AI (BigModel.cn) 平台测试详解
|
||||||
|
|
||||||
|
### 1. 智谱免费 Flash 模型分类与特性
|
||||||
|
|
||||||
|
- **`glm-4v-flash`**:智谱基础免费视觉模型,**实测表现最稳定、速度最快 (3.22s)**,原生支持 `json_object`。
|
||||||
|
- **`glm-4.6v-flash`**:最新轻量多模态模型,支持原生工具调用(Tool Calling),但免费接口频控较严(并发时易触发 HTTP 429)。
|
||||||
|
- **`glm-4.1v-thinking-flash`**:具备 Thinking 思维链机制,回答前会在 `<think>` 中展开多步骤思考逻辑。
|
||||||
|
- **`glm-4-flash-250414`**:纯文本/代码轻量旗舰模型,适合放在双阶段流水线的 Stage 2 以及账户分类。
|
||||||
|
- **`CogView-3-Flash` / `CogVideoX-Flash`**:分别用于文生图与视频生成。
|
||||||
|
|
||||||
|
### 2. `glm-4v-flash` 实测输出数据
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"occurredAt": "2026-07-23 00:00:00",
|
||||||
|
"amount": 143.97,
|
||||||
|
"currency": "CNY",
|
||||||
|
"direction": "expense",
|
||||||
|
"counterparty": "GOOGLE *ChatGPT",
|
||||||
|
"memo": {
|
||||||
|
"card_last_4_digits": "3315",
|
||||||
|
"original_amount": 21.19,
|
||||||
|
"country_or_region": "美国"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、 免费模型配额与 Rate Limits 规则
|
||||||
|
|
||||||
|
### 1. 硅基流动 (SiliconFlow)
|
||||||
|
- **门槛**:需完成账户实名认证。
|
||||||
|
- **配额**:
|
||||||
|
- **RPM (Requests Per Minute)**:100 ~ 1,000 RPM(中小模型 500~1000 RPM)。
|
||||||
|
- **TPM (Tokens Per Minute)**:50,000 ~ 100,000 TPM。
|
||||||
|
- **Pro/ 专线**:带 `Pro/` 前缀的模型(如 `Pro/deepseek-ai/DeepSeek-V3`)为付费独占集群,无免费版的固定并发上限。
|
||||||
|
|
||||||
|
### 2. 智谱开放平台 (BigModel.cn)
|
||||||
|
- **免费规则**:所有的 Flash 命名系列(`GLM-4-Flash`、`GLM-4V-Flash` 等)API 均免费开放。
|
||||||
|
- **并发控制**:对高频连续调用设置了 RPM 阈值(触发时返回 `HTTP 429 Too Many Requests`),代码中需配置指数退避或 1~2 秒重试间隔。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、 DriftLedger (浮记) 架构与账户分配流程
|
||||||
|
|
||||||
|
针对识别结果中“为什么只包含时间、金额、商户名,而没有 Beancount 账户”的说明:
|
||||||
|
|
||||||
|
### 1. 职责解耦设计
|
||||||
|
识图/OCR 模块只负责提取客观的**原始交易事件 (`ImportedEvent`)**,定义于 [types.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/core/types.ts#L31-L38)。由于每个用户的 Beancount 账户名(如 `Assets:招商银行:信用卡3315`)是高度个性化的,模型无法预知用户本地账本结构。
|
||||||
|
|
||||||
|
### 2. 账本账户决定流程
|
||||||
|
账户映射是在 **`BillPipeline` 责任链** 中完成的:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[账单截图]
|
||||||
|
│
|
||||||
|
▼ 1. 图像解析 (AiVisionProcessor.ts)
|
||||||
|
[ImportedEvent] ─── (仅含时间、金额、商户 GOOGLE *ChatGPT、备注 3315)
|
||||||
|
│
|
||||||
|
▼ 2. 进入流水线 BillPipeline.process() ─── [billPipeline.ts]
|
||||||
|
├──> ① 转账识别 (recognizeTransfers)
|
||||||
|
├──> ② 批次去重与历史去重 (dedup)
|
||||||
|
└──> ③ 规则匹配与账户分类 (rules.ts)
|
||||||
|
├── 匹配商户/卡号 "3315" ──> 资金来源账户 (sourceAccount): Assets:招商银行:信用卡3315
|
||||||
|
└── 匹配商户 "GOOGLE *ChatGPT" ──> 支出分类账户 (categoryAccount): Expenses:订阅服务:AI
|
||||||
|
│
|
||||||
|
▼ 3. 输出标准交易草稿 (TransactionDraft)
|
||||||
|
[TransactionDraft] ─── (产生符合 Beancount 规范的两笔双式记账 Postings)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、 AI 账户自动分类实测 (免费 LLM vs 路径 B 向量检索)
|
||||||
|
|
||||||
|
实测验证:能否利用 AI 模型根据用户本地的 Beancount 候选账户列表自动完成账户匹配?
|
||||||
|
|
||||||
|
### 1. 实测结果展示
|
||||||
|
|
||||||
|
#### 路径 A:免费 LLM 直选(智谱 `glm-4-flash-250414`,耗时 2.22 秒)
|
||||||
|
传入 9 个 Beancount 候选账户 + 交易事实,模型 100% 精确返回:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sourceAccount": "Liabilities:CreditCard:CMB:3315",
|
||||||
|
"categoryAccount": "Expenses:Software:Subscription",
|
||||||
|
"confidence": "high",
|
||||||
|
"reason": "交易备注说明信用卡尾号3315,且根据金额和商户判断为软件订阅支出"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 路径 B:向量 Embedding 余弦相似度检索(硅基流动 `BAAI/bge-m3`,耗时 0.32 秒)
|
||||||
|
将交易文本与账户生成 1024 维向量进行余弦相似度计算,点积耗时 `< 1ms`:
|
||||||
|
- **Top 1 命中**:`Liabilities:CreditCard:CMB:3315`(相似度 **0.5662**)
|
||||||
|
- **Top 2 命中**:`Expenses:Shopping:Digital`(相似度 0.5302)
|
||||||
|
|
||||||
|
### 2. 路径 B 的完整实现逻辑拆解
|
||||||
|
|
||||||
|
```text
|
||||||
|
[导入交易 ImportedEvent]
|
||||||
|
商户: GOOGLE *ChatGPT | 备注: 信用卡尾号3315
|
||||||
|
│
|
||||||
|
├───> 资金搜索文本 ──> BAAI/bge-m3 ──> 向量对比 ──> 提取最高分: Liabilities:CreditCard:CMB:3315
|
||||||
|
│
|
||||||
|
└───> 分类搜索文本 ──> BAAI/bge-m3 ──> 向量对比 ──> 提取最高分: Expenses:Software:Subscription
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **账户隔离与语义增强 (离线缓存)**:
|
||||||
|
- 将账户拆分为【资金空间 Assets/Liabilities】与【分类空间 Expenses/Income】。
|
||||||
|
- 为账号补充别名描述(如 `Liabilities:CreditCard:CMB:3315` ➔ `"招商银行 信用卡 尾号3315 掌上生活"`)。
|
||||||
|
2. **构建向量索引**:使用 `BAAI/bge-m3` 将所有账户转为向量缓存在本地内存/SQLite 中。
|
||||||
|
3. **实时查询向量化**:收到交易时,分别生成资金查询与分类查询,耗时约 150 毫秒。
|
||||||
|
4. **双路余弦相似度检索**:通过点积公式计算相似度,并提取最高分。
|
||||||
|
5. **阈值判定与推荐**:高分自动填充,低分弹出 Top 3 快捷芯片让用户点选。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 三、 相关代码位置
|
||||||
|
- **原始事件类型定义**:[src/domain/core/types.ts:L31-L38](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/core/types.ts#L31-L38)
|
||||||
|
- **账单流水线责任链**:[src/domain/pipeline/billPipeline.ts:L94-L180](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/pipeline/billPipeline.ts#L94-L180)
|
||||||
|
- **规则分类与账户映射引擎**:[src/domain/rules/rules.ts:L1-L60](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/rules/rules.ts#L1-L60)
|
||||||
|
- **AI 识图处理器服务**:[src/services/ocr/AiVisionProcessor.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/services/ocr/AiVisionProcessor.ts)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// @ts-check
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactNative from 'eslint-plugin-react-native';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
// 全局忽略
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'node_modules/**',
|
||||||
|
'android/**',
|
||||||
|
'ios/**',
|
||||||
|
'.expo/**',
|
||||||
|
'reference_project/**',
|
||||||
|
'outputs/**',
|
||||||
|
'*.js', // 根目录脚本(fix_*.js 等)
|
||||||
|
'plugins/**/android/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 基础推荐规则
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
|
||||||
|
// 全局规则调整
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// RN 中原生模块加载常使用 require()
|
||||||
|
'@typescript-eslint/no-require-imports': 'off',
|
||||||
|
// 不强制要求 error cause
|
||||||
|
'preserve-caught-error': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// React Hooks 规则
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
// 以下规则对 RN 项目过于严格,暂时关闭
|
||||||
|
'react-hooks/purity': 'off', // Date.now() 在渲染中使用是常见模式
|
||||||
|
'react-hooks/immutability': 'off', // 修改外部变量(如 i18n.locale)是有意为之
|
||||||
|
'react-hooks/refs': 'off', // 访问 ref 值在 RN 中常见
|
||||||
|
'react-hooks/preserve-caught-error': 'off', // 不强制要求 cause
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// React Native 规则(仅对 src/ 下的 tsx/ts 文件)
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{ts,tsx}'],
|
||||||
|
plugins: {
|
||||||
|
'react-native': reactNative,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'react-native/no-unused-styles': 'warn',
|
||||||
|
'react-native/no-inline-styles': 'off', // 项目中存在合理的内联样式
|
||||||
|
'react-native/no-color-literals': 'off', // 使用主题 token,但允许少量直接颜色
|
||||||
|
'react-native/no-raw-text': 'off', // 不强制所有文本包裹
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 项目自定义规则
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
// TypeScript 严格相关
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', {
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
}],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
|
// RN 中原生模块加载常使用 require(),允许
|
||||||
|
'@typescript-eslint/no-require-imports': 'off',
|
||||||
|
|
||||||
|
// 代码质量
|
||||||
|
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
eqeqeq: ['error', 'always', { null: 'ignore' }],
|
||||||
|
|
||||||
|
// Domain 层纯净性:禁止引入 React/RN
|
||||||
|
'no-restricted-imports': ['error', {
|
||||||
|
patterns: [{
|
||||||
|
group: ['react', 'react-native', 'expo-*'],
|
||||||
|
message: 'Domain 层(src/domain/)不允许引入 React/RN/Expo 依赖。',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Domain 层豁免(domain 目录不需要 React 限制,但上面已全局限制)
|
||||||
|
// 实际上 no-restricted-imports 对 domain 层生效即可
|
||||||
|
// 非 domain 层解除限制
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{ts,tsx}', '!src/domain/**'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 测试文件宽松规则
|
||||||
|
{
|
||||||
|
files: ['tests/**/*.{ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'warn', // 测试中未使用变量降为警告
|
||||||
|
'@typescript-eslint/ban-ts-comment': 'off', // 测试中允许 @ts-ignore
|
||||||
|
'no-console': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
Generated
+1324
-13
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "beancount-mobile",
|
"name": "drift-ledger",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
@@ -8,11 +8,11 @@
|
|||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "eslint src/ tests/",
|
||||||
|
"lint:fix": "eslint src/ tests/ --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo-google-fonts/caveat": "^0.4.2",
|
|
||||||
"@expo-google-fonts/quicksand": "^0.4.1",
|
|
||||||
"@expo/metro-runtime": "^6.1.2",
|
"@expo/metro-runtime": "^6.1.2",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@react-navigation/drawer": "^7.5.0",
|
"@react-navigation/drawer": "^7.5.0",
|
||||||
@@ -45,8 +45,13 @@
|
|||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
|
"eslint": "^9.39.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-native": "^5.0.0",
|
||||||
"typescript": "~5.9.0",
|
"typescript": "~5.9.0",
|
||||||
|
"typescript-eslint": "^8.65.0",
|
||||||
"vitest": "^3.2.0"
|
"vitest": "^3.2.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Expo Config Plugins
|
||||||
|
|
||||||
|
本目录包含所有原生模块的 Expo Config Plugin。由于 `android/` 目录由 `expo prebuild` 自动生成且被 Git 忽略,所有原生代码必须以 Config Plugin 形式存在。
|
||||||
|
|
||||||
|
## 插件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins/<name>/
|
||||||
|
app.plugin.js # prebuild 时执行,注入 Kotlin/XML、修改 gradle、注册服务
|
||||||
|
package.json # 插件元数据
|
||||||
|
android/*.kt # 原生 Kotlin 代码
|
||||||
|
assets/ # 模型文件等静态资源(仅 ppocr)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前插件
|
||||||
|
|
||||||
|
| 插件 | 功能 | 备注 |
|
||||||
|
|------|------|------|
|
||||||
|
| `ppocr` | PP-OCRv6 检测+识别 | ONNX Runtime,模型在 `assets/` |
|
||||||
|
| `accessibility` | 无障碍服务账单抓取 + 悬浮窗 + OCR 贴片 | 仅侧载场景 |
|
||||||
|
| `notification-listener` | 通知栏账单监听 | |
|
||||||
|
| `sms-receiver` | 短信账单解析 | |
|
||||||
|
| `screenshot-monitor` | 截图触发 OCR | |
|
||||||
|
| `size-optimization` | ABI 分包 + NDK 版本统一 | 仅影响 gradle 配置 |
|
||||||
|
|
||||||
|
## 开发须知
|
||||||
|
|
||||||
|
1. 每个插件函数**必须 `return config`**,否则后续插件会崩溃
|
||||||
|
2. 修改插件后需重新运行 `npx expo prebuild --platform android`
|
||||||
|
3. 原生服务通过 `NativeEventEmitter` 向 JS 层推送事件,**不直接写入**数据库或文件
|
||||||
|
4. `ppocr` 依赖 `onnxruntime-android:1.20.0`,prebuild 后请验证版本
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.beancount.mobile.accessibility
|
package com.beancount.mobile.accessibility
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||||
import com.facebook.react.bridge.ReactMethod
|
import com.facebook.react.bridge.ReactMethod
|
||||||
@@ -7,11 +8,12 @@ import com.facebook.react.bridge.Promise
|
|||||||
import com.facebook.react.bridge.WritableNativeArray
|
import com.facebook.react.bridge.WritableNativeArray
|
||||||
import com.facebook.react.bridge.WritableNativeMap
|
import com.facebook.react.bridge.WritableNativeMap
|
||||||
import com.facebook.react.bridge.ReadableArray
|
import com.facebook.react.bridge.ReadableArray
|
||||||
|
import com.facebook.react.bridge.ReadableMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 无障碍服务 RN 桥接模块(plan.md「3.6 无障碍服务」JS 接线)。
|
* 无障碍服务 RN 桥接模块(plan.md「3.6 无障碍服务」JS 接线)。
|
||||||
*
|
*
|
||||||
* BillingAccessibilityService 是 AccessibilityService 子类(非 RN 模块),
|
* SelectToSpeakService 是 AccessibilityService 子类(非 RN 模块),
|
||||||
* 其方法无法直接从 JS 调用。本模块作为中间层,通过 instance 静态引用
|
* 其方法无法直接从 JS 调用。本模块作为中间层,通过 instance 静态引用
|
||||||
* 把 JS 调用委托给服务实例。
|
* 把 JS 调用委托给服务实例。
|
||||||
*
|
*
|
||||||
@@ -20,6 +22,10 @@ import com.facebook.react.bridge.ReadableArray
|
|||||||
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
|
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
|
||||||
ReactContextBaseJavaModule(reactContext) {
|
ReactContextBaseJavaModule(reactContext) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AccessibilityBridge"
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
ReactContextHolder.context = reactContext
|
ReactContextHolder.context = reactContext
|
||||||
}
|
}
|
||||||
@@ -36,7 +42,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
/** 无障碍服务是否已连接(用户已在系统设置中启用)。 */
|
/** 无障碍服务是否已连接(用户已在系统设置中启用)。 */
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun isServiceRunning(promise: Promise) {
|
fun isServiceRunning(promise: Promise) {
|
||||||
promise.resolve(BillingAccessibilityService.instance != null)
|
promise.resolve(SelectToSpeakService.instance != null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +51,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
*/
|
*/
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun rememberCurrentPage(promise: Promise) {
|
fun rememberCurrentPage(promise: Promise) {
|
||||||
val service = BillingAccessibilityService.instance
|
val service = SelectToSpeakService.instance
|
||||||
if (service == null) {
|
if (service == null) {
|
||||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||||
return
|
return
|
||||||
@@ -66,7 +72,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
/** 手动触发一次 OCR(截取当前屏幕并发送给 JS 层处理)。 */
|
/** 手动触发一次 OCR(截取当前屏幕并发送给 JS 层处理)。 */
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun triggerManualOcr(promise: Promise) {
|
fun triggerManualOcr(promise: Promise) {
|
||||||
val service = BillingAccessibilityService.instance
|
val service = SelectToSpeakService.instance
|
||||||
if (service == null) {
|
if (service == null) {
|
||||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||||
return
|
return
|
||||||
@@ -82,7 +88,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
/** 获取所有已记住的页面签名列表。 */
|
/** 获取所有已记住的页面签名列表。 */
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun getPageSignatures(promise: Promise) {
|
fun getPageSignatures(promise: Promise) {
|
||||||
val service = BillingAccessibilityService.instance
|
val service = SelectToSpeakService.instance
|
||||||
val sigsSet = if (service != null) {
|
val sigsSet = if (service != null) {
|
||||||
service.getPageSignatures()
|
service.getPageSignatures()
|
||||||
} else {
|
} else {
|
||||||
@@ -108,7 +114,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
/** 清空所有已记住的页面签名。 */
|
/** 清空所有已记住的页面签名。 */
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun clearPageSignatures(promise: Promise) {
|
fun clearPageSignatures(promise: Promise) {
|
||||||
val service = BillingAccessibilityService.instance
|
val service = SelectToSpeakService.instance
|
||||||
if (service != null) {
|
if (service != null) {
|
||||||
service.clearPageSignatures()
|
service.clearPageSignatures()
|
||||||
} else {
|
} else {
|
||||||
@@ -126,7 +132,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
/** 删除指定页面签名。 */
|
/** 删除指定页面签名。 */
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun removePageSignature(signature: String, promise: Promise) {
|
fun removePageSignature(signature: String, promise: Promise) {
|
||||||
val service = BillingAccessibilityService.instance
|
val service = SelectToSpeakService.instance
|
||||||
if (service != null) {
|
if (service != null) {
|
||||||
service.removePageSignature(signature)
|
service.removePageSignature(signature)
|
||||||
} else {
|
} else {
|
||||||
@@ -149,7 +155,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun getPaymentPackages(promise: Promise) {
|
fun getPaymentPackages(promise: Promise) {
|
||||||
val arr = WritableNativeArray()
|
val arr = WritableNativeArray()
|
||||||
for (pkg in BillingAccessibilityService.PAYMENT_PACKAGES) {
|
for (pkg in SelectToSpeakService.PAYMENT_PACKAGES) {
|
||||||
arr.pushString(pkg)
|
arr.pushString(pkg)
|
||||||
}
|
}
|
||||||
promise.resolve(arr)
|
promise.resolve(arr)
|
||||||
@@ -158,7 +164,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
/** 获取当前顶部 App 信息(供 JS 判断用户是否在支付页面)。 */
|
/** 获取当前顶部 App 信息(供 JS 判断用户是否在支付页面)。 */
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun getTopApp(promise: Promise) {
|
fun getTopApp(promise: Promise) {
|
||||||
val service = BillingAccessibilityService.instance
|
val service = SelectToSpeakService.instance
|
||||||
if (service == null) {
|
if (service == null) {
|
||||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||||
return
|
return
|
||||||
@@ -172,7 +178,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
/** 将当前应用拉起至前台,用以在后台识别出账单后,弹窗让用户进行交易确认 */
|
/** 将当前应用拉起至前台,用以在后台识别出账单后,弹窗让用户进行交易确认 */
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun bringAppToForeground(promise: Promise) {
|
fun bringAppToForeground(promise: Promise) {
|
||||||
val service = BillingAccessibilityService.instance
|
val service = SelectToSpeakService.instance
|
||||||
if (service == null) {
|
if (service == null) {
|
||||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||||
return
|
return
|
||||||
@@ -195,10 +201,11 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
categories: ReadableArray,
|
categories: ReadableArray,
|
||||||
accounts: ReadableArray,
|
accounts: ReadableArray,
|
||||||
direction: String,
|
direction: String,
|
||||||
|
currency: String,
|
||||||
draftId: String,
|
draftId: String,
|
||||||
promise: Promise
|
promise: Promise
|
||||||
) {
|
) {
|
||||||
val context = reactContext.currentActivity ?: BillingAccessibilityService.instance
|
val context = SelectToSpeakService.instance ?: reactContext.currentActivity
|
||||||
if (context == null) {
|
if (context == null) {
|
||||||
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
|
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
|
||||||
return
|
return
|
||||||
@@ -222,7 +229,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
|
|
||||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||||
try {
|
try {
|
||||||
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction)
|
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction, currency)
|
||||||
floatingView.show()
|
floatingView.show()
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -230,4 +237,88 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置悬浮球开关状态。 */
|
||||||
|
@ReactMethod
|
||||||
|
fun setFloatingBallEnabled(enabled: Boolean, promise: Promise) {
|
||||||
|
SelectToSpeakService.floatingBallEnabled = enabled
|
||||||
|
val service = SelectToSpeakService.instance
|
||||||
|
if (service == null) {
|
||||||
|
promise.resolve(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!enabled) {
|
||||||
|
service.dismissFloatingHelper()
|
||||||
|
} else {
|
||||||
|
service.refreshFloatingHelper()
|
||||||
|
}
|
||||||
|
promise.resolve(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 APK assets 复制 OCR 模型文件到 filesDir/ocr_models_v6。 */
|
||||||
|
@ReactMethod
|
||||||
|
fun copyOcrModelsFromAssets(promise: Promise) {
|
||||||
|
try {
|
||||||
|
val destDir = java.io.File(reactContext.filesDir, "ocr_models_v6")
|
||||||
|
if (!destDir.exists()) destDir.mkdirs()
|
||||||
|
val assetManager = reactContext.assets
|
||||||
|
val files = listOf("ppocrv6_det.onnx", "ppocrv6_rec.onnx", "ppocrv6_dict.txt")
|
||||||
|
for (filename in files) {
|
||||||
|
val destFile = java.io.File(destDir, filename)
|
||||||
|
if (destFile.exists() && destFile.length() > 0) continue
|
||||||
|
assetManager.open(filename).use { input ->
|
||||||
|
java.io.FileOutputStream(destFile).use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promise.resolve(destDir.absolutePath)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("COPY_MODEL_FAIL", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查通知监听服务是否已启用(反射调用 getEnabledListenerPackages,避免 API level lint 错误)。 */
|
||||||
|
@ReactMethod
|
||||||
|
fun isNotificationListenerEnabled(promise: Promise) {
|
||||||
|
try {
|
||||||
|
// 通过 Settings.Secure 读取系统设置(公开 API,无需反射,不受 hidden API 限制)
|
||||||
|
// enabled_notification_listeners 格式:
|
||||||
|
// "com.pkg1/com.pkg1.Service:com.pkg2/com.pkg2.Service"
|
||||||
|
val flat = android.provider.Settings.Secure.getString(
|
||||||
|
reactContext.contentResolver,
|
||||||
|
"enabled_notification_listeners"
|
||||||
|
) ?: ""
|
||||||
|
val myPkg = reactContext.packageName
|
||||||
|
promise.resolve(flat.split(":").any { it.startsWith(myPkg) })
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "isNotificationListenerEnabled 检测失败", e)
|
||||||
|
promise.resolve(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查悬浮窗权限(SYSTEM_ALERT_WINDOW)是否已授予。 */
|
||||||
|
@ReactMethod
|
||||||
|
fun canDrawOverlays(promise: Promise) {
|
||||||
|
try {
|
||||||
|
promise.resolve(android.provider.Settings.canDrawOverlays(reactContext))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.resolve(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下发浮层 UI 配置(颜色 + 文案)。
|
||||||
|
* JS 侧从 theme tokens + i18n 构建 config,
|
||||||
|
* 原生存 SharedPreferences,三个浮层组件读取。
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun setFloatingUiConfig(config: ReadableMap, promise: Promise) {
|
||||||
|
try {
|
||||||
|
FloatingUiConfigStore.save(reactContext, config)
|
||||||
|
promise.resolve(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.reject("CONFIG_SAVE_FAIL", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import android.widget.LinearLayout
|
|||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import com.facebook.react.bridge.WritableNativeMap
|
import com.facebook.react.bridge.WritableNativeMap
|
||||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||||
|
import java.math.BigDecimal
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 浮窗账单提示(直接呈现高度优化、支持三方向切换的修改入账面板)。
|
* 浮窗账单提示(直接呈现高度优化、支持三方向切换的修改入账面板)。
|
||||||
|
* P6 重设计:颜色/文案走 FloatingUiConfigStore,新增币种 chip,金额校验改正则+BigDecimal。
|
||||||
*/
|
*/
|
||||||
class FloatingBillView(
|
class FloatingBillView(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
@@ -30,12 +32,27 @@ class FloatingBillView(
|
|||||||
private val packageName: String,
|
private val packageName: String,
|
||||||
private val categories: List<Map<String, String>> = emptyList(),
|
private val categories: List<Map<String, String>> = emptyList(),
|
||||||
private val accounts: List<String> = emptyList(),
|
private val accounts: List<String> = emptyList(),
|
||||||
private val initialDirection: String = "expense"
|
private val initialDirection: String = "expense",
|
||||||
|
private val initialCurrency: String = "CNY"
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "FloatingBillView"
|
private const val TAG = "FloatingBillView"
|
||||||
|
private val DEFAULT_CURRENCY_LIST = listOf("CNY", "USD", "HKD", "JPY", "EUR", "GBP")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 JS 下发的配置读取颜色与文案(缺失字段回退默认值,保证旧 JS 行为不变)
|
||||||
|
private val config = FloatingUiConfigStore.load(context)
|
||||||
|
|
||||||
|
// 显示币种列表;若 initialCurrency 不在默认列表中则临时插入首位
|
||||||
|
private val currencyList: List<String> = run {
|
||||||
|
val list = DEFAULT_CURRENCY_LIST.toMutableList()
|
||||||
|
if (!list.contains(initialCurrency)) {
|
||||||
|
list.add(0, initialCurrency)
|
||||||
|
}
|
||||||
|
list
|
||||||
|
}
|
||||||
|
private var currentCurrency: String = initialCurrency
|
||||||
|
|
||||||
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||||
private var view: View? = null
|
private var view: View? = null
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
@@ -49,6 +66,20 @@ class FloatingBillView(
|
|||||||
private var accountContainer: LinearLayout? = null
|
private var accountContainer: LinearLayout? = null
|
||||||
private var saveBtn: Button? = null
|
private var saveBtn: Button? = null
|
||||||
|
|
||||||
|
// 预解析常用颜色(parseColorOr 异常时回退默认值)
|
||||||
|
private val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
|
||||||
|
private val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
|
||||||
|
private val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
|
||||||
|
private val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
|
||||||
|
private val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
|
||||||
|
private val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
|
||||||
|
private val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
|
||||||
|
private val colorIncome = FloatingUiConfigStore.parseColorOr(config.colors.income, "#FF10B981")
|
||||||
|
private val colorExpense = FloatingUiConfigStore.parseColorOr(config.colors.expense, "#FFE11D48")
|
||||||
|
private val colorTransfer = FloatingUiConfigStore.parseColorOr(config.colors.transfer, "#FF10B981")
|
||||||
|
|
||||||
|
// 容器边框直接使用 config border(不再从 accent 派生 alpha)
|
||||||
|
|
||||||
private fun dp(value: Float): Int {
|
private fun dp(value: Float): Int {
|
||||||
val density = context.resources.displayMetrics.density
|
val density = context.resources.displayMetrics.density
|
||||||
return (value * density).toInt()
|
return (value * density).toInt()
|
||||||
@@ -74,29 +105,34 @@ class FloatingBillView(
|
|||||||
currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense"
|
currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense"
|
||||||
|
|
||||||
// 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部
|
// 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部
|
||||||
|
val overlayType = if (context is android.accessibilityservice.AccessibilityService) {
|
||||||
|
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY
|
||||||
|
} else {
|
||||||
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||||
|
}
|
||||||
val windowParams = WindowManager.LayoutParams(
|
val windowParams = WindowManager.LayoutParams(
|
||||||
dp(310f),
|
dp(310f),
|
||||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
overlayType,
|
||||||
0, // 0 标志代表可获取焦点
|
0, // 0 标志代表可获取焦点
|
||||||
PixelFormat.TRANSLUCENT
|
PixelFormat.TRANSLUCENT
|
||||||
).apply {
|
).apply {
|
||||||
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
|
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
|
||||||
y = dp(12f) // 尽量靠下以留出上方账单对照区
|
y = dp(48f) // 留出底部导航栏空间 + 按钮/键盘安全区
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 使用 55% 透明度 OLED 磨砂效果背景,发光靛蓝细边框
|
// 2. 实色卡片背景(不再半透明磨砂),圆角 16dp,1dp 描边
|
||||||
val containerBg = GradientDrawable().apply {
|
val containerBg = GradientDrawable().apply {
|
||||||
shape = GradientDrawable.RECTANGLE
|
shape = GradientDrawable.RECTANGLE
|
||||||
cornerRadius = dp(14f).toFloat()
|
cornerRadius = dp(16f).toFloat()
|
||||||
setColor(0x8C050506.toInt()) // 55% 透明度 OLED 黑色
|
setColor(colorCardBg)
|
||||||
setStroke(dp(1.2f), 0x995E6AD2.toInt()) // 60% 透明度靛蓝发光边框
|
setStroke(dp(1f), colorBorder) // 使用 config border 颜色
|
||||||
}
|
}
|
||||||
|
|
||||||
val container = LinearLayout(context).apply {
|
val container = LinearLayout(context).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
background = containerBg
|
background = containerBg
|
||||||
setPadding(dp(12f), dp(8f), dp(12f), dp(8f))
|
setPadding(dp(16f), dp(16f), dp(16f), dp(16f))
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
@@ -114,9 +150,9 @@ class FloatingBillView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val titleText = TextView(context).apply {
|
val titleText = TextView(context).apply {
|
||||||
text = "调整交易草稿"
|
text = config.labels.billTitle
|
||||||
textSize = 12f
|
textSize = 13f
|
||||||
setTextColor(0xFF98A2FF.toInt())
|
setTextColor(colorFgPrimary)
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
}
|
}
|
||||||
@@ -126,12 +162,12 @@ class FloatingBillView(
|
|||||||
val segmentContainer = LinearLayout(context).apply {
|
val segmentContainer = LinearLayout(context).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
background = GradientDrawable().apply {
|
background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(4f).toFloat()
|
cornerRadius = dp(6f).toFloat()
|
||||||
setColor(0x20FFFFFF.toInt()) // 12% white opacity
|
setColor((colorFgPrimary and 0x00FFFFFF) or 0x12000000) // 7% fg overlay
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val tabTexts = listOf("支出", "收入", "转账")
|
val tabTexts = listOf(config.labels.dirExpense, config.labels.dirIncome, config.labels.dirTransfer)
|
||||||
val tabDirections = listOf("expense", "income", "transfer")
|
val tabDirections = listOf("expense", "income", "transfer")
|
||||||
val tabViews = mutableListOf<TextView>()
|
val tabViews = mutableListOf<TextView>()
|
||||||
|
|
||||||
@@ -139,11 +175,11 @@ class FloatingBillView(
|
|||||||
for (i in tabViews.indices) {
|
for (i in tabViews.indices) {
|
||||||
val active = tabDirections[i] == currentDirection
|
val active = tabDirections[i] == currentDirection
|
||||||
tabViews[i].apply {
|
tabViews[i].apply {
|
||||||
setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
background = if (active) {
|
background = if (active) {
|
||||||
GradientDrawable().apply {
|
GradientDrawable().apply {
|
||||||
cornerRadius = dp(4f).toFloat()
|
cornerRadius = dp(6f).toFloat()
|
||||||
setColor(0xFF5E6AD2.toInt()) // Indigo highlight
|
setColor(colorAccent)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -173,40 +209,75 @@ class FloatingBillView(
|
|||||||
headerLayout.addView(segmentContainer)
|
headerLayout.addView(segmentContainer)
|
||||||
container.addView(headerLayout)
|
container.addView(headerLayout)
|
||||||
|
|
||||||
// 金额编辑
|
// 金额行:币种 chip(左)+ 金额输入框(右)
|
||||||
val amountLabel = TextView(context).apply {
|
val amountLabel = TextView(context).apply {
|
||||||
text = "金额"
|
text = config.labels.amountLabel
|
||||||
textSize = 9f
|
textSize = 10f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
setTextColor(0xFF98A2FF.toInt())
|
setTextColor(colorFgSecondary)
|
||||||
setPadding(0, dp(4f), 0, dp(1f))
|
setPadding(0, dp(8f), 0, dp(1f))
|
||||||
}
|
}
|
||||||
container.addView(amountLabel)
|
container.addView(amountLabel)
|
||||||
|
|
||||||
val amountInput = EditText(context).apply {
|
val amountRow = LinearLayout(context).apply {
|
||||||
textSize = 13f
|
orientation = LinearLayout.HORIZONTAL
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
background = GradientDrawable().apply {
|
|
||||||
setColor(0x4012131A.toInt()) // 25% 半透底色
|
|
||||||
cornerRadius = dp(6f).toFloat()
|
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
|
||||||
}
|
|
||||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
|
||||||
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
container.addView(amountInput)
|
|
||||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
|
// 币种 chip
|
||||||
|
val currencyChip = TextView(context).apply {
|
||||||
|
text = currentCurrency
|
||||||
|
textSize = 11f
|
||||||
|
setTextColor(colorFgSecondary)
|
||||||
|
setPadding(dp(8f), dp(4f), dp(8f), dp(4f))
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.RECTANGLE
|
||||||
|
cornerRadius = dp(12f).toFloat()
|
||||||
|
setColor(colorInputBg)
|
||||||
|
setStroke(dp(1f), colorBorder)
|
||||||
|
}
|
||||||
|
setOnClickListener {
|
||||||
|
val idx = currencyList.indexOf(currentCurrency)
|
||||||
|
currentCurrency = currencyList[(idx + 1) % currencyList.size]
|
||||||
|
text = currentCurrency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
amountRow.addView(currencyChip)
|
||||||
|
|
||||||
|
val amountInput = EditText(context).apply {
|
||||||
|
textSize = 13f
|
||||||
|
setTextColor(colorFgPrimary)
|
||||||
|
setHintTextColor(colorFgSecondary)
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
setColor(colorInputBg)
|
||||||
|
cornerRadius = dp(10f).toFloat()
|
||||||
|
setStroke(dp(1f), colorBorder)
|
||||||
|
}
|
||||||
|
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
|
||||||
|
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
0,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
1f
|
||||||
|
).apply {
|
||||||
|
leftMargin = dp(8f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
amountRow.addView(amountInput)
|
||||||
|
container.addView(amountRow)
|
||||||
|
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
||||||
|
|
||||||
// 商户编辑
|
// 商户编辑
|
||||||
val merchantLabel = TextView(context).apply {
|
val merchantLabel = TextView(context).apply {
|
||||||
text = "交易对手"
|
text = config.labels.payeeLabel
|
||||||
textSize = 9f
|
textSize = 10f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
setTextColor(0xFF98A2FF.toInt())
|
setTextColor(colorFgSecondary)
|
||||||
setPadding(0, 0, 0, dp(1f))
|
setPadding(0, 0, 0, dp(1f))
|
||||||
}
|
}
|
||||||
container.addView(merchantLabel)
|
container.addView(merchantLabel)
|
||||||
@@ -214,56 +285,57 @@ class FloatingBillView(
|
|||||||
val merchantInput = EditText(context).apply {
|
val merchantInput = EditText(context).apply {
|
||||||
setText(merchant)
|
setText(merchant)
|
||||||
textSize = 12f
|
textSize = 12f
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
setTextColor(colorFgPrimary)
|
||||||
|
setHintTextColor(colorFgSecondary)
|
||||||
background = GradientDrawable().apply {
|
background = GradientDrawable().apply {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(10f).toFloat()
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
container.addView(merchantInput)
|
container.addView(merchantInput)
|
||||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
|
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
||||||
|
|
||||||
// 叙述备注编辑
|
// 叙述备注编辑
|
||||||
val narrationLabel = TextView(context).apply {
|
val narrationLabel = TextView(context).apply {
|
||||||
text = "描述/备注"
|
text = config.labels.narrationLabel
|
||||||
textSize = 9f
|
textSize = 10f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
setTextColor(0xFF98A2FF.toInt())
|
setTextColor(colorFgSecondary)
|
||||||
setPadding(0, 0, 0, dp(1f))
|
setPadding(0, 0, 0, dp(1f))
|
||||||
}
|
}
|
||||||
container.addView(narrationLabel)
|
container.addView(narrationLabel)
|
||||||
|
|
||||||
val narrationInput = EditText(context).apply {
|
val narrationInput = EditText(context).apply {
|
||||||
hint = "输入交易叙述"
|
hint = config.labels.narrationHint
|
||||||
setHintTextColor(0xFF6B7280.toInt())
|
setHintTextColor(colorFgSecondary)
|
||||||
textSize = 12f
|
textSize = 12f
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
setTextColor(colorFgPrimary)
|
||||||
background = GradientDrawable().apply {
|
background = GradientDrawable().apply {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(10f).toFloat()
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
|
||||||
layoutParams = LinearLayout.LayoutParams(
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
container.addView(narrationInput)
|
container.addView(narrationInput)
|
||||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
|
||||||
|
|
||||||
// Row 1 分类/转入选择
|
// Row 1 分类/转入选择
|
||||||
categoryLabel = TextView(context).apply {
|
categoryLabel = TextView(context).apply {
|
||||||
text = "交易分类"
|
text = config.labels.categoryExpense
|
||||||
textSize = 9f
|
textSize = 10f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
setTextColor(0xFF98A2FF.toInt())
|
setTextColor(colorFgSecondary)
|
||||||
setPadding(0, 0, 0, dp(2f))
|
setPadding(0, 0, 0, dp(2f))
|
||||||
}
|
}
|
||||||
container.addView(categoryLabel)
|
container.addView(categoryLabel)
|
||||||
@@ -281,14 +353,14 @@ class FloatingBillView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
container.addView(categoryScroll)
|
container.addView(categoryScroll)
|
||||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
|
||||||
|
|
||||||
// Row 2 资金出入账户选择
|
// Row 2 资金出入账户选择
|
||||||
accountLabel = TextView(context).apply {
|
accountLabel = TextView(context).apply {
|
||||||
text = "资金来源账户"
|
text = config.labels.accountExpense
|
||||||
textSize = 9f
|
textSize = 10f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
setTextColor(0xFF98A2FF.toInt())
|
setTextColor(colorFgSecondary)
|
||||||
setPadding(0, 0, 0, dp(2f))
|
setPadding(0, 0, 0, dp(2f))
|
||||||
}
|
}
|
||||||
container.addView(accountLabel)
|
container.addView(accountLabel)
|
||||||
@@ -306,7 +378,7 @@ class FloatingBillView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
container.addView(accountScroll)
|
container.addView(accountScroll)
|
||||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(8f)) })
|
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(10f)) })
|
||||||
|
|
||||||
// 底部操作栏
|
// 底部操作栏
|
||||||
val btnContainer = LinearLayout(context).apply {
|
val btnContainer = LinearLayout(context).apply {
|
||||||
@@ -320,16 +392,16 @@ class FloatingBillView(
|
|||||||
|
|
||||||
// 1) 打开应用按钮
|
// 1) 打开应用按钮
|
||||||
val openAppBtn = Button(context).apply {
|
val openAppBtn = Button(context).apply {
|
||||||
text = "打开应用"
|
text = config.labels.openApp
|
||||||
setTextColor(0xFFD1D5DB.toInt())
|
setTextColor(colorFgPrimary)
|
||||||
background = GradientDrawable().apply {
|
background = GradientDrawable().apply {
|
||||||
shape = GradientDrawable.RECTANGLE
|
shape = GradientDrawable.RECTANGLE
|
||||||
cornerRadius = dp(8f).toFloat()
|
cornerRadius = dp(12f).toFloat()
|
||||||
setColor(0xFF1F2937.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0xFF374151.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
textSize = 11f
|
textSize = 11f
|
||||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
|
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
val newAmount = amountInput.text.toString().trim()
|
val newAmount = amountInput.text.toString().trim()
|
||||||
val newPayee = merchantInput.text.toString().trim()
|
val newPayee = merchantInput.text.toString().trim()
|
||||||
@@ -337,23 +409,22 @@ class FloatingBillView(
|
|||||||
|
|
||||||
sendOpenAppEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
|
sendOpenAppEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
|
||||||
dismiss()
|
dismiss()
|
||||||
BillingAccessibilityService.instance?.bringAppToForeground()
|
SelectToSpeakService.instance?.bringAppToForeground()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 忽略按钮
|
// 2) 忽略按钮
|
||||||
val cancelBg = GradientDrawable().apply {
|
|
||||||
shape = GradientDrawable.RECTANGLE
|
|
||||||
cornerRadius = dp(8f).toFloat()
|
|
||||||
setColor(0xFF1F2937.toInt())
|
|
||||||
setStroke(dp(1f), 0xFF374151.toInt())
|
|
||||||
}
|
|
||||||
val cancelBtn = Button(context).apply {
|
val cancelBtn = Button(context).apply {
|
||||||
text = "忽略"
|
text = config.labels.dismiss
|
||||||
setTextColor(0xFFD1D5DB.toInt())
|
setTextColor(colorFgPrimary)
|
||||||
background = cancelBg
|
background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.RECTANGLE
|
||||||
|
cornerRadius = dp(12f).toFloat()
|
||||||
|
setColor(colorInputBg)
|
||||||
|
setStroke(dp(1f), colorBorder)
|
||||||
|
}
|
||||||
textSize = 11f
|
textSize = 11f
|
||||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
|
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
sendCancelEvent()
|
sendCancelEvent()
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -361,18 +432,17 @@ class FloatingBillView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3) 确认入账按钮
|
// 3) 确认入账按钮
|
||||||
val saveBg = GradientDrawable().apply {
|
|
||||||
shape = GradientDrawable.RECTANGLE
|
|
||||||
cornerRadius = dp(8f).toFloat()
|
|
||||||
setColor(0xFF5E6AD2.toInt())
|
|
||||||
}
|
|
||||||
saveBtn = Button(context).apply {
|
saveBtn = Button(context).apply {
|
||||||
text = "确认入账"
|
text = config.labels.confirm
|
||||||
setTextColor(0xFFFFFFFF.toInt())
|
setTextColor(colorAccentFg)
|
||||||
background = saveBg
|
background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.RECTANGLE
|
||||||
|
cornerRadius = dp(12f).toFloat()
|
||||||
|
setColor(colorAccent)
|
||||||
|
}
|
||||||
textSize = 11f
|
textSize = 11f
|
||||||
paint.isFakeBoldText = true
|
paint.isFakeBoldText = true
|
||||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1.3f)
|
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1.3f)
|
||||||
setOnClickListener {
|
setOnClickListener {
|
||||||
val newAmount = amountInput.text.toString().trim()
|
val newAmount = amountInput.text.toString().trim()
|
||||||
val newPayee = merchantInput.text.toString().trim()
|
val newPayee = merchantInput.text.toString().trim()
|
||||||
@@ -388,21 +458,23 @@ class FloatingBillView(
|
|||||||
btnContainer.addView(saveBtn)
|
btnContainer.addView(saveBtn)
|
||||||
container.addView(btnContainer)
|
container.addView(btnContainer)
|
||||||
|
|
||||||
// 构建金额安全校验
|
// 金额安全校验(BigDecimal + 正则)
|
||||||
val amountWatcher = object : android.text.TextWatcher {
|
val amountWatcher = object : android.text.TextWatcher {
|
||||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
override fun afterTextChanged(s: android.text.Editable?) {
|
override fun afterTextChanged(s: android.text.Editable?) {
|
||||||
try {
|
try {
|
||||||
val text = s?.toString()?.trim() ?: ""
|
val text = s?.toString()?.trim() ?: ""
|
||||||
val value = text.toDoubleOrNull()
|
val pattern = Regex("^\\d+(\\.\\d{1,2})?$")
|
||||||
val isValid = value != null && !value.isNaN() && !value.isInfinite() && value > 0.0
|
val value = text.toBigDecimalOrNull()
|
||||||
|
val isValid = pattern.matches(text) && value != null && value > BigDecimal.ZERO
|
||||||
saveBtn?.isEnabled = isValid
|
saveBtn?.isEnabled = isValid
|
||||||
saveBtn?.background = GradientDrawable().apply {
|
saveBtn?.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(8f).toFloat()
|
shape = GradientDrawable.RECTANGLE
|
||||||
setColor(if (isValid) 0xFF5E6AD2.toInt() else 0xFF374151.toInt())
|
cornerRadius = dp(12f).toFloat()
|
||||||
|
setColor(if (isValid) colorAccent else colorInputBg)
|
||||||
}
|
}
|
||||||
saveBtn?.setTextColor(if (isValid) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
saveBtn?.setTextColor(if (isValid) colorAccentFg else colorFgSecondary)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "金额校验异常", e)
|
Log.e(TAG, "金额校验异常", e)
|
||||||
saveBtn?.isEnabled = false
|
saveBtn?.isEnabled = false
|
||||||
@@ -419,7 +491,7 @@ class FloatingBillView(
|
|||||||
|
|
||||||
view = container
|
view = container
|
||||||
windowManager.addView(view, windowParams)
|
windowManager.addView(view, windowParams)
|
||||||
Log.i(TAG, "悬浮修改记账面板已显示: ¥$amount")
|
Log.i(TAG, "悬浮修改记账面板已显示: $currentCurrency $amount")
|
||||||
|
|
||||||
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
|
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
|
||||||
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
|
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
|
||||||
@@ -438,11 +510,11 @@ class FloatingBillView(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 15 秒无操作自动消失(如果用户没有交互的话)
|
// 30 秒无操作自动消失(如果用户没有交互的话)
|
||||||
handler.postDelayed({
|
handler.postDelayed({
|
||||||
sendCancelEvent()
|
sendCancelEvent()
|
||||||
dismiss()
|
dismiss()
|
||||||
}, 15000L)
|
}, 30000L)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
|
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
|
||||||
@@ -459,7 +531,7 @@ class FloatingBillView(
|
|||||||
|
|
||||||
// === 1. 绘制第一行 (分类 / 转入) ===
|
// === 1. 绘制第一行 (分类 / 转入) ===
|
||||||
if (currentDirection == "expense") {
|
if (currentDirection == "expense") {
|
||||||
categoryLabel?.text = "交易分类"
|
categoryLabel?.text = config.labels.categoryExpense
|
||||||
val filteredCats = categories.filter { it["type"] == "expense" }
|
val filteredCats = categories.filter { it["type"] == "expense" }
|
||||||
for (cat in filteredCats) {
|
for (cat in filteredCats) {
|
||||||
val catAccount = cat["account"] ?: ""
|
val catAccount = cat["account"] ?: ""
|
||||||
@@ -470,14 +542,14 @@ class FloatingBillView(
|
|||||||
for (pair in categoryChips) {
|
for (pair in categoryChips) {
|
||||||
val active = pair.first == selected
|
val active = pair.first == selected
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) setColor(0xFF5E6AD2.toInt())
|
if (active) setColor(colorAccent)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,18 +564,18 @@ class FloatingBillView(
|
|||||||
categoryChips.forEach { pair ->
|
categoryChips.forEach { pair ->
|
||||||
val active = pair.first == selectedCategoryAccount
|
val active = pair.first == selectedCategoryAccount
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) setColor(0xFF5E6AD2.toInt())
|
if (active) setColor(colorAccent)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (currentDirection == "income") {
|
} else if (currentDirection == "income") {
|
||||||
categoryLabel?.text = "收入分类"
|
categoryLabel?.text = config.labels.categoryIncome
|
||||||
val filteredCats = categories.filter { it["type"] == "income" }
|
val filteredCats = categories.filter { it["type"] == "income" }
|
||||||
for (cat in filteredCats) {
|
for (cat in filteredCats) {
|
||||||
val catAccount = cat["account"] ?: ""
|
val catAccount = cat["account"] ?: ""
|
||||||
@@ -514,14 +586,14 @@ class FloatingBillView(
|
|||||||
for (pair in categoryChips) {
|
for (pair in categoryChips) {
|
||||||
val active = pair.first == selected
|
val active = pair.first == selected
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) setColor(0xFF5E6AD2.toInt())
|
if (active) setColor(colorAccent)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,19 +608,19 @@ class FloatingBillView(
|
|||||||
categoryChips.forEach { pair ->
|
categoryChips.forEach { pair ->
|
||||||
val active = pair.first == selectedCategoryAccount
|
val active = pair.first == selectedCategoryAccount
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) setColor(0xFF5E6AD2.toInt())
|
if (active) setColor(colorAccent)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// transfer
|
// transfer — 第一行 = 转入账户,选中色 = transfer
|
||||||
categoryLabel?.text = "转入账户"
|
categoryLabel?.text = config.labels.transferTarget
|
||||||
for (acct in accounts) {
|
for (acct in accounts) {
|
||||||
val shortName = acct.split(":").lastOrNull() ?: acct
|
val shortName = acct.split(":").lastOrNull() ?: acct
|
||||||
val chip = createChipView(shortName)
|
val chip = createChipView(shortName)
|
||||||
@@ -557,14 +629,14 @@ class FloatingBillView(
|
|||||||
for (pair in categoryChips) {
|
for (pair in categoryChips) {
|
||||||
val active = pair.first == selected
|
val active = pair.first == selected
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) setColor(0xFF10B981.toInt())
|
if (active) setColor(colorTransfer)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,26 +651,29 @@ class FloatingBillView(
|
|||||||
categoryChips.forEach { pair ->
|
categoryChips.forEach { pair ->
|
||||||
val active = pair.first == selectedCategoryAccount
|
val active = pair.first == selectedCategoryAccount
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) setColor(0xFF10B981.toInt())
|
if (active) setColor(colorTransfer)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 2. 绘制第二行 (资金账户) ===
|
// === 2. 绘制第二行 (资金账户) ===
|
||||||
if (currentDirection == "expense") {
|
if (currentDirection == "expense") {
|
||||||
accountLabel?.text = "资金来源"
|
accountLabel?.text = config.labels.accountExpense
|
||||||
} else if (currentDirection == "income") {
|
} else if (currentDirection == "income") {
|
||||||
accountLabel?.text = "存入账户"
|
accountLabel?.text = config.labels.accountIncome
|
||||||
} else {
|
} else {
|
||||||
accountLabel?.text = "转出账户"
|
accountLabel?.text = config.labels.accountTransfer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 账户行选中色按方向:expense→expense色,income→income色,transfer→expense色
|
||||||
|
val accountSelectedColor = if (currentDirection == "income") colorIncome else colorExpense
|
||||||
|
|
||||||
for (acct in accounts) {
|
for (acct in accounts) {
|
||||||
val shortName = acct.split(":").lastOrNull() ?: acct
|
val shortName = acct.split(":").lastOrNull() ?: acct
|
||||||
val chip = createChipView(shortName)
|
val chip = createChipView(shortName)
|
||||||
@@ -607,15 +682,15 @@ class FloatingBillView(
|
|||||||
for (pair in accountChips) {
|
for (pair in accountChips) {
|
||||||
val active = pair.first == selected
|
val active = pair.first == selected
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) {
|
if (active) {
|
||||||
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
|
setColor(accountSelectedColor)
|
||||||
} else {
|
} else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,14 +706,14 @@ class FloatingBillView(
|
|||||||
for (p in categoryChips) {
|
for (p in categoryChips) {
|
||||||
val act = p.first == selectedCategoryAccount
|
val act = p.first == selectedCategoryAccount
|
||||||
p.second.background = GradientDrawable().apply {
|
p.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (act) setColor(0xFF10B981.toInt())
|
if (act) setColor(colorTransfer)
|
||||||
else {
|
else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.second.setTextColor(if (act) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
p.second.setTextColor(if (act) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -651,15 +726,15 @@ class FloatingBillView(
|
|||||||
accountChips.forEach { pair ->
|
accountChips.forEach { pair ->
|
||||||
val active = pair.first == selectedSourceAccount
|
val active = pair.first == selectedSourceAccount
|
||||||
pair.second.background = GradientDrawable().apply {
|
pair.second.background = GradientDrawable().apply {
|
||||||
cornerRadius = dp(6f).toFloat()
|
cornerRadius = dp(14f).toFloat()
|
||||||
if (active) {
|
if (active) {
|
||||||
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
|
setColor(accountSelectedColor)
|
||||||
} else {
|
} else {
|
||||||
setColor(0x4012131A.toInt())
|
setColor(colorInputBg)
|
||||||
setStroke(dp(1f), 0x80222433.toInt())
|
setStroke(dp(1f), colorBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,6 +752,7 @@ class FloatingBillView(
|
|||||||
putString("time", time)
|
putString("time", time)
|
||||||
putString("direction", currentDirection)
|
putString("direction", currentDirection)
|
||||||
putString("packageName", packageName)
|
putString("packageName", packageName)
|
||||||
|
putString("currency", currentCurrency)
|
||||||
putBoolean("confirmed", true)
|
putBoolean("confirmed", true)
|
||||||
putBoolean("editRequested", false)
|
putBoolean("editRequested", false)
|
||||||
putBoolean("isManualEdit", true)
|
putBoolean("isManualEdit", true)
|
||||||
@@ -703,6 +779,7 @@ class FloatingBillView(
|
|||||||
putString("time", time)
|
putString("time", time)
|
||||||
putString("direction", currentDirection)
|
putString("direction", currentDirection)
|
||||||
putString("packageName", packageName)
|
putString("packageName", packageName)
|
||||||
|
putString("currency", currentCurrency)
|
||||||
}
|
}
|
||||||
reactContext
|
reactContext
|
||||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||||
@@ -736,4 +813,13 @@ class FloatingBillView(
|
|||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
view = null
|
view = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kotlin 中缺失的 toBigDecimalOrNull 扩展。 */
|
||||||
|
private fun String.toBigDecimalOrNull(): BigDecimal? {
|
||||||
|
return try {
|
||||||
|
BigDecimal(this)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,13 @@ import android.widget.TextView
|
|||||||
* 贴合在屏幕边缘,采用高透、超轻量竖线指示器,点击后展开垂直对齐的功能菜单。
|
* 贴合在屏幕边缘,采用高透、超轻量竖线指示器,点击后展开垂直对齐的功能菜单。
|
||||||
*/
|
*/
|
||||||
class FloatingHelper(
|
class FloatingHelper(
|
||||||
private val service: BillingAccessibilityService
|
private val service: SelectToSpeakService
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "FloatingHelper"
|
private const val TAG = "FloatingHelper"
|
||||||
|
private const val PREFS_NAME = "billing_accessibility_prefs"
|
||||||
|
private const val KEY_X = "floating_ball_x"
|
||||||
|
private const val KEY_Y = "floating_ball_y"
|
||||||
private var lastX = 0
|
private var lastX = 0
|
||||||
private var lastY = 400
|
private var lastY = 400
|
||||||
}
|
}
|
||||||
@@ -41,7 +44,7 @@ class FloatingHelper(
|
|||||||
private val params = WindowManager.LayoutParams(
|
private val params = WindowManager.LayoutParams(
|
||||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
|
||||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
|
||||||
PixelFormat.TRANSLUCENT
|
PixelFormat.TRANSLUCENT
|
||||||
).apply {
|
).apply {
|
||||||
@@ -57,6 +60,25 @@ class FloatingHelper(
|
|||||||
try {
|
try {
|
||||||
val context = service
|
val context = service
|
||||||
|
|
||||||
|
// 加载浮层 UI 配置(颜色与文案均从 JS 下发的 config 读取,未下发时回退默认值)
|
||||||
|
val config = FloatingUiConfigStore.load(service)
|
||||||
|
|
||||||
|
// 预解析颜色
|
||||||
|
val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
|
||||||
|
val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
|
||||||
|
val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
|
||||||
|
val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
|
||||||
|
val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
|
||||||
|
val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
|
||||||
|
val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
|
||||||
|
|
||||||
|
// 位置持久化:从 SharedPreferences 读取,回退到 companion 缓存
|
||||||
|
val prefs = service.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
params.x = prefs.getInt(KEY_X, lastX)
|
||||||
|
params.y = prefs.getInt(KEY_Y, lastY)
|
||||||
|
lastX = params.x
|
||||||
|
lastY = params.y
|
||||||
|
|
||||||
val density = service.resources.displayMetrics.density
|
val density = service.resources.displayMetrics.density
|
||||||
fun dp(value: Float) = (value * density).toInt()
|
fun dp(value: Float) = (value * density).toInt()
|
||||||
|
|
||||||
@@ -79,25 +101,31 @@ class FloatingHelper(
|
|||||||
layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f))
|
layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 边缘指示竖线:accent 色取 RGB + 固定 0xB0 alpha(保持 70% 透明)
|
||||||
|
val indicatorColor = (colorAccent and 0x00FFFFFF) or 0xB0000000.toInt()
|
||||||
val indicatorView = View(context).apply {
|
val indicatorView = View(context).apply {
|
||||||
background = GradientDrawable().apply {
|
background = GradientDrawable().apply {
|
||||||
shape = GradientDrawable.RECTANGLE
|
shape = GradientDrawable.RECTANGLE
|
||||||
cornerRadius = dp(1.5f).toFloat() // 高度圆润
|
cornerRadius = dp(1.5f).toFloat()
|
||||||
setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框
|
setColor(indicatorColor)
|
||||||
}
|
}
|
||||||
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
|
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
|
||||||
gravity = Gravity.CENTER
|
gravity = Gravity.CENTER_VERTICAL or Gravity.START
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bubbleLayout.addView(indicatorView)
|
bubbleLayout.addView(indicatorView)
|
||||||
bubbleView = bubbleLayout
|
bubbleView = bubbleLayout
|
||||||
|
|
||||||
// 3. 创建展开菜单 (垂直布局,高紧凑度设计)
|
// 3. 创建展开菜单 (垂直布局,高紧凑度设计)
|
||||||
|
// 菜单背景:实色 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()
|
||||||
}
|
}
|
||||||
@@ -255,16 +295,22 @@ class FloatingHelper(
|
|||||||
if (!isMoving) {
|
if (!isMoving) {
|
||||||
toggleMenu()
|
toggleMenu()
|
||||||
} else {
|
} else {
|
||||||
// 拖动抬起时自动吸附到屏幕边缘
|
// 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘)
|
||||||
if (params.x < service.resources.displayMetrics.widthPixels / 2) {
|
if (params.x < service.resources.displayMetrics.widthPixels / 2) {
|
||||||
params.x = 0
|
params.x = -dp(1.5f)
|
||||||
} else {
|
} else {
|
||||||
params.x = service.resources.displayMetrics.widthPixels - dp(24f)
|
params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f)
|
||||||
}
|
}
|
||||||
containerView?.let { windowManager.updateViewLayout(it, params) }
|
containerView?.let { windowManager.updateViewLayout(it, params) }
|
||||||
|
|
||||||
lastX = params.x
|
lastX = params.x
|
||||||
lastY = params.y
|
lastY = params.y
|
||||||
|
|
||||||
|
// 位置持久化:吸附后写入 SharedPreferences
|
||||||
|
prefs.edit()
|
||||||
|
.putInt(KEY_X, params.x)
|
||||||
|
.putInt(KEY_Y, params.y)
|
||||||
|
.apply()
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -276,6 +322,10 @@ class FloatingHelper(
|
|||||||
Log.i(TAG, "记账助手悬浮窗显示成功")
|
Log.i(TAG, "记账助手悬浮窗显示成功")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e)
|
Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e)
|
||||||
|
// 修复:失败时清理状态,允许下次重试
|
||||||
|
containerView = null
|
||||||
|
bubbleView = null
|
||||||
|
menuView = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = "记录失败",
|
||||||
|
/** SelectToSpeakService Toast「已记住页面签名」前缀。 */ val pageRemembered: String = "已记住页面签名",
|
||||||
|
/** 「该页面签名已存在」前缀。 */ val pageSignatureExists: String = "该页面签名已存在",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 原生浮层 UI 配置(JS 经 AccessibilityBridge.setFloatingUiConfig 下发)。 */
|
||||||
|
data class FloatingUiConfig(
|
||||||
|
val colors: FloatingUiConfigColors = FloatingUiConfigColors(),
|
||||||
|
val labels: FloatingUiConfigLabels = FloatingUiConfigLabels(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FloatingUiConfig 的 SharedPreferences 持久化(plan「原生持久化」节)。
|
||||||
|
*
|
||||||
|
* - 文件名 floating_ui_config,单键 config_json 存整个 JSON。
|
||||||
|
* - save 采用合并策略:先 load 出现存 JSON,再覆盖传入键,避免部分更新丢字段。
|
||||||
|
* - load 逐字段 optString 缺省回退 data class 默认值;任何异常返回全默认。
|
||||||
|
*/
|
||||||
|
object FloatingUiConfigStore {
|
||||||
|
private const val TAG = "FloatingUiConfigStore"
|
||||||
|
private const val PREFS = "floating_ui_config"
|
||||||
|
private const val KEY = "config_json"
|
||||||
|
|
||||||
|
/** 契约内已知 colors 键(save 时只写这些键)。 */
|
||||||
|
private val COLOR_KEYS = listOf(
|
||||||
|
"accent", "accentFg", "cardBg", "inputBg", "fgPrimary",
|
||||||
|
"fgSecondary", "border", "income", "expense", "transfer",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 契约内已知 labels 键(save 时只写这些键)。 */
|
||||||
|
private val LABEL_KEYS = listOf(
|
||||||
|
"billTitle", "dirExpense", "dirIncome", "dirTransfer", "amountLabel",
|
||||||
|
"payeeLabel", "narrationLabel", "narrationHint", "categoryExpense",
|
||||||
|
"categoryIncome", "transferTarget", "accountExpense", "accountIncome",
|
||||||
|
"accountTransfer", "openApp", "dismiss", "confirm", "ballOcr",
|
||||||
|
"ballRemember", "rememberSuccess", "rememberFail", "pageRemembered",
|
||||||
|
"pageSignatureExists",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 保存(合并):先读出现存 JSON,再覆盖传入的已知键。 */
|
||||||
|
fun save(context: Context, map: ReadableMap) {
|
||||||
|
val root = readRawJson(context)
|
||||||
|
|
||||||
|
if (map.hasKey("colors")) {
|
||||||
|
val colorsMap = map.getMap("colors")
|
||||||
|
val colorsJson = root.optJSONObject("colors") ?: JSONObject().also { root.put("colors", it) }
|
||||||
|
if (colorsMap != null) {
|
||||||
|
for (key in COLOR_KEYS) {
|
||||||
|
if (colorsMap.hasKey(key) && !colorsMap.isNull(key)) {
|
||||||
|
colorsJson.put(key, colorsMap.getString(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.hasKey("labels")) {
|
||||||
|
val labelsMap = map.getMap("labels")
|
||||||
|
val labelsJson = root.optJSONObject("labels") ?: JSONObject().also { root.put("labels", it) }
|
||||||
|
if (labelsMap != null) {
|
||||||
|
for (key in LABEL_KEYS) {
|
||||||
|
if (labelsMap.hasKey(key) && !labelsMap.isNull(key)) {
|
||||||
|
labelsJson.put(key, labelsMap.getString(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString(KEY, root.toString())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取配置;缺失字段回退默认值,任何异常返回全默认。 */
|
||||||
|
fun load(context: Context): FloatingUiConfig {
|
||||||
|
return try {
|
||||||
|
val root = readRawJson(context)
|
||||||
|
val defaultColors = FloatingUiConfigColors()
|
||||||
|
val defaultLabels = FloatingUiConfigLabels()
|
||||||
|
val colorsJson = root.optJSONObject("colors")
|
||||||
|
val labelsJson = root.optJSONObject("labels")
|
||||||
|
|
||||||
|
val colors = FloatingUiConfigColors(
|
||||||
|
accent = colorsJson.optStringOr("accent", defaultColors.accent),
|
||||||
|
accentFg = colorsJson.optStringOr("accentFg", defaultColors.accentFg),
|
||||||
|
cardBg = colorsJson.optStringOr("cardBg", defaultColors.cardBg),
|
||||||
|
inputBg = colorsJson.optStringOr("inputBg", defaultColors.inputBg),
|
||||||
|
fgPrimary = colorsJson.optStringOr("fgPrimary", defaultColors.fgPrimary),
|
||||||
|
fgSecondary = colorsJson.optStringOr("fgSecondary", defaultColors.fgSecondary),
|
||||||
|
border = colorsJson.optStringOr("border", defaultColors.border),
|
||||||
|
income = colorsJson.optStringOr("income", defaultColors.income),
|
||||||
|
expense = colorsJson.optStringOr("expense", defaultColors.expense),
|
||||||
|
transfer = colorsJson.optStringOr("transfer", defaultColors.transfer),
|
||||||
|
)
|
||||||
|
val labels = FloatingUiConfigLabels(
|
||||||
|
billTitle = labelsJson.optStringOr("billTitle", defaultLabels.billTitle),
|
||||||
|
dirExpense = labelsJson.optStringOr("dirExpense", defaultLabels.dirExpense),
|
||||||
|
dirIncome = labelsJson.optStringOr("dirIncome", defaultLabels.dirIncome),
|
||||||
|
dirTransfer = labelsJson.optStringOr("dirTransfer", defaultLabels.dirTransfer),
|
||||||
|
amountLabel = labelsJson.optStringOr("amountLabel", defaultLabels.amountLabel),
|
||||||
|
payeeLabel = labelsJson.optStringOr("payeeLabel", defaultLabels.payeeLabel),
|
||||||
|
narrationLabel = labelsJson.optStringOr("narrationLabel", defaultLabels.narrationLabel),
|
||||||
|
narrationHint = labelsJson.optStringOr("narrationHint", defaultLabels.narrationHint),
|
||||||
|
categoryExpense = labelsJson.optStringOr("categoryExpense", defaultLabels.categoryExpense),
|
||||||
|
categoryIncome = labelsJson.optStringOr("categoryIncome", defaultLabels.categoryIncome),
|
||||||
|
transferTarget = labelsJson.optStringOr("transferTarget", defaultLabels.transferTarget),
|
||||||
|
accountExpense = labelsJson.optStringOr("accountExpense", defaultLabels.accountExpense),
|
||||||
|
accountIncome = labelsJson.optStringOr("accountIncome", defaultLabels.accountIncome),
|
||||||
|
accountTransfer = labelsJson.optStringOr("accountTransfer", defaultLabels.accountTransfer),
|
||||||
|
openApp = labelsJson.optStringOr("openApp", defaultLabels.openApp),
|
||||||
|
dismiss = labelsJson.optStringOr("dismiss", defaultLabels.dismiss),
|
||||||
|
confirm = labelsJson.optStringOr("confirm", defaultLabels.confirm),
|
||||||
|
ballOcr = labelsJson.optStringOr("ballOcr", defaultLabels.ballOcr),
|
||||||
|
ballRemember = labelsJson.optStringOr("ballRemember", defaultLabels.ballRemember),
|
||||||
|
rememberSuccess = labelsJson.optStringOr("rememberSuccess", defaultLabels.rememberSuccess),
|
||||||
|
rememberFail = labelsJson.optStringOr("rememberFail", defaultLabels.rememberFail),
|
||||||
|
pageRemembered = labelsJson.optStringOr("pageRemembered", defaultLabels.pageRemembered),
|
||||||
|
pageSignatureExists = labelsJson.optStringOr("pageSignatureExists", defaultLabels.pageSignatureExists),
|
||||||
|
)
|
||||||
|
FloatingUiConfig(colors, labels)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "读取浮层 UI 配置失败,回退默认值: ${e.message}")
|
||||||
|
FloatingUiConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析颜色字符串;失败/为空时解析 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import android.app.PendingIntent
|
|||||||
* - 用户下拉快速设置,点击「OCR 记账」磁贴触发一次手动 OCR
|
* - 用户下拉快速设置,点击「OCR 记账」磁贴触发一次手动 OCR
|
||||||
* - Android 14+ 用 PendingIntent + startActivityAndCollapse
|
* - Android 14+ 用 PendingIntent + startActivityAndCollapse
|
||||||
*
|
*
|
||||||
* 触发后调用 BillingAccessibilityService.triggerManualOcr()。
|
* 触发后调用 SelectToSpeakService.triggerManualOcr()。
|
||||||
*/
|
*/
|
||||||
class OcrTileService : TileService() {
|
class OcrTileService : TileService() {
|
||||||
|
|
||||||
@@ -38,9 +38,9 @@ class OcrTileService : TileService() {
|
|||||||
triggerManualOcr()
|
triggerManualOcr()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 触发手动 OCR(通过 BillingAccessibilityService)。 */
|
/** 触发手动 OCR(通过 SelectToSpeakService)。 */
|
||||||
private fun triggerManualOcr() {
|
private fun triggerManualOcr() {
|
||||||
val service = BillingAccessibilityService.instance
|
val service = SelectToSpeakService.instance
|
||||||
if (service != null) {
|
if (service != null) {
|
||||||
service.triggerManualOcr()
|
service.triggerManualOcr()
|
||||||
return
|
return
|
||||||
@@ -50,8 +50,8 @@ class OcrTileService : TileService() {
|
|||||||
val pi = PendingIntent.getActivity(
|
val pi = PendingIntent.getActivity(
|
||||||
this, 0,
|
this, 0,
|
||||||
Intent().apply {
|
Intent().apply {
|
||||||
setClassName(packageName, "com.beancount.mobile.MainActivity")
|
setClassName(packageName, "$packageName.MainActivity")
|
||||||
action = "com.beancount.mobile.TRIGGER_OCR"
|
action = "$packageName.TRIGGER_OCR"
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
},
|
},
|
||||||
PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
|||||||
+194
-64
@@ -29,16 +29,25 @@ import android.view.accessibility.AccessibilityNodeInfo
|
|||||||
* - 横屏免打扰(游戏/视频时不触发)
|
* - 横屏免打扰(游戏/视频时不触发)
|
||||||
* - ocrDoing 守卫(防止重复触发)
|
* - ocrDoing 守卫(防止重复触发)
|
||||||
*
|
*
|
||||||
* 伪装说明:plan.md 决策 3「纯开源侧载」保留无障碍伪装(非应用商店分发)。
|
* 伪装说明(绕过微信 8.0.52+ 节点混淆):
|
||||||
* 注意:本服务类名在 manifest 中声明为 BillingAccessibilityService,
|
* 微信按 ComponentName(包名/类名)识别系统服务白名单——TalkBack、SelectToSpeak
|
||||||
* 伪装为系统服务(包名 com.beancount.mobile.accessibility)仅在侧载版本保留。
|
* 等系统服务不受节点混淆影响,第三方服务则被混淆。
|
||||||
|
* 本服务的 package + class 全部伪装为
|
||||||
|
* com.google.android.accessibility.selecttospeak.SelectToSpeakService,
|
||||||
|
* 使微信将其识别为系统 SelectToSpeak 服务,从而拿到未混淆的真实节点文本。
|
||||||
|
*
|
||||||
|
* 伪装由 Config Plugin(plugins/accessibility/app.plugin.js)在 prebuild 时完成:
|
||||||
|
* - kt 源码 package 被 rewrite 为 com.google.android.accessibility.selecttospeak
|
||||||
|
* - Manifest android:name 写成完整全限定名
|
||||||
|
*
|
||||||
|
* 注意:plan.md 决策 3「纯开源侧载」保留无障碍伪装(非应用商店分发)。
|
||||||
*
|
*
|
||||||
* 通过 DeviceEventEmitter 把识别事件推送到 JS 层 automationStore。
|
* 通过 DeviceEventEmitter 把识别事件推送到 JS 层 automationStore。
|
||||||
*
|
*
|
||||||
* ⚠️ 与 src/domain/constants.ts 同步:PAYMENT_PACKAGES
|
* ⚠️ 与 src/domain/constants.ts 同步:PAYMENT_PACKAGES
|
||||||
* 修改时需两边同时更新。
|
* 修改时需两边同时更新。
|
||||||
*/
|
*/
|
||||||
class BillingAccessibilityService : AccessibilityService() {
|
class SelectToSpeakService : AccessibilityService() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "BillingAccessibility"
|
private const val TAG = "BillingAccessibility"
|
||||||
@@ -46,10 +55,17 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
private const val PREF_PAGE_SIGNATURES = "page_signatures"
|
private const val PREF_PAGE_SIGNATURES = "page_signatures"
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var instance: BillingAccessibilityService? = null
|
var instance: SelectToSpeakService? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/** 支付 App 白名单。 */
|
/** 悬浮球全局开关(由 JS 层通过 AccessibilityBridge 控制)。 */
|
||||||
|
@Volatile
|
||||||
|
var floatingBallEnabled = true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付 App 包名(自动生成,来源:src/domain/constants.ts PAYMENT_PACKAGES)。
|
||||||
|
* 修改时只需编辑 constants.ts,运行 expo prebuild 即可同步。
|
||||||
|
*/
|
||||||
val PAYMENT_PACKAGES = setOf(
|
val PAYMENT_PACKAGES = setOf(
|
||||||
"com.eg.android.AlipayGphone", // 支付宝
|
"com.eg.android.AlipayGphone", // 支付宝
|
||||||
"com.tencent.mm", // 微信
|
"com.tencent.mm", // 微信
|
||||||
@@ -79,6 +95,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var ocrDoing = false
|
private var ocrDoing = false
|
||||||
|
private var ocrDoingTimestamp = 0L
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private val debounceRunnable = Runnable { processContentChange() }
|
private val debounceRunnable = Runnable { processContentChange() }
|
||||||
@Volatile private var topPackage: String? = null
|
@Volatile private var topPackage: String? = null
|
||||||
@@ -93,6 +110,14 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
Log.i(TAG, "无障碍账单识别服务已连接")
|
Log.i(TAG, "无障碍账单识别服务已连接")
|
||||||
loadPageSignatures()
|
loadPageSignatures()
|
||||||
configureService()
|
configureService()
|
||||||
|
// 修复:检查当前前台 App,恢复悬浮球
|
||||||
|
val root = rootInActiveWindow
|
||||||
|
val pkg = root?.packageName?.toString()
|
||||||
|
root?.recycle()
|
||||||
|
if (pkg != null) {
|
||||||
|
topPackage = pkg
|
||||||
|
updateFloatingHelperVisibility(pkg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 动态配置服务能力(截图 + 页面变化监听)。 */
|
/** 动态配置服务能力(截图 + 页面变化监听)。 */
|
||||||
@@ -103,6 +128,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC
|
feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC
|
||||||
flags = AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY or
|
flags = AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY or
|
||||||
AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or
|
AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or
|
||||||
|
AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS or
|
||||||
AccessibilityServiceInfo.DEFAULT
|
AccessibilityServiceInfo.DEFAULT
|
||||||
notificationTimeout = 100L
|
notificationTimeout = 100L
|
||||||
}
|
}
|
||||||
@@ -110,7 +136,15 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||||
if (ocrDoing) return // 处理中,跳过
|
// ocrDoing 超时兜底:5秒后强制重置
|
||||||
|
if (ocrDoing) {
|
||||||
|
if (System.currentTimeMillis() - ocrDoingTimestamp > 5000) {
|
||||||
|
Log.w(TAG, "ocrDoing 超时重置")
|
||||||
|
ocrDoing = false
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
val eventPackage = event?.packageName?.toString() ?: return
|
val eventPackage = event?.packageName?.toString() ?: return
|
||||||
|
|
||||||
when (event.eventType) {
|
when (event.eventType) {
|
||||||
@@ -124,51 +158,27 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
// 更新悬浮窗助手状态
|
// 更新悬浮窗助手状态
|
||||||
updateFloatingHelperVisibility(eventPackage)
|
updateFloatingHelperVisibility(eventPackage)
|
||||||
|
|
||||||
if (PAYMENT_PACKAGES.contains(eventPackage)) {
|
// 页面切换时检查是否有已记住的签名需要触发文本提取
|
||||||
scheduleContentChange()
|
scheduleContentChange()
|
||||||
}
|
|
||||||
|
|
||||||
// 调试:如果是微信或支付宝,延迟 800ms 抓取并打印全屏无障碍文本内容
|
// 注意:自动监听调试日志已移除(微信 8.0.52+ 混淆节点文本,自动监听无意义)。
|
||||||
if (eventPackage == "com.tencent.mm" || eventPackage == "com.eg.android.AlipayGphone") {
|
// 账单识别仅在用户点击悬浮球时触发(isManual=true),此时提取完整节点文本。
|
||||||
handler.postDelayed({
|
|
||||||
val rootNode = rootInActiveWindow
|
|
||||||
val texts = mutableListOf<String>()
|
|
||||||
dumpNodeTexts(rootNode, texts)
|
|
||||||
rootNode?.recycle()
|
|
||||||
|
|
||||||
val reactContext = ReactContextHolder.context
|
|
||||||
if (reactContext != null) {
|
|
||||||
try {
|
|
||||||
val map = WritableNativeMap().apply {
|
|
||||||
putString("package", eventPackage)
|
|
||||||
putString("activity", activityName)
|
|
||||||
val array = WritableNativeArray()
|
|
||||||
for (t in texts) {
|
|
||||||
array.pushString(t)
|
|
||||||
}
|
|
||||||
putArray("texts", array)
|
|
||||||
}
|
|
||||||
reactContext
|
|
||||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
||||||
.emit("billingDebugNodes", map)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// 忽略
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 800)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
|
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
|
||||||
if (PAYMENT_PACKAGES.contains(eventPackage)) {
|
// 内容变化时也检查(防抖合并)
|
||||||
scheduleContentChange()
|
scheduleContentChange()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateFloatingHelperVisibility(pkg: String?) {
|
private fun updateFloatingHelperVisibility(pkg: String?) {
|
||||||
handler.post {
|
handler.post {
|
||||||
if (pkg != null && PAYMENT_PACKAGES.contains(pkg)) {
|
val shouldShow = floatingBallEnabled
|
||||||
|
&& pkg != null
|
||||||
|
&& !filterPackage(pkg, topActivity ?: "")
|
||||||
|
&& !isLandscape()
|
||||||
|
|
||||||
|
if (shouldShow) {
|
||||||
if (floatingHelper == null) {
|
if (floatingHelper == null) {
|
||||||
floatingHelper = FloatingHelper(this)
|
floatingHelper = FloatingHelper(this)
|
||||||
floatingHelper?.show()
|
floatingHelper?.show()
|
||||||
@@ -204,11 +214,8 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
return // 未记住的页面不自动触发
|
return // 未记住的页面不自动触发
|
||||||
}
|
}
|
||||||
|
|
||||||
// 抓取并提取屏幕所有无障碍文本,并推送至 JS 侧进行解析/控制
|
// 抓取并提取屏幕所有无障碍文本(含 WebView 子窗口),并推送至 JS 侧
|
||||||
val rootNode = rootInActiveWindow
|
val texts = dumpAllTexts()
|
||||||
val texts = mutableListOf<String>()
|
|
||||||
dumpNodeTexts(rootNode, texts)
|
|
||||||
rootNode?.recycle()
|
|
||||||
|
|
||||||
val reactContext = ReactContextHolder.context
|
val reactContext = ReactContextHolder.context
|
||||||
if (reactContext != null) {
|
if (reactContext != null) {
|
||||||
@@ -236,6 +243,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
private fun takeScreenshotAndProcess(packageName: String) {
|
private fun takeScreenshotAndProcess(packageName: String) {
|
||||||
if (ocrDoing) return
|
if (ocrDoing) return
|
||||||
ocrDoing = true
|
ocrDoing = true
|
||||||
|
ocrDoingTimestamp = System.currentTimeMillis()
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||||
ocrDoing = false
|
ocrDoing = false
|
||||||
return
|
return
|
||||||
@@ -250,7 +258,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
val bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
|
val bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
|
||||||
result.hardwareBuffer.close()
|
result.hardwareBuffer.close()
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
val base64 = bitmapToBase64(bitmap)
|
val base64 = bitmapToBase64(bitmap, packageName)
|
||||||
bitmap.recycle()
|
bitmap.recycle()
|
||||||
// 推送到 JS 层(NativeEventEmitter)
|
// 推送到 JS 层(NativeEventEmitter)
|
||||||
sendScreenshotEvent(base64, packageName)
|
sendScreenshotEvent(base64, packageName)
|
||||||
@@ -310,7 +318,12 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
}, 150)
|
}, 150)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手动触发一次节点文本提取并发送到 JS,从而让 JS 优先尝试直接文本解析。 */
|
/**
|
||||||
|
* 手动触发一次节点文本提取并发送到 JS,从而让 JS 优先尝试直接文本解析。
|
||||||
|
*
|
||||||
|
* 使用 dumpAllTexts() 覆盖所有窗口(含 WebView 子窗口),而非仅 rootInActiveWindow。
|
||||||
|
* 伪装生效后这条路径会成为微信账单页的主识别路径,必须保证完整性。
|
||||||
|
*/
|
||||||
fun triggerManualExtraction() {
|
fun triggerManualExtraction() {
|
||||||
handler.post {
|
handler.post {
|
||||||
floatingHelper?.collapse()
|
floatingHelper?.collapse()
|
||||||
@@ -319,10 +332,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
val activity = topActivity ?: ""
|
val activity = topActivity ?: ""
|
||||||
val sigKey = "$pkg|$activity"
|
val sigKey = "$pkg|$activity"
|
||||||
|
|
||||||
val rootNode = rootInActiveWindow
|
val texts = dumpAllTexts()
|
||||||
val texts = mutableListOf<String>()
|
|
||||||
dumpNodeTexts(rootNode, texts)
|
|
||||||
rootNode?.recycle()
|
|
||||||
|
|
||||||
val reactContext = ReactContextHolder.context
|
val reactContext = ReactContextHolder.context
|
||||||
if (reactContext != null) {
|
if (reactContext != null) {
|
||||||
@@ -352,11 +362,12 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
val pkg = topPackage ?: return
|
val pkg = topPackage ?: return
|
||||||
val activity = topActivity ?: ""
|
val activity = topActivity ?: ""
|
||||||
val sig = "$pkg|$activity"
|
val sig = "$pkg|$activity"
|
||||||
|
val uiLabels = FloatingUiConfigStore.load(this).labels
|
||||||
if (pageSignatures.add(sig)) {
|
if (pageSignatures.add(sig)) {
|
||||||
savePageSignatures()
|
savePageSignatures()
|
||||||
Log.i(TAG, "已记住页面: $sig")
|
Log.i(TAG, "已记住页面: $sig")
|
||||||
handler.post {
|
handler.post {
|
||||||
android.widget.Toast.makeText(this, "已记住页面签名:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
android.widget.Toast.makeText(this, "${uiLabels.pageRemembered}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 抓取并提取屏幕所有无障碍文本
|
// 抓取并提取屏幕所有无障碍文本
|
||||||
@@ -388,7 +399,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
handler.post {
|
handler.post {
|
||||||
android.widget.Toast.makeText(this, "该页面签名已存在:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
android.widget.Toast.makeText(this, "${uiLabels.pageSignatureExists}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,6 +462,21 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 关闭悬浮球(由 JS 层 setFloatingBallEnabled(false) 调用)。 */
|
||||||
|
fun dismissFloatingHelper() {
|
||||||
|
handler.post {
|
||||||
|
floatingHelper?.dismiss()
|
||||||
|
floatingHelper = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新悬浮球状态(由 JS 层 setFloatingBallEnabled(true) 调用)。 */
|
||||||
|
fun refreshFloatingHelper() {
|
||||||
|
handler.post {
|
||||||
|
updateFloatingHelperVisibility(topPackage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 横屏检测(plan.md「3.10 横屏免打扰」)。 */
|
/** 横屏检测(plan.md「3.10 横屏免打扰」)。 */
|
||||||
private fun isLandscape(): Boolean {
|
private fun isLandscape(): Boolean {
|
||||||
val dm = getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
|
val dm = getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||||
@@ -459,28 +485,31 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270
|
return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 过滤系统组件/桌面(不触发 OCR)。 */
|
/** 过滤不应处理的系统组件(桌面启动器、SystemUI、输入法等)。 */
|
||||||
private fun filterPackage(pkg: String, className: String): Boolean {
|
private fun filterPackage(pkg: String, className: String): Boolean {
|
||||||
val p = pkg.lowercase()
|
val p = pkg.lowercase()
|
||||||
// 针对自身应用的特殊过滤:只允许 MainActivity 通过以触发隐藏悬浮窗;
|
// 自身 App:只放行 MainActivity,避免悬浮球容器触发自毁式关闭
|
||||||
// 其他自身组件(如悬浮球容器 LinearLayout)一律过滤,避免自毁式关闭。
|
|
||||||
if (pkg == packageName) {
|
if (pkg == packageName) {
|
||||||
return className != "com.example.beanmobile.MainActivity"
|
if (className.isEmpty()) return false
|
||||||
|
return !className.endsWith(".MainActivity")
|
||||||
}
|
}
|
||||||
if (p == "android" || p.startsWith("com.android.") || p.startsWith("com.google.android.")) return true
|
// 桌面启动器(悬浮球在桌面无意义)
|
||||||
if (p.contains("systemui") || p.contains("settings") || p.contains("inputmethod") || p.contains("keyboard") || p.contains("input")) return true
|
|
||||||
if (LAUNCHER_PACKAGES.any { p.contains(it) }) return true
|
if (LAUNCHER_PACKAGES.any { p.contains(it) }) return true
|
||||||
|
// SystemUI / 输入法(悬浮球在这些组件上会遮挡系统 UI)
|
||||||
|
if (p.contains("systemui") || p.contains("inputmethod") || p.contains("keyboard")) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bitmap → base64(JPEG 质量 60,参考 AutoAccounting bitmapToBase64)。 */
|
/** Bitmap → base64(JPEG 质量 60,参考 AutoAccounting bitmapToBase64)。 */
|
||||||
private fun bitmapToBase64(bitmap: Bitmap): String {
|
private fun bitmapToBase64(bitmap: Bitmap, packageName: String = ""): String {
|
||||||
// 安全起见,如果 bitmap 是 HARDWARE 格式,将其复制为 ARGB_8888 软件格式
|
// 安全起见,如果 bitmap 是 HARDWARE 格式,将其复制为 ARGB_8888 软件格式
|
||||||
val softwareBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.config == Bitmap.Config.HARDWARE) {
|
val softwareBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.config == Bitmap.Config.HARDWARE) {
|
||||||
bitmap.copy(Bitmap.Config.ARGB_8888, false)
|
bitmap.copy(Bitmap.Config.ARGB_8888, false)
|
||||||
} else {
|
} else {
|
||||||
bitmap
|
bitmap
|
||||||
}
|
}
|
||||||
|
// 诊断:分析截图有效性(区分「截到空白帧」与「有内容但 OCR 失败」)
|
||||||
|
analyzeScreenshot(softwareBitmap, packageName)
|
||||||
val baos = ByteArrayOutputStream()
|
val baos = ByteArrayOutputStream()
|
||||||
softwareBitmap.compress(Bitmap.CompressFormat.JPEG, 60, baos)
|
softwareBitmap.compress(Bitmap.CompressFormat.JPEG, 60, baos)
|
||||||
if (softwareBitmap !== bitmap) {
|
if (softwareBitmap !== bitmap) {
|
||||||
@@ -489,6 +518,70 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截图有效性诊断(纯日志,不影响主流程)。
|
||||||
|
*
|
||||||
|
* 通过网格采样统计像素,输出:
|
||||||
|
* - 尺寸、采样数
|
||||||
|
* - 是否纯色(所有采样像素颜色完全一致 → 疑似空白/纯色帧)
|
||||||
|
* - 亮度均值与方差(方差≈0 → 无内容;方差高 → 有内容)
|
||||||
|
* - 深色/浅色像素占比
|
||||||
|
*
|
||||||
|
* 用于定位微信 WebView 场景:takeScreenshot 是否截到了未渲染的空白帧。
|
||||||
|
*/
|
||||||
|
private fun analyzeScreenshot(bitmap: Bitmap, packageName: String) {
|
||||||
|
try {
|
||||||
|
val w = bitmap.width
|
||||||
|
val h = bitmap.height
|
||||||
|
if (w <= 0 || h <= 0) {
|
||||||
|
Log.w(TAG, "[截图诊断] pkg=$packageName 无效尺寸: ${w}x${h}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 网格采样:目标约 400 个采样点,避免大图全像素扫描阻塞
|
||||||
|
val stepX = Math.max(1, w / 20)
|
||||||
|
val stepY = Math.max(1, h / 20)
|
||||||
|
var sum = 0L
|
||||||
|
var sumSq = 0L
|
||||||
|
var count = 0
|
||||||
|
var firstPixel = 0
|
||||||
|
var isSolidColor = true
|
||||||
|
var darkCount = 0 // 亮度 < 30(近黑)
|
||||||
|
var lightCount = 0 // 亮度 > 225(近白)
|
||||||
|
for (y in 0 until h step stepY) {
|
||||||
|
for (x in 0 until w step stepX) {
|
||||||
|
val pixel = bitmap.getPixel(x, y)
|
||||||
|
if (count == 0) {
|
||||||
|
firstPixel = pixel
|
||||||
|
} else if (pixel != firstPixel) {
|
||||||
|
isSolidColor = false
|
||||||
|
}
|
||||||
|
// 亮度(ITU-R BT.601):0.299R + 0.587G + 0.114B
|
||||||
|
val r = (pixel shr 16) and 0xFF
|
||||||
|
val g = (pixel shr 8) and 0xFF
|
||||||
|
val b = pixel and 0xFF
|
||||||
|
val lum = (r * 299 + g * 587 + b * 114) / 1000
|
||||||
|
sum += lum
|
||||||
|
sumSq += lum.toLong() * lum
|
||||||
|
if (lum < 30) darkCount++
|
||||||
|
if (lum > 225) lightCount++
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val mean = if (count > 0) sum.toDouble() / count else 0.0
|
||||||
|
val variance = if (count > 0) sumSq.toDouble() / count - mean * mean else 0.0
|
||||||
|
val darkRatio = if (count > 0) darkCount.toDouble() / count else 0.0
|
||||||
|
val lightRatio = if (count > 0) lightCount.toDouble() / count else 0.0
|
||||||
|
// 判定:纯色 或 方差<10 视为「疑似空白帧」
|
||||||
|
val likelyBlank = isSolidColor || variance < 10.0
|
||||||
|
Log.i(TAG, "[截图诊断] pkg=$packageName 尺寸=${w}x${h} 采样=$count " +
|
||||||
|
"纯色=$isSolidColor 亮度均值=${"%.1f".format(mean)} 方差=${"%.1f".format(variance)} " +
|
||||||
|
"深色占比=${"%.2f".format(darkRatio)} 浅色占比=${"%.2f".format(lightRatio)} " +
|
||||||
|
"疑似空白帧=$likelyBlank base64大小约=${(w * h) / 8}字节")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "[截图诊断] pkg=$packageName 分析异常: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 辅助:构造 WritableNativeMap。 */
|
/** 辅助:构造 WritableNativeMap。 */
|
||||||
private fun writableMapOf(vararg pairs: Pair<String, Any?>): WritableNativeMap {
|
private fun writableMapOf(vararg pairs: Pair<String, Any?>): WritableNativeMap {
|
||||||
val map = WritableNativeMap()
|
val map = WritableNativeMap()
|
||||||
@@ -521,13 +614,18 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 递归提取无障碍节点树的全部文本。 */
|
/** 递归提取无障碍节点树的全部文本(text + contentDescription)。 */
|
||||||
private fun dumpNodeTexts(node: AccessibilityNodeInfo?, list: MutableList<String>) {
|
private fun dumpNodeTexts(node: AccessibilityNodeInfo?, list: MutableList<String>) {
|
||||||
if (node == null) return
|
if (node == null) return
|
||||||
val text = node.text?.toString()
|
val text = node.text?.toString()
|
||||||
if (!text.isNullOrBlank()) {
|
if (!text.isNullOrBlank()) {
|
||||||
list.add(text)
|
list.add(text)
|
||||||
}
|
}
|
||||||
|
// 修复:微信 8.0.52+ 混淆了 text,但部分节点仍通过 contentDescription 暴露内容
|
||||||
|
val desc = node.contentDescription?.toString()
|
||||||
|
if (!desc.isNullOrBlank() && desc != text) {
|
||||||
|
list.add(desc)
|
||||||
|
}
|
||||||
val childCount = node.childCount
|
val childCount = node.childCount
|
||||||
for (i in 0 until childCount) {
|
for (i in 0 until childCount) {
|
||||||
val child = node.getChild(i) ?: continue
|
val child = node.getChild(i) ?: continue
|
||||||
@@ -536,6 +634,38 @@ class BillingAccessibilityService : AccessibilityService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增强版文本提取:先尝试 rootInActiveWindow,若为空则遍历所有窗口。
|
||||||
|
* 修复:新版 Android WebView 可能不在 activeWindow 中暴露节点,
|
||||||
|
* 需要通过 getWindows() 获取 WebView 子窗口的节点树。
|
||||||
|
*/
|
||||||
|
private fun dumpAllTexts(): List<String> {
|
||||||
|
val texts = mutableListOf<String>()
|
||||||
|
|
||||||
|
// 1. 先尝试 rootInActiveWindow(常规路径)
|
||||||
|
val rootNode = rootInActiveWindow
|
||||||
|
if (rootNode != null) {
|
||||||
|
dumpNodeTexts(rootNode, texts)
|
||||||
|
rootNode.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果 activeWindow 为空或无文本,遍历所有窗口(WebView 子窗口可能在这里)
|
||||||
|
if (texts.isEmpty()) {
|
||||||
|
try {
|
||||||
|
val allWindows = windows
|
||||||
|
for (window in allWindows) {
|
||||||
|
val wRoot = window.root ?: continue
|
||||||
|
dumpNodeTexts(wRoot, texts)
|
||||||
|
wRoot.recycle()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "getWindows() 遍历失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return texts
|
||||||
|
}
|
||||||
|
|
||||||
/** 递归遍历无障碍节点树,检查是否包含指定的任意一个关键字。 */
|
/** 递归遍历无障碍节点树,检查是否包含指定的任意一个关键字。 */
|
||||||
private fun findTextInNode(node: AccessibilityNodeInfo?, keywords: List<String>): Boolean {
|
private fun findTextInNode(node: AccessibilityNodeInfo?, keywords: List<String>): Boolean {
|
||||||
if (node == null) return false
|
if (node == null) return false
|
||||||
@@ -2,7 +2,13 @@
|
|||||||
* 无障碍服务 Config Plugin(plan.md「3.6 无障碍服务」+「决策 4」)。
|
* 无障碍服务 Config Plugin(plan.md「3.6 无障碍服务」+「决策 4」)。
|
||||||
*
|
*
|
||||||
* 在 expo prebuild 时注册 Android 无障碍服务(manifest service + xml 配置)。
|
* 在 expo prebuild 时注册 Android 无障碍服务(manifest service + xml 配置)。
|
||||||
* 参考 AutoAccounting 的 SelectToSpeakService 伪装机制(侧载保留,plan.md 决策 3)。
|
*
|
||||||
|
* 伪装机制(绕过微信 8.0.52+ 节点混淆):
|
||||||
|
* 微信按 ComponentName(包名/类名)识别系统服务白名单。早期只伪装类名不伪装包名,
|
||||||
|
* 因此被微信识别为第三方服务、对节点 text/contentDescription 做混淆。
|
||||||
|
* 现在把 7 个 kt 文件整体移到 com.google.android.accessibility.selecttospeak 包,
|
||||||
|
* 并把 Manifest 服务名写成完整全限定名,让微信将其识别为系统 SelectToSpeak 服务,
|
||||||
|
* 从而拿到未混淆的真实节点文本。
|
||||||
*
|
*
|
||||||
* 注意:withAndroidManifest 的 modResults 结构是 { manifest: { ... } },
|
* 注意:withAndroidManifest 的 modResults 结构是 { manifest: { ... } },
|
||||||
* 操作 application 必须通过 modResults.manifest.application。
|
* 操作 application 必须通过 modResults.manifest.application。
|
||||||
@@ -12,7 +18,43 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require('
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const PACKAGE = 'com.beancount.mobile.accessibility';
|
/**
|
||||||
|
* 伪装目标包名:系统 SelectToSpeak 服务的完整包名。
|
||||||
|
* 7 个 kt 文件会被复制到此包路径下,Manifest 服务名也用此包的全限定名。
|
||||||
|
*/
|
||||||
|
const FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
|
||||||
|
/** SelectToSpeakService 的完整 ComponentName(Manifest android:name 用)。 */
|
||||||
|
const FAKE_SERVICE_NAME = `${FAKE_PACKAGE}.SelectToSpeakService`;
|
||||||
|
/** OcrTileService 的完整 ComponentName(与 Service 同包,保持同 package 引用)。 */
|
||||||
|
const FAKE_TILE_SERVICE_NAME = `${FAKE_PACKAGE}.OcrTileService`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 src/domain/constants.ts 的 PAYMENT_PACKAGES 中提取包名列表。
|
||||||
|
* 这是唯一的真相来源,Kotlin 端通过此函数自动同步。
|
||||||
|
*/
|
||||||
|
function readPaymentPackagesFromConstants() {
|
||||||
|
const constantsPath = path.resolve(__dirname, '../../src/domain/constants.ts');
|
||||||
|
if (!fs.existsSync(constantsPath)) return null;
|
||||||
|
const content = fs.readFileSync(constantsPath, 'utf8');
|
||||||
|
// 匹配 PAYMENT_PACKAGES = { ... } 中的 key(冒号前的字符串)
|
||||||
|
const match = content.match(/PAYMENT_PACKAGES:\s*Record<string,\s*string>\s*=\s*\{([\s\S]*?)\n\};/);
|
||||||
|
if (!match) return null;
|
||||||
|
const keys = [];
|
||||||
|
const keyRegex = /^(\s*)'([^']+)'\s*:/gm;
|
||||||
|
let m;
|
||||||
|
while ((m = keyRegex.exec(match[1])) !== null) {
|
||||||
|
keys.push(m[2]);
|
||||||
|
}
|
||||||
|
return keys.length > 0 ? keys : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 Kotlin setOf("...", "...", ...) 代码。
|
||||||
|
*/
|
||||||
|
function generateKotlinPaymentPackages(packages) {
|
||||||
|
const items = packages.map(pkg => ` "${pkg}"`).join(',\n');
|
||||||
|
return ` val PAYMENT_PACKAGES = setOf(\n${items}\n )`;
|
||||||
|
}
|
||||||
|
|
||||||
/** 递归复制目录。 */
|
/** 递归复制目录。 */
|
||||||
function copyDir(src, dest) {
|
function copyDir(src, dest) {
|
||||||
@@ -27,18 +69,28 @@ function copyDir(src, dest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function withAccessibilityService(config) {
|
function withAccessibilityService(config) {
|
||||||
|
// 7 个 kt 文件全部跟随 SelectToSpeakService 伪装到 FAKE_PACKAGE 包下,
|
||||||
|
// 不再使用应用真实包名(appId)。
|
||||||
|
|
||||||
// 1. 复制 Kotlin 源码 + res/xml 资源到原生工程
|
// 1. 复制 Kotlin 源码 + res/xml 资源到原生工程
|
||||||
config = withDangerousMod(config, [
|
config = withDangerousMod(config, [
|
||||||
'android',
|
'android',
|
||||||
async (modConfig) => {
|
async (modConfig) => {
|
||||||
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
||||||
// Kotlin 源码(只复制 .kt 文件,不含 res/ 子目录)
|
// Kotlin 源码统一复制到 FAKE_PACKAGE 目录下(伪装为系统 SelectToSpeak 服务包名)。
|
||||||
const ktDest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/accessibility');
|
// kt 源码里仍写 `com.beancount.mobile`,此处用正则替换为 FAKE_PACKAGE。
|
||||||
|
const fakePkgPath = FAKE_PACKAGE.replace(/\./g, '/');
|
||||||
|
const ktDest = path.join(projectRoot, 'app/src/main/java', fakePkgPath);
|
||||||
fs.mkdirSync(ktDest, { recursive: true });
|
fs.mkdirSync(ktDest, { recursive: true });
|
||||||
const androidDir = path.join(__dirname, 'android');
|
const androidDir = path.join(__dirname, 'android');
|
||||||
for (const f of fs.readdirSync(androidDir)) {
|
for (const f of fs.readdirSync(androidDir)) {
|
||||||
if (f.endsWith('.kt')) {
|
if (f.endsWith('.kt')) {
|
||||||
fs.copyFileSync(path.join(androidDir, f), path.join(ktDest, f));
|
let content = fs.readFileSync(path.join(androidDir, f), 'utf8');
|
||||||
|
// kt 源码 package 为 com.beancount.mobile.accessibility,整体替换为 FAKE_PACKAGE。
|
||||||
|
// 注意正则要匹配到 .accessibility 后缀,否则会多出 .accessibility 导致与 Manifest 不一致。
|
||||||
|
content = content.replace(/package\s+com\.beancount\.mobile\.accessibility/g, `package ${FAKE_PACKAGE}`);
|
||||||
|
content = content.replace(/import\s+com\.beancount\.mobile\.accessibility/g, `import ${FAKE_PACKAGE}`);
|
||||||
|
fs.writeFileSync(path.join(ktDest, f), content, 'utf8');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// res/xml 资源
|
// res/xml 资源
|
||||||
@@ -46,6 +98,23 @@ function withAccessibilityService(config) {
|
|||||||
if (fs.existsSync(resSrc)) {
|
if (fs.existsSync(resSrc)) {
|
||||||
copyDir(resSrc, path.join(projectRoot, 'app/src/main/res'));
|
copyDir(resSrc, path.join(projectRoot, 'app/src/main/res'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 constants.ts 读取包名列表,自动同步到 Kotlin
|
||||||
|
const packages = readPaymentPackagesFromConstants();
|
||||||
|
if (packages) {
|
||||||
|
const ktFile = path.join(ktDest, 'SelectToSpeakService.kt');
|
||||||
|
if (fs.existsSync(ktFile)) {
|
||||||
|
let ktContent = fs.readFileSync(ktFile, 'utf8');
|
||||||
|
// 替换 PAYMENT_PACKAGES 定义(匹配 val PAYMENT_PACKAGES = setOf( ... ))
|
||||||
|
const regex = /val PAYMENT_PACKAGES = setOf\([\s\S]*?\)/;
|
||||||
|
if (regex.test(ktContent)) {
|
||||||
|
ktContent = ktContent.replace(regex, generateKotlinPaymentPackages(packages));
|
||||||
|
fs.writeFileSync(ktFile, ktContent, 'utf8');
|
||||||
|
console.log(`[accessibility plugin] 已同步 ${packages.length} 个支付 App 包名到 Kotlin`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 确保有 accessibility_service_description 字符串资源
|
// 确保有 accessibility_service_description 字符串资源
|
||||||
const stringsXmlPath = path.join(projectRoot, 'app/src/main/res/values/strings.xml');
|
const stringsXmlPath = path.join(projectRoot, 'app/src/main/res/values/strings.xml');
|
||||||
if (fs.existsSync(stringsXmlPath)) {
|
if (fs.existsSync(stringsXmlPath)) {
|
||||||
@@ -63,16 +132,17 @@ function withAccessibilityService(config) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// 2. 注册 AccessibilityBridgePackage 到 MainApplication
|
// 2. 注册 AccessibilityBridgePackage 到 MainApplication
|
||||||
|
// 注意:AccessibilityBridgePackage 跟随其他 kt 文件一起被复制到 FAKE_PACKAGE 下,
|
||||||
|
// 因此 import 路径要用 FAKE_PACKAGE 而非 PACKAGE(应用包名)。
|
||||||
config = withMainApplication(config, (modConfig) => {
|
config = withMainApplication(config, (modConfig) => {
|
||||||
let content = modConfig.modResults.contents;
|
let content = modConfig.modResults.contents;
|
||||||
|
|
||||||
// 2a. 注入 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 ${FAKE_PACKAGE}.AccessibilityBridgePackage`,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
|
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
|
||||||
if (!content.includes('add(AccessibilityBridgePackage())')) {
|
if (!content.includes('add(AccessibilityBridgePackage())')) {
|
||||||
@@ -93,16 +163,26 @@ function withAccessibilityService(config) {
|
|||||||
return modConfig;
|
return modConfig;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. 注册服务到 AndroidManifest
|
// 3. 注册权限 + 服务到 AndroidManifest
|
||||||
config = withAndroidManifest(config, (modConfig) => {
|
config = withAndroidManifest(config, (modConfig) => {
|
||||||
const manifest = modConfig.modResults.manifest;
|
const manifest = modConfig.modResults.manifest;
|
||||||
|
|
||||||
// 1. 添加无障碍服务声明
|
// 0. 添加 SYSTEM_ALERT_WINDOW 权限(悬浮窗必需)
|
||||||
|
if (!manifest['uses-permission']) {
|
||||||
|
manifest['uses-permission'] = [];
|
||||||
|
}
|
||||||
|
const overlayPerm = 'android.permission.SYSTEM_ALERT_WINDOW';
|
||||||
|
const overlayPermExists = manifest['uses-permission'].some(p => p.$['android:name'] === overlayPerm);
|
||||||
|
if (!overlayPermExists) {
|
||||||
|
manifest['uses-permission'].push({ $: { 'android:name': overlayPerm } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 添加无障碍服务声明(服务名使用伪装包的全限定名,以匹配微信系统服务白名单)
|
||||||
const serviceNode = {
|
const serviceNode = {
|
||||||
$: {
|
$: {
|
||||||
'android:name': 'com.beancount.mobile.accessibility.BillingAccessibilityService',
|
'android:name': FAKE_SERVICE_NAME,
|
||||||
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
|
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
|
||||||
'android:label': '账单识别',
|
'android:label': '浮记-账单识别',
|
||||||
'android:exported': 'false',
|
'android:exported': 'false',
|
||||||
},
|
},
|
||||||
'intent-filter': [{
|
'intent-filter': [{
|
||||||
@@ -125,7 +205,7 @@ function withAccessibilityService(config) {
|
|||||||
app.service = [];
|
app.service = [];
|
||||||
}
|
}
|
||||||
const exists = app.service.some(
|
const exists = app.service.some(
|
||||||
s => s.$['android:name'] === 'com.beancount.mobile.accessibility.BillingAccessibilityService'
|
s => s.$['android:name'] === FAKE_SERVICE_NAME
|
||||||
);
|
);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
app.service.push(serviceNode);
|
app.service.push(serviceNode);
|
||||||
@@ -134,7 +214,7 @@ function withAccessibilityService(config) {
|
|||||||
// 3. 添加 OcrTileService 声明(快速设置磁贴,plan.md「3.11」)
|
// 3. 添加 OcrTileService 声明(快速设置磁贴,plan.md「3.11」)
|
||||||
const tileServiceNode = {
|
const tileServiceNode = {
|
||||||
$: {
|
$: {
|
||||||
'android:name': 'com.beancount.mobile.accessibility.OcrTileService',
|
'android:name': FAKE_TILE_SERVICE_NAME,
|
||||||
'android:label': 'OCR 记账',
|
'android:label': 'OCR 记账',
|
||||||
'android:icon': '@android:drawable/ic_menu_camera',
|
'android:icon': '@android:drawable/ic_menu_camera',
|
||||||
'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE',
|
'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE',
|
||||||
@@ -145,7 +225,7 @@ function withAccessibilityService(config) {
|
|||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
const tileExists = app.service.some(
|
const tileExists = app.service.some(
|
||||||
s => s.$['android:name'] === 'com.beancount.mobile.accessibility.OcrTileService'
|
s => s.$['android:name'] === FAKE_TILE_SERVICE_NAME
|
||||||
);
|
);
|
||||||
if (!tileExists) {
|
if (!tileExists) {
|
||||||
app.service.push(tileServiceNode);
|
app.service.push(tileServiceNode);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "beancount-mobile-plugin-accessibility",
|
"name": "drift-ledger-plugin-accessibility",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "app.plugin.js"
|
"main": "app.plugin.js"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.util.Log
|
|||||||
import com.facebook.react.bridge.ReactApplicationContext
|
import com.facebook.react.bridge.ReactApplicationContext
|
||||||
import com.facebook.react.bridge.WritableNativeMap
|
import com.facebook.react.bridge.WritableNativeMap
|
||||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||||
|
import com.beancount.mobile.accessibility.SelectToSpeakService
|
||||||
import com.beancount.mobile.accessibility.ReactContextHolder
|
import com.beancount.mobile.accessibility.ReactContextHolder
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +16,7 @@ import com.beancount.mobile.accessibility.ReactContextHolder
|
|||||||
*
|
*
|
||||||
* 参考 AutoAccounting 的 NotificationListenerService:
|
* 参考 AutoAccounting 的 NotificationListenerService:
|
||||||
* - 提取支付 App 通知的 title/text
|
* - 提取支付 App 通知的 title/text
|
||||||
* - 白名单过滤(仅支付类 App)
|
* - 白名单过滤(复用 SelectToSpeakService.PAYMENT_PACKAGES,单一数据源)
|
||||||
* - 关键词黑白名单(JS 层 keywordFilter 进一步过滤)
|
* - 关键词黑白名单(JS 层 keywordFilter 进一步过滤)
|
||||||
* - MD5 去重(JS 层 NotificationChannel 处理,避免原生持有状态)
|
* - MD5 去重(JS 层 NotificationChannel 处理,避免原生持有状态)
|
||||||
* - onListenerDisconnected 时 requestRebind 自动重连
|
* - onListenerDisconnected 时 requestRebind 自动重连
|
||||||
@@ -26,26 +27,14 @@ class BillingNotificationListenerService : NotificationListenerService() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "BillingNotification"
|
private const val TAG = "BillingNotification"
|
||||||
|
|
||||||
/** 支付 App 白名单(与 JS 层 DEFAULT_PAYMENT_PACKAGES 一致)。 */
|
|
||||||
private val PAYMENT_PACKAGES = setOf(
|
|
||||||
"com.eg.android.AlipayGphone",
|
|
||||||
"com.tencent.mm",
|
|
||||||
"com.unionpay",
|
|
||||||
"com.cmbchina",
|
|
||||||
"com.icbc",
|
|
||||||
"com.chinamworld.main",
|
|
||||||
"com.ccbrcb",
|
|
||||||
"com.bankcomm.Bankcomm",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||||
super.onNotificationPosted(sbn)
|
super.onNotificationPosted(sbn)
|
||||||
runCatching {
|
runCatching {
|
||||||
val packageName = sbn?.packageName?.toString() ?: return
|
val packageName = sbn?.packageName?.toString() ?: return
|
||||||
// 白名单过滤
|
// 白名单过滤(复用无障碍服务的 PAYMENT_PACKAGES)
|
||||||
if (!PAYMENT_PACKAGES.contains(packageName)) return
|
if (!SelectToSpeakService.PAYMENT_PACKAGES.contains(packageName)) return
|
||||||
|
|
||||||
val notification = sbn.notification
|
val notification = sbn.notification
|
||||||
val extras = notification.extras
|
val extras = notification.extras
|
||||||
|
|||||||
@@ -11,18 +11,41 @@ const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins'
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
function getAppId(config) {
|
||||||
|
return config.android?.package || 'com.example.driftledger';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无障碍服务伪装目标包名(必须与 accessibility plugin 保持一致)。
|
||||||
|
* 本服务的 Kotlin 源码 import 了 SelectToSpeakService/ReactContextHolder,
|
||||||
|
* 而这些类在 prebuild 时被 accessibility plugin 整体迁移到此伪装包下,
|
||||||
|
* 因此本 plugin 复制源码时必须把对 accessibility 子包的引用一并改写到此包,
|
||||||
|
* 否则会产生 "Unresolved reference" 编译错误。
|
||||||
|
*/
|
||||||
|
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
|
||||||
|
|
||||||
function withNotificationListener(config) {
|
function withNotificationListener(config) {
|
||||||
|
const appId = getAppId(config);
|
||||||
|
const PACKAGE = `${appId}.notification`;
|
||||||
|
|
||||||
// 1. 复制 Kotlin 源码到原生工程
|
// 1. 复制 Kotlin 源码到原生工程
|
||||||
config = withDangerousMod(config, [
|
config = withDangerousMod(config, [
|
||||||
'android',
|
'android',
|
||||||
async (modConfig) => {
|
async (modConfig) => {
|
||||||
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
||||||
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/notification');
|
const pkgPath = appId.replace(/\./g, '/');
|
||||||
|
const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'notification');
|
||||||
fs.mkdirSync(dest, { recursive: true });
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
const srcDir = path.join(__dirname, 'android');
|
const srcDir = path.join(__dirname, 'android');
|
||||||
for (const f of fs.readdirSync(srcDir)) {
|
for (const f of fs.readdirSync(srcDir)) {
|
||||||
if (f.endsWith('.kt')) {
|
if (f.endsWith('.kt')) {
|
||||||
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f));
|
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
|
||||||
|
// 必须先改写 accessibility 子包引用(迁移到伪装包),再做通用包名替换,
|
||||||
|
// 否则通用规则会把 com.beancount.mobile.accessibility 错误地改成 appId.accessibility。
|
||||||
|
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
|
||||||
|
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
|
||||||
|
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||||
|
fs.writeFileSync(path.join(dest, f), content, 'utf8');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return modConfig;
|
return modConfig;
|
||||||
@@ -36,9 +59,9 @@ function withNotificationListener(config) {
|
|||||||
// 1. 添加通知监听服务
|
// 1. 添加通知监听服务
|
||||||
const serviceNode = {
|
const serviceNode = {
|
||||||
$: {
|
$: {
|
||||||
'android:name': 'com.beancount.mobile.notification.BillingNotificationListenerService',
|
'android:name': `${PACKAGE}.BillingNotificationListenerService`,
|
||||||
'android:permission': 'android.permission.BIND_NOTIFICATION_LISTENER_SERVICE',
|
'android:permission': 'android.permission.BIND_NOTIFICATION_LISTENER_SERVICE',
|
||||||
'android:exported': 'false',
|
'android:exported': 'true',
|
||||||
},
|
},
|
||||||
'intent-filter': [{
|
'intent-filter': [{
|
||||||
action: [{ $: { 'android:name': 'android.service.notification.NotificationListenerService' } }],
|
action: [{ $: { 'android:name': 'android.service.notification.NotificationListenerService' } }],
|
||||||
@@ -54,7 +77,7 @@ function withNotificationListener(config) {
|
|||||||
app.service = [];
|
app.service = [];
|
||||||
}
|
}
|
||||||
const exists = app.service.some(
|
const exists = app.service.some(
|
||||||
s => s.$['android:name'] === 'com.beancount.mobile.notification.BillingNotificationListenerService'
|
s => s.$['android:name'] === `${PACKAGE}.BillingNotificationListenerService`
|
||||||
);
|
);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
app.service.push(serviceNode);
|
app.service.push(serviceNode);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "beancount-mobile-plugin-notification-listener",
|
"name": "drift-ledger-plugin-notification-listener",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "app.plugin.js"
|
"main": "app.plugin.js"
|
||||||
|
|||||||
+46
-28
@@ -1,9 +1,19 @@
|
|||||||
# PP-OCRv5 (ONNX Runtime) Config Plugin
|
# PP-OCR (ONNX Runtime) Config Plugin
|
||||||
|
|
||||||
本插件在 `expo prebuild` 时注入 PP-OCRv5 本地 OCR 原生模块(plan.md 决策 4)。
|
本插件在 `expo prebuild` 时注入 PP-OCR 本地 OCR 原生模块(plan.md 决策 4)。
|
||||||
|
|
||||||
引擎选用 **ONNX Runtime**(跨平台、微软官方、Windows 友好),替代原 NCNN 方案。
|
引擎选用 **ONNX Runtime**(跨平台、微软官方、Windows 友好),替代原 NCNN 方案。
|
||||||
|
|
||||||
|
## 当前版本
|
||||||
|
|
||||||
|
**PP-OCRv6 small**(det 2.5M 参数 + rec 5.3M 参数,精度显著优于 v5 mobile)
|
||||||
|
|
||||||
|
| 指标 | PP-OCRv5 mobile | PP-OCRv6 small |
|
||||||
|
| ------------- | --------------- | -------------- |
|
||||||
|
| det Hmean | 75.2% | 84.1% |
|
||||||
|
| rec W-Avg | 73.7% | 81.3% |
|
||||||
|
| rec ONNX 大小 | ~17 MB | ~21 MB |
|
||||||
|
|
||||||
## 文件结构
|
## 文件结构
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -13,48 +23,46 @@ plugins/ppocr/
|
|||||||
│ ├── OcrModule.kt # React Native Bridge:ONNX Runtime 推理 + det/rec 前后处理
|
│ ├── OcrModule.kt # React Native Bridge:ONNX Runtime 推理 + det/rec 前后处理
|
||||||
│ └── OcrPackage.kt # RN Package 注册(注入到 MainApplication.getPackages)
|
│ └── OcrPackage.kt # RN Package 注册(注入到 MainApplication.getPackages)
|
||||||
└── assets/ # ONNX 模型 + 字典(需自行下载放置)
|
└── assets/ # ONNX 模型 + 字典(需自行下载放置)
|
||||||
├── ppocrv5_det.onnx # 文本检测模型
|
├── ppocrv6_det.onnx # 文本检测模型(PP-OCRv6 small)
|
||||||
├── ppocrv5_rec.onnx # 文本识别模型(多语言,输出 18385 维)
|
├── ppocrv6_rec.onnx # 文本识别模型(PP-OCRv6 small,多语言)
|
||||||
└── ppocrv5_dict.txt # PP-OCRv5 多语言字典(18383 字符,CTC 解码用)
|
└── ppocrv6_dict.txt # PP-OCRv6 多语言字典(CTC 解码用)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 模型获取(一键下载)
|
## 模型获取(一键下载)
|
||||||
|
|
||||||
社区已转好的 ONNX 版本(来自官方 Paddle 权重,无质量损失):
|
PP-OCRv6 官方 ONNX 模型来自 [PaddlePaddle/PP-OCRv6 系列](https://huggingface.co/collections/PaddlePaddle/pp-ocrv6):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在项目根目录执行
|
# 在项目根目录执行
|
||||||
mkdir -p plugins/ppocr/assets
|
mkdir -p plugins/ppocr/assets
|
||||||
cd plugins/ppocr/assets
|
cd plugins/ppocr/assets
|
||||||
|
|
||||||
# det 模型(4.8 MB)
|
# det 模型(PP-OCRv6 small)
|
||||||
curl -L -o ppocrv5_det.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_det.onnx
|
curl -L -o ppocrv6_det.onnx \
|
||||||
|
https://huggingface.co/PaddlePaddle/PP-OCRv6_small_det_onnx/resolve/main/inference.onnx
|
||||||
|
|
||||||
# rec 模型(16.6 MB)
|
# rec 模型(PP-OCRv6 small)
|
||||||
curl -L -o ppocrv5_rec.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_rec.onnx
|
curl -L -o ppocrv6_rec.onnx \
|
||||||
|
https://huggingface.co/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/main/inference.onnx
|
||||||
|
|
||||||
# PP-OCRv5 多语言字典(74 KB,必须与上面的 rec 模型配套)
|
# PP-OCRv6 多语言字典(必须与上面的 rec 模型配套)
|
||||||
curl -L -o ppocrv5_dict.txt https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv5_dict.txt
|
curl -L -o ppocrv6_dict.txt \
|
||||||
|
https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv6_dict.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
或用 HuggingFace CLI(首次下载原生模型再转 ONNX 的方式,参见历史 git log)。
|
> ⚠️ **字典必须与 rec 模型配套**:v6 字典字符集与 v5 完全不同,混用会导致 CTC 解码乱码。
|
||||||
|
> 若之前使用过 v5 模型,务必删除旧文件(`ppocrv5_det.onnx`、`ppocrv5_rec.onnx`、`ppocrv5_dict.txt`)。
|
||||||
> ⚠️ **字典必须与 rec 模型配套**:ppocrv5_rec.onnx 输出 18385 维(= 18383 字符 + blank + 特殊位),
|
|
||||||
> 必须使用 `ppocrv5_dict.txt`(18383 行)。若错用旧版 `ppocr_keys_v1.txt`(仅 6623 行),
|
|
||||||
> CTC 解码会把真实字符的高索引全部丢弃,只输出形如 `'消'青'露'仰'` 的单引号穿插单字符乱码。
|
|
||||||
|
|
||||||
> 来源说明:[ilaylow/PP_OCRv5_mobile_onnx](https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx) 是社区维护的 PP-OCRv5 mobile ONNX 镜像,基于官方 [PaddlePaddle/PP-OCRv5_mobile_det](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_det) 与 [_rec](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_rec) 转换而来。
|
|
||||||
|
|
||||||
## 性能配置(参考 AutoAccounting OcrProcessor.kt)
|
## 性能配置(参考 AutoAccounting OcrProcessor.kt)
|
||||||
|
|
||||||
| 优化项 | 配置 |
|
| 优化项 | 配置 |
|
||||||
|--------|------|
|
| -------- | -------------------------------------- |
|
||||||
| 引擎 | ONNX Runtime Android 1.20.1 |
|
| 引擎 | ONNX Runtime Android |
|
||||||
| 执行器 | CPU(兼容性最稳,部分设备 GPU 会崩溃) |
|
| 执行器 | CPU(兼容性最稳,部分设备 GPU 会崩溃) |
|
||||||
| 线程 | intraOp=2 / interOp=2 |
|
| 线程 | intraOp=2 / interOp=2 |
|
||||||
| det 图像 | 最大边 960px,短边压缩 720px |
|
| det 图像 | 最大边 960px,短边压缩 720px |
|
||||||
| rec 图像 | 固定高度 48px |
|
| rec 图像 | 固定高度 48px |
|
||||||
| ABI | arm64-v8a(主流设备) |
|
| ABI | arm64-v8a(主流设备) |
|
||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
|
|
||||||
@@ -81,4 +89,14 @@ JS 层通过 `src/services/ocrBridge.ts` 的 `NativeOcrBridge` 调用,桥接
|
|||||||
|
|
||||||
真机构建步骤:放置模型文件 → `npx expo prebuild --platform android`(Config Plugin 会把 Kotlin 源码与 `assets/` 下的模型/字典复制进 `android/`)→ `npx expo run:android`。
|
真机构建步骤:放置模型文件 → `npx expo prebuild --platform android`(Config Plugin 会把 Kotlin 源码与 `assets/` 下的模型/字典复制进 `android/`)→ `npx expo run:android`。
|
||||||
|
|
||||||
> 若之前已 prebuild 过且更换过字典/模型文件,务必重新执行 `npx expo prebuild --clean`,否则 `android/app/src/main/assets/` 下可能残留旧字典(如 `ppocr_keys_v1.txt`),导致新代码找不到配套字典。
|
> 若之前已 prebuild 过且更换过字典/模型文件,务必重新执行 `npx expo prebuild --clean`,否则 `android/app/src/main/assets/` 下可能残留旧模型/字典。
|
||||||
|
|
||||||
|
## v5 → v6 迁移说明
|
||||||
|
|
||||||
|
若从 PP-OCRv5 升级,需完成以下步骤:
|
||||||
|
|
||||||
|
1. **下载新模型**:按上述「模型获取」章节下载 v6 模型和字典
|
||||||
|
2. **删除旧文件**:移除 `ppocrv5_det.onnx`、`ppocrv5_rec.onnx`、`ppocrv5_dict.txt`
|
||||||
|
3. **代码已自动适配**:`OcrModule.kt` 中的常量已更新为 v6 文件名
|
||||||
|
4. **重新 prebuild**:`npx expo prebuild --clean` 确保旧资产被清理
|
||||||
|
5. **验证 tensor 名称**:v6 ONNX 模型的输入 tensor 名可能与 v5 不同,若推理报错需用 Netron 检查并调整 `OcrModule.kt` 中的 `detInputs`/`recInputs` map key
|
||||||
|
|||||||
+240
-132
@@ -24,16 +24,18 @@ import kotlinx.coroutines.launch
|
|||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.nio.FloatBuffer
|
import java.nio.FloatBuffer
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PP-OCRv5 (ONNX Runtime) React Native Bridge(plan.md「3.4 Layer 2」+「决策 4 Config Plugin」)。
|
* PP-OCRv6 (ONNX Runtime) React Native Bridge(plan.md「3.4 Layer 2」+「决策 4 Config Plugin」)。
|
||||||
*
|
*
|
||||||
* 引擎:ONNX Runtime(跨平台、微软官方、Windows 友好),替代 NCNN 路线。
|
* 引擎:ONNX Runtime(跨平台、微软官方、Windows 友好),替代 NCNN 路线。
|
||||||
* 模型:ppocrv5_det.onnx + ppocrv5_rec.onnx(从 ilaylow/PP_OCRv5_mobile_onnx 下载)。
|
* 模型:ppocrv6_det.onnx + ppocrv6_rec.onnx(PP-OCRv6 small,从 PaddlePaddle 官方 HuggingFace 下载)。
|
||||||
* 字典:ppocrv5_dict.txt(PP-OCRv5 多语言字典,18383 字符;rec 模型 18385 维输出 = 字典 + blank + 特殊位)。
|
* 字典:ppocrv6_dict.txt(PP-OCRv6 多语言字典,18708 字符;rec 模型 18710 维输出 = 字典 + blank + 特殊位)。
|
||||||
*
|
*
|
||||||
* 流水线:
|
* 流水线:
|
||||||
* 1. det(文本检测):bitmap → DB 后处理得到文本框
|
* 1. det(文本检测):bitmap → DB 后处理得到文本框
|
||||||
@@ -62,6 +64,11 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
@Volatile private var initialized = false
|
@Volatile private var initialized = false
|
||||||
@Volatile private var initFailed = false
|
@Volatile private var initFailed = false
|
||||||
|
|
||||||
|
/** 模型文件目录(filesystem 绝对路径)。非空时从该目录加载模型,否则回退到 assets。 */
|
||||||
|
@Volatile private var modelDir: String? = null
|
||||||
|
/** 初始化完成信号,ensureReady 可等待异步 initEngine 完成(最多等 15 秒)。 */
|
||||||
|
@Volatile private var initLatch = CountDownLatch(1)
|
||||||
|
|
||||||
override fun getName(): String = OCR_MODULE_NAME
|
override fun getName(): String = OCR_MODULE_NAME
|
||||||
|
|
||||||
override fun initialize() {
|
override fun initialize() {
|
||||||
@@ -70,7 +77,7 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
scope.launch { initEngine() }
|
scope.launch { initEngine() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 assets 加载 det/rec ONNX 模型与字典。 */
|
/** 从 assets 或 filesystem 加载 det/rec ONNX 模型与字典。 */
|
||||||
private fun initEngine() {
|
private fun initEngine() {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
try {
|
try {
|
||||||
@@ -83,22 +90,47 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
// 移动端关闭内存优化里的图优化级别过高(部分模型会崩)
|
// 移动端关闭内存优化里的图优化级别过高(部分模型会崩)
|
||||||
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT)
|
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT)
|
||||||
}
|
}
|
||||||
val 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
|
||||||
ortEnv = env
|
ortEnv = env
|
||||||
dictionary = dict
|
dictionary = dict
|
||||||
initialized = true
|
initialized = true
|
||||||
Log.i(OCR_MODULE_NAME, "PP-OCRv5 ONNX 模型加载成功(det+rec, dict=${dict.size})")
|
initLatch.countDown()
|
||||||
|
Log.i(OCR_MODULE_NAME, "PP-OCRv6 ONNX 模型加载成功(det+rec, dict=${dict.size})")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
initFailed = true
|
initFailed = true
|
||||||
|
initLatch.countDown()
|
||||||
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
|
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
|
||||||
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 +140,13 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
private val recEnv: OrtEnvironment? get() = ortEnv
|
private val recEnv: OrtEnvironment? get() = ortEnv
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载 ppocrv5_dict.txt 字典。
|
* 从 assets 加载 ppocrv6_dict.txt 字典。
|
||||||
*
|
*
|
||||||
* PaddleOCR CTC 约定:模型输出 logits 的 index 0 是 blank,字符从 index 1 开始;
|
* PaddleOCR CTC 约定:模型输出 logits 的 index 0 是 blank,字符从 index 1 开始;
|
||||||
* 字典条目 dictionary[i] 对应模型输出 index i+1。解码时 dictIdx = argmaxIdx - 1。
|
* 字典条目 dictionary[i] 对应模型输出 index i+1。解码时 dictIdx = argmaxIdx - 1。
|
||||||
* 字典本身不含 blank,运行时固定用 index 0 作 blank(见 ctcGreedyDecode)。
|
* 字典本身不含 blank,运行时固定用 index 0 作 blank(见 ctcGreedyDecode)。
|
||||||
*/
|
*/
|
||||||
private fun loadDictionary(): List<String> {
|
private fun loadDictionaryFromAssets(): List<String> {
|
||||||
val words = mutableListOf<String>()
|
val words = mutableListOf<String>()
|
||||||
context.assets.open(ASSET_DICT).use { stream ->
|
context.assets.open(ASSET_DICT).use { stream ->
|
||||||
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines ->
|
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines ->
|
||||||
@@ -127,6 +159,17 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
return words
|
return words
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从 filesystem 路径加载字典文件。 */
|
||||||
|
private fun loadDictionaryFromFile(path: String): List<String> {
|
||||||
|
val words = mutableListOf<String>()
|
||||||
|
java.io.File(path).bufferedReader(Charsets.UTF_8).useLines { lines ->
|
||||||
|
lines.forEach { line ->
|
||||||
|
words.add(line.trimEnd('\r', '\n'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return words
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 识别图片文本。
|
* 识别图片文本。
|
||||||
* @param imageBase64 base64 编码的图片(JPEG/PNG)
|
* @param imageBase64 base64 编码的图片(JPEG/PNG)
|
||||||
@@ -144,7 +187,7 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
promise.reject("DECODE_FAILED", "base64 解码失败")
|
promise.reject("DECODE_FAILED", "base64 解码失败")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
|
scaled = capLongEdge(bitmap, CAP_LONG_EDGE)
|
||||||
val blocks = runInference(scaled)
|
val blocks = runInference(scaled)
|
||||||
val text = blocks.joinToString("\n") { it.text }
|
val text = blocks.joinToString("\n") { it.text }
|
||||||
promise.resolve(text)
|
promise.resolve(text)
|
||||||
@@ -176,7 +219,7 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
promise.reject("DECODE_FAILED", "base64 解码失败")
|
promise.reject("DECODE_FAILED", "base64 解码失败")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
|
scaled = capLongEdge(bitmap, CAP_LONG_EDGE)
|
||||||
val blocks = runInference(scaled)
|
val blocks = runInference(scaled)
|
||||||
// 序列化为 RN WritableArray
|
// 序列化为 RN WritableArray
|
||||||
val arr = WritableNativeArray()
|
val arr = WritableNativeArray()
|
||||||
@@ -209,15 +252,41 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
promise.resolve(initialized)
|
promise.resolve(initialized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置模型文件目录(绝对路径)。若引擎已初始化则释放并重新加载。 */
|
||||||
|
@ReactMethod
|
||||||
|
fun setModelDir(dir: String, promise: Promise) {
|
||||||
|
// 修复:expo-file-system 返回 file:// URI,需转为文件系统绝对路径
|
||||||
|
val newDir = dir.removePrefix("file://")
|
||||||
|
// 修复:目录未变且引擎已就绪时跳过重新加载,避免冗余 release 导致竞态
|
||||||
|
if (newDir == modelDir && initialized) {
|
||||||
|
promise.resolve(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelDir = newDir
|
||||||
|
// 修复:无论之前是成功还是失败,设置新目录后都应重新加载模型
|
||||||
|
if (initialized || initFailed) {
|
||||||
|
release()
|
||||||
|
initFailed = false
|
||||||
|
initLatch = CountDownLatch(1)
|
||||||
|
scope.launch { initEngine() }
|
||||||
|
}
|
||||||
|
promise.resolve(true)
|
||||||
|
}
|
||||||
|
|
||||||
// ============== 推理流水线 ==============
|
// ============== 推理流水线 ==============
|
||||||
|
|
||||||
private fun ensureReady() {
|
private fun ensureReady() {
|
||||||
if (!initialized && !initFailed) initEngine()
|
if (!initialized && !initFailed) initEngine()
|
||||||
|
// 修复:异步 initEngine 进行中时等待完成(最多 15 秒),而非立即抛异常
|
||||||
|
if (!initialized && !initFailed) {
|
||||||
|
initLatch.await(15, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
if (!initialized) throw IllegalStateException("OCR 引擎未就绪(模型未加载,${if (initFailed) "初始化失败" else "加载中"})")
|
if (!initialized) throw IllegalStateException("OCR 引擎未就绪(模型未加载,${if (initFailed) "初始化失败" else "加载中"})")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 完整推理:det 检测文本框 → 对每个框 rec 识别 → 返回带坐标的文本块。 */
|
/** 完整推理:det 检测文本框 → 对每个框 rec 识别 → 返回带坐标的文本块。 */
|
||||||
private fun runInference(bitmap: Bitmap): List<OcrBlock> {
|
private fun runInference(bitmap: Bitmap): List<OcrBlock> {
|
||||||
|
val totalStartTime = System.currentTimeMillis()
|
||||||
Log.i(OCR_MODULE_NAME, "runInference 开始: bitmap 尺寸 = ${bitmap.width}x${bitmap.height}")
|
Log.i(OCR_MODULE_NAME, "runInference 开始: bitmap 尺寸 = ${bitmap.width}x${bitmap.height}")
|
||||||
val det = detSession ?: run {
|
val det = detSession ?: run {
|
||||||
Log.e(OCR_MODULE_NAME, "detSession 为空,放弃推理")
|
Log.e(OCR_MODULE_NAME, "detSession 为空,放弃推理")
|
||||||
@@ -233,8 +302,14 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
var detOutputs: OrtSession.Result? = null
|
var detOutputs: OrtSession.Result? = null
|
||||||
val results = mutableListOf<OcrBlock>()
|
val results = mutableListOf<OcrBlock>()
|
||||||
|
|
||||||
|
var detTime = 0L
|
||||||
|
var postTime = 0L
|
||||||
|
var recTime = 0L
|
||||||
|
var boxCount = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ---- 1. 文本检测(DB)----
|
// ---- 1. 文本检测(DB)----
|
||||||
|
val detStartTime = System.currentTimeMillis()
|
||||||
resized = resizeForDet(bitmap, DET_LIMIT_MAX_SIDE)
|
resized = resizeForDet(bitmap, DET_LIMIT_MAX_SIDE)
|
||||||
Log.i(OCR_MODULE_NAME, "det 图像缩放后尺寸 = ${resized.width}x${resized.height}")
|
Log.i(OCR_MODULE_NAME, "det 图像缩放后尺寸 = ${resized.width}x${resized.height}")
|
||||||
val ratioX = bitmap.width.toFloat() / resized.width
|
val ratioX = bitmap.width.toFloat() / resized.width
|
||||||
@@ -243,18 +318,30 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
val detInput = preprocessDet(resized)
|
val detInput = preprocessDet(resized)
|
||||||
detInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(detInput.data), longArrayOf(1L, 3L, detInput.h.toLong(), detInput.w.toLong()))
|
detInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(detInput.data), longArrayOf(1L, 3L, detInput.h.toLong(), detInput.w.toLong()))
|
||||||
val detInputs = mapOf("x" to detInputTensor)
|
val detInputs = mapOf("x" to detInputTensor)
|
||||||
|
|
||||||
|
val detRunStart = System.currentTimeMillis()
|
||||||
detOutputs = det.run(detInputs)
|
detOutputs = det.run(detInputs)
|
||||||
|
detTime = System.currentTimeMillis() - detRunStart
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val detProb = (detOutputs[0].value as Array<Array<Array<FloatArray>>>)[0][0] // [H,W]
|
val detProb = (detOutputs[0].value as Array<Array<Array<FloatArray>>>)[0][0] // [H,W]
|
||||||
Log.i(OCR_MODULE_NAME, "det 推理完成,概率图尺寸 = ${detProb.size}x${detProb[0].size}")
|
Log.i(OCR_MODULE_NAME, "det 推理完成 (耗时: ${detTime}ms),概率图尺寸 = ${detProb.size}x${detProb[0].size}")
|
||||||
|
|
||||||
// DB 后处理:threshold → 轮廓 → 最小外接矩形
|
// DB 后处理:threshold → 轮廓 → 最小外接矩形
|
||||||
|
val postStart = System.currentTimeMillis()
|
||||||
val boxes = dbPostprocess(detProb, detInput.h, detInput.w, ratioX, ratioY)
|
val boxes = dbPostprocess(detProb, detInput.h, detInput.w, ratioX, ratioY)
|
||||||
Log.i(OCR_MODULE_NAME, "dbPostprocess 后处理完成,检测到文本框数量 = ${boxes.size}")
|
postTime = System.currentTimeMillis() - postStart
|
||||||
|
boxCount = boxes.size
|
||||||
|
Log.i(OCR_MODULE_NAME, "dbPostprocess 后处理完成 (耗时: ${postTime}ms),检测到文本框数量 = ${boxCount}")
|
||||||
|
|
||||||
if (boxes.isEmpty()) return emptyList()
|
if (boxes.isEmpty()) {
|
||||||
|
val totalTime = System.currentTimeMillis() - totalStartTime
|
||||||
|
Log.i(OCR_MODULE_NAME, "PP-OCRv6 推理完成(无文本): 总耗时 = ${totalTime}ms (det模型推理 = ${detTime}ms, det后处理 = ${postTime}ms)")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 2. 文本识别(CRNN+CTC)----
|
// ---- 2. 文本识别(CRNN+CTC)----
|
||||||
|
val recStart = System.currentTimeMillis()
|
||||||
for ((idx, box) in boxes.withIndex()) {
|
for ((idx, box) in boxes.withIndex()) {
|
||||||
var crop: Bitmap? = null
|
var crop: Bitmap? = null
|
||||||
var recInputTensor: OnnxTensor? = null
|
var recInputTensor: OnnxTensor? = null
|
||||||
@@ -292,6 +379,7 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
recOutputs?.close()
|
recOutputs?.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
recTime = System.currentTimeMillis() - recStart
|
||||||
} finally {
|
} finally {
|
||||||
if (resized !== bitmap) {
|
if (resized !== bitmap) {
|
||||||
resized?.recycle()
|
resized?.recycle()
|
||||||
@@ -300,6 +388,12 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
detOutputs?.close()
|
detOutputs?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val totalTime = System.currentTimeMillis() - totalStartTime
|
||||||
|
Log.i(
|
||||||
|
OCR_MODULE_NAME,
|
||||||
|
"PP-OCRv6 ONNX 推理整体完成: 总耗时 = ${totalTime}ms (det模型推理 = ${detTime}ms, det后处理 = ${postTime}ms, rec文本识别 = ${recTime}ms, 识别文本框数 = ${results.size}/${boxCount})"
|
||||||
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,8 +415,8 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
for (y in 0 until h) {
|
for (y in 0 until h) {
|
||||||
for (x in 0 until w) {
|
for (x in 0 until w) {
|
||||||
val px = pixels[y * w + x]
|
val px = pixels[y * w + x]
|
||||||
// 提取 R/G/B(c=0→R, 1→G, 2→B)
|
// 对齐官方训练通道序 BGR(c=0→B, 1→G, 2→R);mean/std 数值按 BGR 顺序配套
|
||||||
val channelVal = (px shr (16 - 8 * c)) and 0xFF
|
val channelVal = (px shr (8 * c)) and 0xFF
|
||||||
data[c * w * h + y * w + x] = (channelVal / 255.0f - mean) / std
|
data[c * w * h + y * w + x] = (channelVal / 255.0f - mean) / std
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,9 +428,10 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
private fun preprocessRec(bmp: Bitmap): TensorData {
|
private fun preprocessRec(bmp: Bitmap): TensorData {
|
||||||
var w = bmp.width
|
var w = bmp.width
|
||||||
val h = bmp.height
|
val h = bmp.height
|
||||||
// resize 到高度 48,宽度等比缩放
|
// resize 到高度 48,宽度等比缩放(ceil 取整对齐官方,避免右缘字符因 floor 被裁)
|
||||||
var resizedW = (w.toFloat() / h * REC_IMAGE_HEIGHT).toInt()
|
// 注:java.lang.Math.ceil 仅有 double 重载,故用 toDouble()(Math.round 有 float 重载所以无需)
|
||||||
// 宽度上限,避免单行过长爆显存
|
var resizedW = Math.ceil(w.toDouble() / h * REC_IMAGE_HEIGHT).toInt()
|
||||||
|
// 宽度上限,避免单行过长爆显存(官方 cap=3200,移动端折中 REC_MAX_WIDTH=1280)
|
||||||
resizedW = min(resizedW, REC_MAX_WIDTH)
|
resizedW = min(resizedW, REC_MAX_WIDTH)
|
||||||
resizedW = max(resizedW, 1)
|
resizedW = max(resizedW, 1)
|
||||||
val resized = if (resizedW == w && h == REC_IMAGE_HEIGHT) bmp
|
val resized = if (resizedW == w && h == REC_IMAGE_HEIGHT) bmp
|
||||||
@@ -362,9 +457,9 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
return TensorData(data, w, REC_IMAGE_HEIGHT)
|
return TensorData(data, w, REC_IMAGE_HEIGHT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== DB 后处理(简化版) ==============
|
// ============== DB 后处理(连通域法,对齐 PaddleOCR 官方) ==============
|
||||||
// 参考 PaddleOCR db_postprocess:sigmoid → threshold → 连通域 → 最小外接矩形
|
// sigmoid → 阈值二值化 → 4-连通域标记 → 每域 bbox 按官方 unclip 公式外扩 → box_score_fast 过滤。
|
||||||
// 这里用轻量实现:逐像素阈值化后用投影法估框,对常见单/多行账单足够。
|
// 取代旧的「水平/垂直投影法」:投影法会把基线孤立小数点切到行外导致金额丢点(¥143.97→¥14397)。
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DB 后处理:sigmoid + 阈值 0.3 → 二值图 → 连通域外接矩形。
|
* DB 后处理:sigmoid + 阈值 0.3 → 二值图 → 连通域外接矩形。
|
||||||
@@ -389,6 +484,7 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
val endY = (h * 0.92).toInt()
|
val endY = (h * 0.92).toInt()
|
||||||
|
|
||||||
val binMask = Array(h) { IntArray(w) }
|
val binMask = Array(h) { IntArray(w) }
|
||||||
|
val sigMap = Array(h) { FloatArray(w) } // 概率图(sigmoid 后),供连通域 box_score_fast 取文本像素均值
|
||||||
var activeCountTotal = 0
|
var activeCountTotal = 0
|
||||||
for (y in 0 until h) {
|
for (y in 0 until h) {
|
||||||
for (x in 0 until w) {
|
for (x in 0 until w) {
|
||||||
@@ -402,6 +498,7 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
} else {
|
} else {
|
||||||
raw
|
raw
|
||||||
}
|
}
|
||||||
|
sigMap[y][x] = sig
|
||||||
val isActive = if (sig > DET_THRESH) 1 else 0
|
val isActive = if (sig > DET_THRESH) 1 else 0
|
||||||
binMask[y][x] = isActive
|
binMask[y][x] = isActive
|
||||||
if (isActive == 1) activeCountTotal++
|
if (isActive == 1) activeCountTotal++
|
||||||
@@ -426,79 +523,76 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
}
|
}
|
||||||
Log.i(OCR_MODULE_NAME, "垂直线噪清理完成: 清理了 $clearedColsCount / $w 列")
|
Log.i(OCR_MODULE_NAME, "垂直线噪清理完成: 清理了 $clearedColsCount / $w 列")
|
||||||
|
|
||||||
// 水平投影:按行找文本行
|
// 连通域标记(4-连通,迭代 BFS 防栈溢出):取代旧的「水平投影切行 + 垂直投影切列」。
|
||||||
val rowHits = IntArray(h)
|
// 投影法会把基线/顶线上的孤立标点(小数点等,该行水平投影 < w/20)切到行外,
|
||||||
|
// 导致 crop 不含标点、rec 漏识(金额 ¥143.97 → ¥14397 的根因,已用官方同模型对照坐实)。
|
||||||
|
// 连通域天然把「数字 + 基线点」归为同一域,根治丢点;并正确处理断裂字符与多栏版面。
|
||||||
|
val labels = Array(h) { IntArray(w) }
|
||||||
|
var nComp = 0
|
||||||
|
val compX0 = mutableListOf<Int>()
|
||||||
|
val compY0 = mutableListOf<Int>()
|
||||||
|
val compX1 = mutableListOf<Int>()
|
||||||
|
val compY1 = mutableListOf<Int>()
|
||||||
|
val compArea = mutableListOf<Int>()
|
||||||
|
val compSum = mutableListOf<Float>() // 域内文本像素 sig 累加,供 box_score_fast 取均值
|
||||||
|
val stack = ArrayDeque<IntArray>()
|
||||||
for (y in 0 until h) {
|
for (y in 0 until h) {
|
||||||
var sum = 0
|
for (x in 0 until w) {
|
||||||
for (x in 0 until w) sum += binMask[y][x]
|
if (binMask[y][x] == 1 && labels[y][x] == 0) {
|
||||||
rowHits[y] = sum
|
nComp++
|
||||||
}
|
val id = nComp
|
||||||
val minRowWidth = max(1, w / 20) // 一行至少要有这么多像素才算文本
|
var x0 = x; var y0 = y; var x1 = x; var y1 = y
|
||||||
val rowRanges = mutableListOf<IntArray>()
|
var area = 0; var sum = 0f
|
||||||
var inLine = false
|
stack.clear()
|
||||||
var lineStart = 0
|
stack.addLast(intArrayOf(x, y))
|
||||||
for (y in 0 until h) {
|
labels[y][x] = id
|
||||||
val isText = rowHits[y] >= minRowWidth
|
while (stack.isNotEmpty()) {
|
||||||
if (isText && !inLine) { inLine = true; lineStart = y }
|
val p = stack.removeLast()
|
||||||
else if (!isText && inLine) {
|
val px = p[0]; val py = p[1]
|
||||||
rowRanges.add(intArrayOf(lineStart, y - 1))
|
area++
|
||||||
inLine = false
|
sum += sigMap[py][px]
|
||||||
|
if (px < x0) x0 = px; if (px > x1) x1 = px
|
||||||
|
if (py < y0) y0 = py; if (py > y1) y1 = py
|
||||||
|
if (px > 0 && binMask[py][px - 1] == 1 && labels[py][px - 1] == 0) { labels[py][px - 1] = id; stack.addLast(intArrayOf(px - 1, py)) }
|
||||||
|
if (px < w - 1 && binMask[py][px + 1] == 1 && labels[py][px + 1] == 0) { labels[py][px + 1] = id; stack.addLast(intArrayOf(px + 1, py)) }
|
||||||
|
if (py > 0 && binMask[py - 1][px] == 1 && labels[py - 1][px] == 0) { labels[py - 1][px] = id; stack.addLast(intArrayOf(px, py - 1)) }
|
||||||
|
if (py < h - 1 && binMask[py + 1][px] == 1 && labels[py + 1][px] == 0) { labels[py + 1][px] = id; stack.addLast(intArrayOf(px, py + 1)) }
|
||||||
|
}
|
||||||
|
compX0.add(x0); compY0.add(y0); compX1.add(x1); compY1.add(y1)
|
||||||
|
compArea.add(area); compSum.add(sum)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (inLine) rowRanges.add(intArrayOf(lineStart, h - 1))
|
Log.i(OCR_MODULE_NAME, "dbPostprocess 连通域标记完成,连通域数 = $nComp")
|
||||||
Log.i(OCR_MODULE_NAME, "dbPostprocess 水平分割完成,找到行Ranges数 = ${rowRanges.size}")
|
|
||||||
|
|
||||||
val boxes = mutableListOf<List<FloatArray>>()
|
val boxes = mutableListOf<List<FloatArray>>()
|
||||||
// 对每行做垂直投影切列(账单每行通常是连续一段或多段)
|
var dropSmall = 0; var dropScore = 0
|
||||||
for ((y0, y1) in rowRanges.map { it[0] to it[1] }) {
|
for (i in 0 until nComp) {
|
||||||
val colHits = IntArray(w)
|
val bx0 = compX0[i]; val by0 = compY0[i]; val bx1 = compX1[i]; val by1 = compY1[i]
|
||||||
for (x in 0 until w) {
|
val bw = bx1 - bx0 + 1; val bh = by1 - by0 + 1
|
||||||
var sum = 0
|
val area = compArea[i]
|
||||||
for (y in y0..y1) sum += binMask[y][x]
|
val perim = 2 * (bw + bh)
|
||||||
colHits[x] = sum
|
// 官方 unclip 公式:distance = 连通域面积 × 比例 / 周长(水平文本下≈各向同性外扩,把基线标点纳入框)
|
||||||
}
|
var d = if (perim > 0) area * UNCLIP_RATIO / perim else 0f
|
||||||
val minColHeight = max(1, (y1 - y0 + 1) / 12)
|
if (d < 1f) d = 1f
|
||||||
var inSeg = false
|
val nx0 = max(0, Math.round(bx0 - d))
|
||||||
var segStart = 0
|
val ny0 = max(0, Math.round(by0 - d))
|
||||||
var segs = mutableListOf<IntArray>()
|
val nx1 = min(w - 1, Math.round(bx1 + d))
|
||||||
for (x in 0 until w) {
|
val ny1 = min(h - 1, Math.round(by1 + d))
|
||||||
val isText = colHits[x] >= minColHeight
|
if ((nx1 - nx0 + 1) < MIN_SIZE || (ny1 - ny0 + 1) < MIN_SIZE) { dropSmall++; continue }
|
||||||
if (isText && !inSeg) { inSeg = true; segStart = x }
|
// box_score_fast:域内文本像素 prob 均值(非整 bbox 均值,否则被背景稀释而误杀)
|
||||||
else if (!isText && inSeg) {
|
val score = compSum[i] / area
|
||||||
// 合并间隔很近的段
|
if (score < BOX_THRESH) { dropScore++; continue }
|
||||||
if (segs.isNotEmpty() && segStart - segs.last()[1] < DET_MERGE_GAP) {
|
val fx0 = nx0 * ratioX; val fy0 = ny0 * ratioY
|
||||||
segs.last()[1] = x - 1
|
val fx1 = nx1 * ratioX; val fy1 = ny1 * ratioY
|
||||||
} else {
|
boxes.add(listOf(
|
||||||
segs.add(intArrayOf(segStart, x - 1))
|
floatArrayOf(fx0, fy0),
|
||||||
}
|
floatArrayOf(fx1, fy0),
|
||||||
inSeg = false
|
floatArrayOf(fx1, fy1),
|
||||||
}
|
floatArrayOf(fx0, fy1),
|
||||||
}
|
))
|
||||||
if (inSeg) {
|
|
||||||
if (segs.isNotEmpty() && (w - 1) - segs.last()[1] < DET_MERGE_GAP) {
|
|
||||||
segs.last()[1] = w - 1
|
|
||||||
} else {
|
|
||||||
segs.add(intArrayOf(segStart, w - 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for ((x0, x1) in segs.map { it[0] to it[1] }) {
|
|
||||||
// 过滤过小的框
|
|
||||||
val boxW = x1 - x0 + 1
|
|
||||||
val boxH = y1 - y0 + 1
|
|
||||||
if (boxW < 4 || boxH < 2) continue
|
|
||||||
// 映射回原图坐标(4 个角点)
|
|
||||||
val fx0 = x0 * ratioX
|
|
||||||
val fx1 = x1 * ratioX
|
|
||||||
val fy0 = y0 * ratioY
|
|
||||||
val fy1 = y1 * ratioY
|
|
||||||
boxes.add(listOf(
|
|
||||||
floatArrayOf(fx0, fy0),
|
|
||||||
floatArrayOf(fx1, fy0),
|
|
||||||
floatArrayOf(fx1, fy1),
|
|
||||||
floatArrayOf(fx0, fy1),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Log.i(OCR_MODULE_NAME, "dbPostprocess 取框完成: 扩后min_size丢=$dropSmall, box_score丢=$dropScore, 保留=${boxes.size}")
|
||||||
return boxes
|
return boxes
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,8 +603,9 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
*
|
*
|
||||||
* PaddleOCR 约定:logits 的 index 0 固定是 blank,字符从 index 1 起,
|
* PaddleOCR 约定:logits 的 index 0 固定是 blank,字符从 index 1 起,
|
||||||
* dictionary[i] 对应模型输出 index i+1。因此 dictIdx = argmaxIdx - 1。
|
* dictionary[i] 对应模型输出 index i+1。因此 dictIdx = argmaxIdx - 1。
|
||||||
* 已用 onnxruntime 实证:argmax 序列中 0 占多数(即 blank),真实字符索引
|
*
|
||||||
* (如 90→'支')按 idx-1 映射到 dictionary 即可正确还原中文。
|
* PP-OCRv6 模型输出已经是概率分布(值域 [0,1]),无需额外 softmax。
|
||||||
|
* 直接取 argmax 对应的值作为该时间步的置信度。
|
||||||
*/
|
*/
|
||||||
private fun ctcGreedyDecode(logits: Array<FloatArray>): Pair<String, Float> {
|
private fun ctcGreedyDecode(logits: Array<FloatArray>): Pair<String, Float> {
|
||||||
if (logits.isEmpty()) return "" to 0f
|
if (logits.isEmpty()) return "" to 0f
|
||||||
@@ -527,16 +622,12 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
for (i in 1 until numClasses) {
|
for (i in 1 until numClasses) {
|
||||||
if (logits[t][i] > maxVal) { maxVal = logits[t][i]; maxIdx = i }
|
if (logits[t][i] > maxVal) { maxVal = logits[t][i]; maxIdx = i }
|
||||||
}
|
}
|
||||||
// softmax 概率(用于置信度统计)
|
|
||||||
var expSum = 0.0
|
|
||||||
for (i in 0 until numClasses) expSum += Math.exp(logits[t][i].toDouble())
|
|
||||||
val prob = Math.exp(maxVal.toDouble()) / expSum
|
|
||||||
|
|
||||||
if (maxIdx != blankIdx && maxIdx != lastIdx) {
|
if (maxIdx != blankIdx && maxIdx != lastIdx) {
|
||||||
val dictIdx = maxIdx - 1 // index 1..N → dictionary[0..N-1]
|
val dictIdx = maxIdx - 1 // index 1..N → dictionary[0..N-1]
|
||||||
if (dictIdx in 0 until dictionary.size) {
|
if (dictIdx in 0 until dictionary.size) {
|
||||||
sb.append(dictionary[dictIdx])
|
sb.append(dictionary[dictIdx])
|
||||||
confSum += prob.toFloat()
|
confSum += maxVal // v6 输出已是概率,直接用
|
||||||
confCount++
|
confCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -560,18 +651,29 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
val w = maxX - minX
|
val w = maxX - minX
|
||||||
val h = maxY - minY
|
val h = maxY - minY
|
||||||
if (w < 2 || h < 2) return null
|
if (w < 2 || h < 2) return null
|
||||||
return Bitmap.createBitmap(bmp, minX, minY, w, h)
|
var crop = Bitmap.createBitmap(bmp, minX, minY, w, h)
|
||||||
|
// 竖排文本旋转 90°(对齐官方 get_rotate_crop_image:高/宽 ≥ 1.5 视为竖排,
|
||||||
|
// 否则 rec 模型按水平行识别会乱码;账单侧边竖排小字借此可识别)
|
||||||
|
if (crop.height >= crop.width * 1.5f) {
|
||||||
|
val m = android.graphics.Matrix()
|
||||||
|
m.postRotate(90f)
|
||||||
|
val rotated = Bitmap.createBitmap(crop, 0, 0, crop.width, crop.height, m, true)
|
||||||
|
if (rotated !== crop) crop.recycle()
|
||||||
|
crop = rotated
|
||||||
|
}
|
||||||
|
return crop
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resizeForDet(bmp: Bitmap, maxSide: Int): Bitmap {
|
private fun resizeForDet(bmp: Bitmap, maxSide: Int): Bitmap {
|
||||||
val ratio = maxSide.toFloat() / max(bmp.width, bmp.height)
|
// 长边上限 + 无条件对齐 32(det 下采样要求),half-up 取整对齐官方 round。
|
||||||
if (ratio >= 1f) return bmp
|
// 关键修复:旧实现 ratio>=1 时直接 return 不对齐,若整图未预压且非 32 倍数会喂入非法尺寸,
|
||||||
val newW = (bmp.width * ratio).toInt()
|
// 导致 det 内部特征图广播报错;现无条件对齐,且 cap 后图通常 ≤32 倍数时仅轻微取整。
|
||||||
val newH = (bmp.height * ratio).toInt()
|
val ratioC = minOf(1f, maxSide.toFloat() / max(bmp.width, bmp.height))
|
||||||
// 确保尺寸是 32 的倍数(det 模型下采样要求)
|
val newW = Math.round(bmp.width * ratioC)
|
||||||
val alignedW = (newW / 32) * 32
|
val newH = Math.round(bmp.height * ratioC)
|
||||||
val alignedH = (newH / 32) * 32
|
val alignedW = max(32, Math.round(newW / 32f) * 32)
|
||||||
if (alignedW < 32 || alignedH < 32) return bmp
|
val alignedH = max(32, Math.round(newH / 32f) * 32)
|
||||||
|
if (alignedW == bmp.width && alignedH == bmp.height) return bmp
|
||||||
return Bitmap.createScaledBitmap(bmp, alignedW, alignedH, true)
|
return Bitmap.createScaledBitmap(bmp, alignedW, alignedH, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,17 +693,18 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 短边压缩到 maxShortEdge(参考 AutoAccounting scaleDownForOcr)。
|
* 整图长边上限降采样:仅当长边超过 cap 才按比例缩小(half-up 取整),否则原样返回。
|
||||||
* 像素量比 1440p 减少约 75%,识别速度大幅提升。
|
* 使 rec 的 crop 源尽量高清(手机截图通常不触发),仅防超大图 OOM。
|
||||||
|
* 取代旧的「短边压 720」——旧法把整图先砍掉 75% 像素,叠加 det resize 后小数点等细笔画被严重淡化。
|
||||||
*/
|
*/
|
||||||
private fun scaleDownForOcr(bitmap: Bitmap, maxShortEdge: Int): Bitmap {
|
private fun capLongEdge(bitmap: Bitmap, capLong: Int): Bitmap {
|
||||||
val width = bitmap.width
|
val width = bitmap.width
|
||||||
val height = bitmap.height
|
val height = bitmap.height
|
||||||
val shortEdge = minOf(width, height)
|
val longEdge = maxOf(width, height)
|
||||||
if (shortEdge <= maxShortEdge) return bitmap
|
if (longEdge <= capLong) return bitmap
|
||||||
val scale = maxShortEdge.toFloat() / shortEdge
|
val scale = capLong.toFloat() / longEdge
|
||||||
val newWidth = (width * scale).toInt()
|
val newWidth = Math.round(width * scale)
|
||||||
val newHeight = (height * scale).toInt()
|
val newHeight = Math.round(height * scale)
|
||||||
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
|
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,23 +740,28 @@ class OcrModule(private val context: ReactApplicationContext) :
|
|||||||
private data class TensorData(val data: FloatArray, val w: Int, val h: Int)
|
private data class TensorData(val data: FloatArray, val w: Int, val h: Int)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** OCR 最大短边(参考 AutoAccounting OCR_MAX_SHORT_EDGE)。 */
|
/** 整图长边上限:仅超大图降采样防 OOM;手机截图(≤2400)不预压,使 rec 的 crop 源为高清原图。
|
||||||
private const val OCR_MAX_SHORT_EDGE = 720
|
* 对齐官方「只放大不缩小」语义的移动端折中(官方 max_side_limit=4000)。 */
|
||||||
/** det resize 最大边(PaddleOCR limit_max_side_len 默认值)。 */
|
private const val CAP_LONG_EDGE = 3000
|
||||||
private const val DET_LIMIT_MAX_SIDE = 960
|
/** det 输入长边上限(官方 OCR 管线不降采样;移动端为性能折中取 1600,旧值 960 会让小数点仅 1-2px 而糊掉)。 */
|
||||||
/** DB 二值化阈值。 */
|
private const val DET_LIMIT_MAX_SIDE = 1600
|
||||||
private const val DET_THRESH = 0.3f
|
/** DB 二值化阈值(对齐官方模型 inference.yml=0.2,对细笔画/小数点更敏感;噪声框由 BOX_THRESH 兜底)。 */
|
||||||
/** 投影法合并相邻文本段的间隔(像素)。 */
|
private const val DET_THRESH = 0.2f
|
||||||
private const val DET_MERGE_GAP = 10
|
/** box_score_fast 过滤阈值:连通域文本像素 prob 均值低于此值视为噪声框(对齐官方 OCR 管线=0.6)。 */
|
||||||
|
private const val BOX_THRESH = 0.6f
|
||||||
|
/** 官方 unclip 外扩比例:distance = 连通域面积 × 比例 / 周长。 */
|
||||||
|
private const val UNCLIP_RATIO = 1.5f
|
||||||
|
/** 外扩后文本框短边下限(像素),小于此值丢弃(对齐官方 min_size=5)。 */
|
||||||
|
private const val MIN_SIZE = 5
|
||||||
/** rec 固定图像高度。 */
|
/** rec 固定图像高度。 */
|
||||||
private const val REC_IMAGE_HEIGHT = 48
|
private const val REC_IMAGE_HEIGHT = 48
|
||||||
/** rec 单行最大宽度。 */
|
/** rec 单行最大宽度(官方 cap=3200,移动端折中 1280;旧值 320 会把长商户名水平压扁 5×+)。 */
|
||||||
private const val REC_MAX_WIDTH = 320
|
private const val REC_MAX_WIDTH = 1280
|
||||||
/** assets 中的模型/字典文件名。 */
|
/** assets 中的模型/字典文件名。 */
|
||||||
private const val ASSET_DET_MODEL = "ppocrv5_det.onnx"
|
private const val ASSET_DET_MODEL = "ppocrv6_det.onnx"
|
||||||
private const val ASSET_REC_MODEL = "ppocrv5_rec.onnx"
|
private const val ASSET_REC_MODEL = "ppocrv6_rec.onnx"
|
||||||
// PP-OCRv5 多语言识别模型的配套字典(18383 字符 + 运行时 1 blank = 18385 维输出)。
|
// PP-OCRv6 多语言识别模型的配套字典(18708 字符 + 运行时 1 blank = 18710 维输出)。
|
||||||
// 注意:必须与 rec 模型配套,错用旧版 ppocr_keys_v1.txt(6623)会导致 CTC 解码乱码。
|
// 注意:必须与 rec 模型配套,错用 v5 字典(18383)会导致 CTC 解码丢字符。
|
||||||
private const val ASSET_DICT = "ppocrv5_dict.txt"
|
private const val ASSET_DICT = "ppocrv6_dict.txt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-12
@@ -22,9 +22,33 @@ const {
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const PACKAGE = 'com.beancount.mobile.ppocr';
|
function getAppId(config) {
|
||||||
|
return config.android?.package || 'com.example.driftledger';
|
||||||
|
}
|
||||||
|
|
||||||
/** 递归复制目录,prebuild 阶段执行一次。 */
|
/** 递归复制目录,prebuild 阶段执行并替换包名。 */
|
||||||
|
function copyAndReplaceDir(src, dest, appId) {
|
||||||
|
if (!fs.existsSync(src)) return;
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
for (const entry of fs.readdirSync(src)) {
|
||||||
|
const s = path.join(src, entry);
|
||||||
|
const d = path.join(dest, entry);
|
||||||
|
if (fs.statSync(s).isDirectory()) {
|
||||||
|
copyAndReplaceDir(s, d, appId);
|
||||||
|
} else {
|
||||||
|
if (entry.endsWith('.kt') || entry.endsWith('.java')) {
|
||||||
|
let content = fs.readFileSync(s, 'utf8');
|
||||||
|
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
|
||||||
|
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||||
|
fs.writeFileSync(d, content, 'utf8');
|
||||||
|
} else {
|
||||||
|
fs.copyFileSync(s, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归复制目录(仅用于模型资源复制,不替换包名)。 */
|
||||||
function copyDir(src, dest) {
|
function copyDir(src, dest) {
|
||||||
if (!fs.existsSync(src)) return;
|
if (!fs.existsSync(src)) return;
|
||||||
fs.mkdirSync(dest, { recursive: true });
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
@@ -37,17 +61,24 @@ function copyDir(src, dest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function withPpOcr(config) {
|
function withPpOcr(config) {
|
||||||
|
const appId = getAppId(config);
|
||||||
|
const PACKAGE = `${appId}.ppocr`;
|
||||||
|
|
||||||
// 1+2. 复制 Kotlin 源码与 ONNX 模型/字典到原生工程
|
// 1+2. 复制 Kotlin 源码与 ONNX 模型/字典到原生工程
|
||||||
config = withDangerousMod(config, [
|
config = withDangerousMod(config, [
|
||||||
'android',
|
'android',
|
||||||
async (modConfig) => {
|
async (modConfig) => {
|
||||||
// platformProjectRoot 在 modRequest 里(项目根的 android/ 子目录)
|
|
||||||
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
||||||
// Kotlin 源码(OcrModule.kt + OcrPackage.kt)
|
const pkgPath = appId.replace(/\./g, '/');
|
||||||
copyDir(
|
const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'ppocr');
|
||||||
|
|
||||||
|
// Kotlin 源码并自动重命名包名
|
||||||
|
copyAndReplaceDir(
|
||||||
path.join(__dirname, 'android'),
|
path.join(__dirname, 'android'),
|
||||||
path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/ppocr'),
|
ktDest,
|
||||||
|
appId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ONNX 模型 + 字典(若已下载)
|
// ONNX 模型 + 字典(若已下载)
|
||||||
const assetsSrc = path.join(__dirname, 'assets');
|
const assetsSrc = path.join(__dirname, 'assets');
|
||||||
if (fs.existsSync(assetsSrc)) {
|
if (fs.existsSync(assetsSrc)) {
|
||||||
@@ -65,12 +96,11 @@ function withPpOcr(config) {
|
|||||||
let content = modConfig.modResults.contents;
|
let content = modConfig.modResults.contents;
|
||||||
|
|
||||||
// 3a. 注入 import(在 package 声明行后插入)
|
// 3a. 注入 import(在 package 声明行后插入)
|
||||||
if (!content.includes(`import ${PACKAGE}.OcrPackage`)) {
|
content = content.replace(/^import\s+[\w.]+\.OcrPackage\s*$/gm, '');
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/^(package\s+[\w.]+;?\s*)$/m,
|
/^(package\s+[\w.]+;?\s*)$/m,
|
||||||
`$1\nimport ${PACKAGE}.OcrPackage`,
|
`$1\nimport ${PACKAGE}.OcrPackage`,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 3b. 在 getPackages() 的 .apply {} 块里注入 add(OcrPackage())
|
// 3b. 在 getPackages() 的 .apply {} 块里注入 add(OcrPackage())
|
||||||
if (!content.includes('add(OcrPackage())')) {
|
if (!content.includes('add(OcrPackage())')) {
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "beancount-mobile-plugin-ppocr",
|
"name": "drift-ledger-plugin-ppocr",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "app.plugin.js"
|
"main": "app.plugin.js"
|
||||||
|
|||||||
@@ -12,37 +12,55 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require('
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const PACKAGE = 'com.beancount.mobile.screenshot';
|
function getAppId(config) {
|
||||||
|
return config.android?.package || 'com.example.driftledger';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无障碍服务伪装目标包名(必须与 accessibility plugin 保持一致)。
|
||||||
|
* ScreenshotModule import 了 ReactContextHolder,该类在 prebuild 时被
|
||||||
|
* accessibility plugin 整体迁移到此伪装包下,故此处需同步改写引用。
|
||||||
|
*/
|
||||||
|
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
|
||||||
|
|
||||||
function withScreenshotMonitor(config) {
|
function withScreenshotMonitor(config) {
|
||||||
|
const appId = getAppId(config);
|
||||||
|
const PACKAGE = `${appId}.screenshot`;
|
||||||
|
|
||||||
// 1. 复制 Kotlin 源码到原生工程
|
// 1. 复制 Kotlin 源码到原生工程
|
||||||
config = withDangerousMod(config, [
|
config = withDangerousMod(config, [
|
||||||
'android',
|
'android',
|
||||||
async (modConfig) => {
|
async (modConfig) => {
|
||||||
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
||||||
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/screenshot');
|
const pkgPath = appId.replace(/\./g, '/');
|
||||||
|
const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'screenshot');
|
||||||
fs.mkdirSync(dest, { recursive: true });
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
const srcDir = path.join(__dirname, 'android');
|
const srcDir = path.join(__dirname, 'android');
|
||||||
for (const f of fs.readdirSync(srcDir)) {
|
for (const f of fs.readdirSync(srcDir)) {
|
||||||
if (f.endsWith('.kt')) {
|
if (f.endsWith('.kt')) {
|
||||||
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f));
|
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
|
||||||
|
// 必须先改写 accessibility 子包引用(迁移到伪装包),再做通用包名替换,
|
||||||
|
// 否则通用规则会把 com.beancount.mobile.accessibility 错误地改成 appId.accessibility。
|
||||||
|
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
|
||||||
|
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
|
||||||
|
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||||
|
fs.writeFileSync(path.join(dest, f), content, 'utf8');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return modConfig;
|
return modConfig;
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 2. 注册 ScreenshotPackage 到 MainApplication(复制 ppocr 模式)
|
// 2. 注册 ScreenshotPackage 到 MainApplication
|
||||||
config = withMainApplication(config, (modConfig) => {
|
config = withMainApplication(config, (modConfig) => {
|
||||||
let content = modConfig.modResults.contents;
|
let content = modConfig.modResults.contents;
|
||||||
|
|
||||||
// 2a. 注入 import
|
// 2a. 注入 import
|
||||||
if (!content.includes(`import ${PACKAGE}.ScreenshotPackage`)) {
|
content = content.replace(/^import\s+[\w.]+\.ScreenshotPackage\s*$/gm, '');
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/^(package\s+[\w.]+;?\s*)$/m,
|
/^(package\s+[\w.]+;?\s*)$/m,
|
||||||
`$1\nimport ${PACKAGE}.ScreenshotPackage`,
|
`$1\nimport ${PACKAGE}.ScreenshotPackage`,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 2b. 在 getPackages() 的 .apply {} 块里注入 add(ScreenshotPackage())
|
// 2b. 在 getPackages() 的 .apply {} 块里注入 add(ScreenshotPackage())
|
||||||
if (!content.includes('add(ScreenshotPackage())')) {
|
if (!content.includes('add(ScreenshotPackage())')) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "beancount-mobile-plugin-screenshot-monitor",
|
"name": "drift-ledger-plugin-screenshot-monitor",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "app.plugin.js"
|
"main": "app.plugin.js"
|
||||||
|
|||||||
@@ -1,28 +1,46 @@
|
|||||||
const { withDangerousMod } = require('@expo/config-plugins');
|
const { withDangerousMod, withGradleProperties } = require('@expo/config-plugins');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
function withSizeOptimization(config) {
|
/**
|
||||||
// 1. 在 prebuild 时修改 gradle.properties
|
* 在 gradle.properties 的 modResults 数组中设置某个 key(存在则更新,不存在则追加)。
|
||||||
config = withDangerousMod(config, [
|
*
|
||||||
'android',
|
* 必须用 withGradleProperties(走 expo 内存 mod 流程)而非 withDangerousMod(直接改文件),
|
||||||
async (modConfig) => {
|
* 否则会被 expo 的 gradleProperties base mod 在 write 阶段用内存快照全量覆盖。
|
||||||
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
* 典型症状:reactNativeArchitectures 改了不生效(被覆盖回全架构),
|
||||||
const propertiesPath = path.join(projectRoot, 'gradle.properties');
|
* 而 minify/shrink 等新 key 反而生效(不在 expo 内存快照里,未被覆盖)。
|
||||||
if (fs.existsSync(propertiesPath)) {
|
*/
|
||||||
let content = fs.readFileSync(propertiesPath, 'utf8');
|
function setGradleProperty(modResults, key, value) {
|
||||||
if (content.includes('reactNativeArchitectures=')) {
|
let found = false;
|
||||||
content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a');
|
const updated = modResults.map((prop) => {
|
||||||
} else {
|
if (prop.type === 'property' && prop.key === key) {
|
||||||
content += '\nreactNativeArchitectures=arm64-v8a\n';
|
found = true;
|
||||||
}
|
return { ...prop, value };
|
||||||
fs.writeFileSync(propertiesPath, content, 'utf8');
|
|
||||||
}
|
|
||||||
return modConfig;
|
|
||||||
}
|
}
|
||||||
]);
|
return prop;
|
||||||
|
});
|
||||||
|
if (!found) {
|
||||||
|
updated.push({ type: 'property', key, value });
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包
|
function withSizeOptimization(config) {
|
||||||
|
// 1. 修改 gradle.properties(用 withGradleProperties 避免 base mod write 覆盖)
|
||||||
|
// - 限制打包架构为 arm64-v8a(加速构建、减小包体)
|
||||||
|
// - 开启 R8 混淆与资源裁剪
|
||||||
|
// - 开启 .so 高倍率压缩
|
||||||
|
config = withGradleProperties(config, (config) => {
|
||||||
|
let props = config.modResults;
|
||||||
|
props = setGradleProperty(props, 'reactNativeArchitectures', 'arm64-v8a');
|
||||||
|
props = setGradleProperty(props, 'android.enableMinifyInReleaseBuilds', 'true');
|
||||||
|
props = setGradleProperty(props, 'android.enableShrinkResourcesInReleaseBuilds', 'true');
|
||||||
|
props = setGradleProperty(props, 'expo.useLegacyPackaging', 'true');
|
||||||
|
config.modResults = props;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包 + 字体裁剪
|
||||||
config = withDangerousMod(config, [
|
config = withDangerousMod(config, [
|
||||||
'android',
|
'android',
|
||||||
async (modConfig) => {
|
async (modConfig) => {
|
||||||
@@ -30,12 +48,98 @@ 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;
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4. 在 prebuild 时修改 root build.gradle 强制指定所有模块的 NDK 版本以匹配 27.1.12297006
|
||||||
|
config = withDangerousMod(config, [
|
||||||
|
'android',
|
||||||
|
async (modConfig) => {
|
||||||
|
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
||||||
|
const rootBuildGradlePath = path.join(projectRoot, 'build.gradle');
|
||||||
|
if (fs.existsSync(rootBuildGradlePath)) {
|
||||||
|
let content = fs.readFileSync(rootBuildGradlePath, 'utf8');
|
||||||
|
if (!content.includes('ndkVersion = "27.1.12297006"')) {
|
||||||
|
content += `
|
||||||
|
subprojects { project ->
|
||||||
|
def configureProject = { proj ->
|
||||||
|
if (proj.hasProperty("android")) {
|
||||||
|
proj.android {
|
||||||
|
ndkVersion = "27.1.12297006"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (project.state.executed) {
|
||||||
|
configureProject(project)
|
||||||
|
} else {
|
||||||
|
project.afterEvaluate {
|
||||||
|
configureProject(project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(rootBuildGradlePath, content, 'utf8');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return modConfig;
|
return modConfig;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"name": "size-optimization",
|
"name": "drift-ledger-plugin-size-optimization",
|
||||||
"main": "app.plugin.js"
|
"main": "app.plugin.js"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,18 +11,39 @@ const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins'
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
function getAppId(config) {
|
||||||
|
return config.android?.package || 'com.example.driftledger';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 无障碍服务伪装目标包名(必须与 accessibility plugin 保持一致)。
|
||||||
|
* BillingSmsReceiver import 了 ReactContextHolder,该类在 prebuild 时被
|
||||||
|
* accessibility plugin 整体迁移到此伪装包下,故此处需同步改写引用。
|
||||||
|
*/
|
||||||
|
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
|
||||||
|
|
||||||
function withSmsReceiver(config) {
|
function withSmsReceiver(config) {
|
||||||
|
const appId = getAppId(config);
|
||||||
|
const PACKAGE = `${appId}.sms`;
|
||||||
|
|
||||||
// 1. 复制 Kotlin 源码到原生工程
|
// 1. 复制 Kotlin 源码到原生工程
|
||||||
config = withDangerousMod(config, [
|
config = withDangerousMod(config, [
|
||||||
'android',
|
'android',
|
||||||
async (modConfig) => {
|
async (modConfig) => {
|
||||||
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
||||||
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/sms');
|
const pkgPath = appId.replace(/\./g, '/');
|
||||||
|
const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'sms');
|
||||||
fs.mkdirSync(dest, { recursive: true });
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
const srcDir = path.join(__dirname, 'android');
|
const srcDir = path.join(__dirname, 'android');
|
||||||
for (const f of fs.readdirSync(srcDir)) {
|
for (const f of fs.readdirSync(srcDir)) {
|
||||||
if (f.endsWith('.kt')) {
|
if (f.endsWith('.kt')) {
|
||||||
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f));
|
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
|
||||||
|
// 必须先改写 accessibility 子包引用(迁移到伪装包),再做通用包名替换,
|
||||||
|
// 否则通用规则会把 com.beancount.mobile.accessibility 错误地改成 appId.accessibility。
|
||||||
|
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
|
||||||
|
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
|
||||||
|
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||||
|
fs.writeFileSync(path.join(dest, f), content, 'utf8');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return modConfig;
|
return modConfig;
|
||||||
@@ -48,7 +69,7 @@ function withSmsReceiver(config) {
|
|||||||
// 2. 添加短信 Receiver
|
// 2. 添加短信 Receiver
|
||||||
const receiverNode = {
|
const receiverNode = {
|
||||||
$: {
|
$: {
|
||||||
'android:name': 'com.beancount.mobile.sms.BillingSmsReceiver',
|
'android:name': `${PACKAGE}.BillingSmsReceiver`,
|
||||||
'android:exported': 'true',
|
'android:exported': 'true',
|
||||||
},
|
},
|
||||||
'intent-filter': [{
|
'intent-filter': [{
|
||||||
@@ -65,7 +86,7 @@ function withSmsReceiver(config) {
|
|||||||
app.receiver = [];
|
app.receiver = [];
|
||||||
}
|
}
|
||||||
const exists = app.receiver.some(
|
const exists = app.receiver.some(
|
||||||
r => r.$['android:name'] === 'com.beancount.mobile.sms.BillingSmsReceiver'
|
r => r.$['android:name'] === `${PACKAGE}.BillingSmsReceiver`
|
||||||
);
|
);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
app.receiver.push(receiverNode);
|
app.receiver.push(receiverNode);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "beancount-mobile-plugin-sms-receiver",
|
"name": "drift-ledger-plugin-sms-receiver",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "app.plugin.js"
|
"main": "app.plugin.js"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* 复用 domain/ai.ts 的 processNaturalLanguage + buildNaturalLanguagePrompt。
|
* 复用 domain/ai.ts 的 processNaturalLanguage + buildNaturalLanguagePrompt。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CHAT_TRANSACTION_KEYWORDS } from '../domain/constants';
|
import { CHAT_TRANSACTION_KEYWORDS } from '../domain/core/constants';
|
||||||
import { processNaturalLanguage, removeThink, type AiProvider, type AiBillResult } from '../domain/ai';
|
import { processNaturalLanguage, removeThink, type AiProvider, type AiBillResult } from '../domain/ai';
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
@@ -39,6 +39,8 @@ export function isTransactionIntent(input: string): boolean {
|
|||||||
return hasAmount && hasKeyword;
|
return hasAmount && hasKeyword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理用户消息:判定意图 → 记账或自由对话。
|
* 处理用户消息:判定意图 → 记账或自由对话。
|
||||||
*/
|
*/
|
||||||
@@ -47,11 +49,14 @@ export async function processChatMessage(
|
|||||||
provider: AiProvider,
|
provider: AiProvider,
|
||||||
conversation?: ChatConversation,
|
conversation?: ChatConversation,
|
||||||
): Promise<ChatResponse> {
|
): Promise<ChatResponse> {
|
||||||
|
logger.info('aiChat', `收到 AI 助手对话请求 [文本: "${input}"]`);
|
||||||
try {
|
try {
|
||||||
// 意图判定
|
// 意图判定
|
||||||
if (isTransactionIntent(input)) {
|
if (isTransactionIntent(input)) {
|
||||||
|
logger.info('aiChat', '检测到记账意图,触发自然语言识别流水线');
|
||||||
const result = await processNaturalLanguage(input, provider);
|
const result = await processNaturalLanguage(input, provider);
|
||||||
if (result.type === 'draft') {
|
if (result.type === 'draft') {
|
||||||
|
logger.info('aiChat', `AI 识别生成交易草稿: [时间: ${result.draft.time}, 金额: ${result.draft.amount} ${result.draft.currency}, 叙述: "${result.draft.narration}"]`);
|
||||||
return { type: 'bill_card', billCards: [result.draft] };
|
return { type: 'bill_card', billCards: [result.draft] };
|
||||||
}
|
}
|
||||||
// 非 draft 但有意图 → 返回 AI 文本
|
// 非 draft 但有意图 → 返回 AI 文本
|
||||||
@@ -59,10 +64,13 @@ export async function processChatMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 自由对话
|
// 自由对话
|
||||||
|
logger.info('aiChat', '未命中记账模式,发起 AI 自由问答');
|
||||||
const messages = buildChatContext(input, conversation);
|
const messages = buildChatContext(input, conversation);
|
||||||
const response = await provider.chat(messages);
|
const response = await provider.chat(messages);
|
||||||
|
logger.info('aiChat', 'AI 自由问答回复成功');
|
||||||
return { type: 'text', text: removeThink(response) };
|
return { type: 'text', text: removeThink(response) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error('aiChat', 'AI 对话助手响应失败', e);
|
||||||
return { type: 'error', error: e instanceof Error ? e.message : String(e) };
|
return { type: 'error', error: e instanceof Error ? e.message : String(e) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-19
@@ -10,9 +10,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { buildMonthlySummaryPrompt, type AiProvider } from '../domain/ai';
|
import { buildMonthlySummaryPrompt, type AiProvider } from '../domain/ai';
|
||||||
import { generateAnnualReport } from '../domain/annualReport';
|
import { generateAnnualReport } from '../domain/stats/annualReport';
|
||||||
import { removeThink } from '../domain/ai';
|
import { removeThink } from '../domain/ai';
|
||||||
import type { Transaction } from '../domain/types';
|
import type { Transaction } from '../domain/core/types';
|
||||||
|
|
||||||
export interface MonthlyStats {
|
export interface MonthlyStats {
|
||||||
year: number;
|
year: number;
|
||||||
@@ -50,6 +50,8 @@ export function calculateMonthlyStats(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成 AI 月度总结。
|
* 生成 AI 月度总结。
|
||||||
*/
|
*/
|
||||||
@@ -59,33 +61,40 @@ 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)。 */
|
||||||
export function generatePlainTextSummary(stats: MonthlyStats): string {
|
export function generatePlainTextSummary(stats: MonthlyStats): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`📊 ${stats.year}年${stats.month}月财务总结`);
|
lines.push(`${stats.year}年${stats.month}月财务总结`);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push(`💰 总收入:${stats.totalIncome} 元`);
|
lines.push(`总收入:${stats.totalIncome} 元`);
|
||||||
lines.push(`💸 总支出:${stats.totalExpense} 元`);
|
lines.push(`总支出:${stats.totalExpense} 元`);
|
||||||
lines.push(`📝 交易笔数:${stats.transactionCount}`);
|
lines.push(`交易笔数:${stats.transactionCount}`);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
if (stats.topCategories.length > 0) {
|
if (stats.topCategories.length > 0) {
|
||||||
lines.push('🏷️ 主要支出分类:');
|
lines.push('主要支出分类:');
|
||||||
for (const cat of stats.topCategories) {
|
for (const cat of stats.topCategories) {
|
||||||
lines.push(` ${cat.category}: ${cat.amount} 元`);
|
lines.push(` ${cat.category}: ${cat.amount} 元`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,32 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useTheme } from '../../theme';
|
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
|
import { AppTabBar } from '../../components/layout/AppTabBar';
|
||||||
|
|
||||||
/** 底部 Tab 导航:首页/交易/报表/导入/规则/设置。 */
|
/** 底部 Tab 导航:首页/交易/报表/设置 + 中央+(AppTabBar)。 */
|
||||||
export default function TabsLayout() {
|
export default function TabsLayout() {
|
||||||
const { theme } = useTheme();
|
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{ headerShown: false }}
|
||||||
headerShown: false,
|
tabBar={(props) => <AppTabBar {...props} />}
|
||||||
tabBarActiveTintColor: theme.colors.accent,
|
|
||||||
tabBarInactiveTintColor: theme.colors.fgSecondary,
|
|
||||||
tabBarStyle: {
|
|
||||||
backgroundColor: theme.colors.bgSecondary,
|
|
||||||
borderTopColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="index"
|
name="index"
|
||||||
options={{
|
options={{ title: t('tab.home') }}
|
||||||
title: t('tab.home'),
|
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="transactions"
|
name="transactions"
|
||||||
options={{
|
options={{ title: t('tab.transactions') }}
|
||||||
title: t('tab.transactions'),
|
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="list-outline" size={size} color={color} />,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="report"
|
name="report"
|
||||||
options={{
|
options={{ title: t('tab.report') }}
|
||||||
title: t('tab.report'),
|
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="pie-chart-outline" size={size} color={color} />,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{ title: t('tab.settings') }}
|
||||||
title: t('tab.settings'),
|
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|||||||
+127
-158
@@ -1,20 +1,29 @@
|
|||||||
import React, { useEffect, useMemo, useRef } from 'react';
|
/**
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View, Alert } from 'react-native';
|
* 首页(spec §7.1):今日视角。
|
||||||
|
* 问候 header → 净资产白卡(本月收支/预算剩余小字)→ 待办条 → 最近 5 条 → 月度趋势。
|
||||||
|
* 账户树已下沉到「我的」页(/account)。
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
import { useMetadataStore } from '../../store/metadataStore';
|
import { useMetadataStore } from '../../store/metadataStore';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { SpeedDial } from '../../components/SpeedDial';
|
import { TransactionCard } from '../../components/transaction/TransactionCard';
|
||||||
import { TransactionCard } from '../../components/TransactionCard';
|
import { TodoStrip } from '../../components/stats/TodoStrip';
|
||||||
import { AccountTree } from '../../components/AccountTree';
|
|
||||||
import { TrendLine } from '../../components/charts/TrendLine';
|
import { TrendLine } from '../../components/charts/TrendLine';
|
||||||
import { calculateNetWorth } from '../../domain/netWorth';
|
import { calculateNetWorth } from '../../domain/stats/netWorth';
|
||||||
import { groupByMonth } from '../../domain/chartStats';
|
import { groupByMonth } from '../../domain/stats/chartStats';
|
||||||
import { buildAccountTree } from '../../domain/accountTree';
|
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/finance/recurring';
|
||||||
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/recurring';
|
import { calculateBudgetProgress } from '../../domain/finance/budgets';
|
||||||
|
import { addDecimals, toDateString } from '../../domain/core/decimal';
|
||||||
|
import type { Transaction } from '../../domain/core/types';
|
||||||
|
import { EmptyState } from '../../components/ui/EmptyState';
|
||||||
|
import { useNumpadUiStore } from '../../store/numpadUiStore';
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -23,28 +32,19 @@ export default function HomeScreen() {
|
|||||||
|
|
||||||
const ledger = useLedgerStore(s => s.ledger);
|
const ledger = useLedgerStore(s => s.ledger);
|
||||||
const addTransaction = useLedgerStore(s => s.addTransaction);
|
const addTransaction = useLedgerStore(s => s.addTransaction);
|
||||||
|
|
||||||
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
|
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
|
||||||
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
|
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
|
||||||
|
const budgets = useMetadataStore(s => s.budgets);
|
||||||
|
const categories = useMetadataStore(s => s.categories);
|
||||||
|
const locale = useSettingsStore(s => s.locale);
|
||||||
|
|
||||||
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
|
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
|
||||||
const netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]);
|
const netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]);
|
||||||
const monthlyData = useMemo(() => groupByMonth(transactions), [transactions]);
|
const monthlyData = useMemo(() => groupByMonth(transactions), [transactions]);
|
||||||
const currentMonth = useMemo(() => {
|
const currentMonth = useMemo(() => monthlyData.length ? monthlyData[monthlyData.length - 1] : null, [monthlyData]);
|
||||||
if (monthlyData.length === 0) return null;
|
|
||||||
return monthlyData[monthlyData.length - 1];
|
|
||||||
}, [monthlyData]);
|
|
||||||
// 1. 账户树计算
|
|
||||||
const accountNodes = useMemo(() => {
|
|
||||||
if (!ledger || !ledger.accounts) return [];
|
|
||||||
return buildAccountTree(ledger.accounts, transactions, ledger);
|
|
||||||
}, [ledger, transactions]);
|
|
||||||
|
|
||||||
// 2. 周期性记账到期计算
|
const todayStr = useMemo(() => toDateString(new Date()), []);
|
||||||
const todayStr = useMemo(() => new Date().toISOString().slice(0, 10), []);
|
const dueRecurring = useMemo(() => getDueRecurring(recurringTransactions, todayStr), [recurringTransactions, todayStr]);
|
||||||
const dueRecurring = useMemo(() => {
|
|
||||||
return getDueRecurring(recurringTransactions, todayStr);
|
|
||||||
}, [recurringTransactions, todayStr]);
|
|
||||||
|
|
||||||
// 周期记账自动触发:启动时自动确认标记了 autoConfirm 的到期项
|
// 周期记账自动触发:启动时自动确认标记了 autoConfirm 的到期项
|
||||||
const autoConfirmed = useRef(false);
|
const autoConfirmed = useRef(false);
|
||||||
@@ -70,149 +70,122 @@ export default function HomeScreen() {
|
|||||||
return () => { isMounted = false; };
|
return () => { isMounted = false; };
|
||||||
}, [dueRecurring, addTransaction, updateRecurringTransaction]);
|
}, [dueRecurring, addTransaction, updateRecurringTransaction]);
|
||||||
|
|
||||||
const handleConfirmRecurring = async (rec: any) => {
|
// 月度预算剩余合计(无月度预算则不显示)
|
||||||
try {
|
const budgetRemaining = useMemo(() => {
|
||||||
const draft = instantiateRecurring(rec);
|
const monthlyBudgets = budgets.filter(b => b.period === 'monthly');
|
||||||
await addTransaction(draft);
|
if (monthlyBudgets.length === 0) return null;
|
||||||
const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate);
|
const remainings = monthlyBudgets.map(b => calculateBudgetProgress(b, transactions, todayStr).remaining);
|
||||||
updateRecurringTransaction(rec.id, { nextDueDate });
|
return remainings.reduce((a, b) => addDecimals([a, b]), '0');
|
||||||
Alert.alert(t('home.recurringSuccess'), t('home.recurringSuccessDesc', { name: rec.name }));
|
}, [budgets, transactions, todayStr]);
|
||||||
} catch (e) {
|
|
||||||
Alert.alert(t('home.recurringFail'), String(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 3. 千分位格式化金额
|
// 最近 5 条(按日期+序号倒序)
|
||||||
|
const recentTxs = useMemo(() =>
|
||||||
|
transactions.map((tx, idx) => ({ tx, idx }))
|
||||||
|
.sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(item => item.tx),
|
||||||
|
[transactions]);
|
||||||
|
|
||||||
|
// 分类匹配(TransactionCard 图标)
|
||||||
|
const categoryIdFor = useCallback((tx: Transaction): string | undefined => {
|
||||||
|
const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
|
||||||
|
return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
// 问候语 + 日期行
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const greeting = hour < 12 ? t('home.greetingMorning') : hour < 18 ? t('home.greetingAfternoon') : t('home.greetingEvening');
|
||||||
|
const localeTag = locale === 'zh' ? 'zh-CN' : 'en-US';
|
||||||
|
const dateLine = new Date().toLocaleDateString(localeTag, { month: 'long', day: 'numeric', weekday: 'long' });
|
||||||
|
|
||||||
|
// 千分位格式化金额
|
||||||
const fmtAmount = (s: string) => {
|
const fmtAmount = (s: string) => {
|
||||||
if (!s || s === '0') return '¥0.00';
|
if (!s || s === '0') return '¥0.00';
|
||||||
const num = parseFloat(s);
|
const num = parseFloat(s);
|
||||||
if (isNaN(num)) return s;
|
if (isNaN(num)) return s;
|
||||||
const formatted = num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
const formatted = num.toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
return num >= 0 ? `¥${formatted}` : `-¥${Math.abs(num).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
return num >= 0 ? `¥${formatted}` : `-¥${Math.abs(num).toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h1.fontFamily }]}>
|
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{greeting}</Text>
|
||||||
{t('app.name')}
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>{dateLine}</Text>
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
<FlatList
|
||||||
|
data={recentTxs}
|
||||||
{/* 净资产 Hero 卡片 */}
|
keyExtractor={(tx) => tx.id}
|
||||||
<Card
|
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
|
||||||
style={{
|
ListHeaderComponent={
|
||||||
backgroundColor: theme.colors.accent,
|
<View style={{ gap: theme.spacing.md }}>
|
||||||
borderColor: theme.colors.accentLight,
|
{/* 净资产白卡 */}
|
||||||
shadowColor: theme.colors.accent,
|
<Card>
|
||||||
shadowOpacity: 0.15,
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorthTitle')}</Text>
|
||||||
shadowRadius: 10,
|
<Text style={[theme.typography.display, { color: theme.colors.fgPrimary, marginVertical: 6, fontVariant: ['tabular-nums'] }]}>
|
||||||
}}
|
{fmtAmount(netWorth.netWorth)}
|
||||||
>
|
|
||||||
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontSize: 13, fontWeight: '600', fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{t('home.netWorthTitle')}
|
|
||||||
</Text>
|
|
||||||
<Text style={[theme.typography.h1, { color: '#FFFFFF', fontSize: 32, fontWeight: '800', marginVertical: 8, fontFamily: 'monospace' }]}>
|
|
||||||
{fmtAmount(netWorth.netWorth)}
|
|
||||||
</Text>
|
|
||||||
<View style={styles.heroFooter}>
|
|
||||||
<View>
|
|
||||||
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{t('home.assets')}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[theme.typography.h3, { color: '#FFFFFF', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
|
<View style={[styles.heroFooter, { borderTopColor: theme.colors.divider }]}>
|
||||||
{fmtAmount(netWorth.assets)}
|
<View style={styles.heroStat}>
|
||||||
</Text>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthExpense')}</Text>
|
||||||
</View>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||||||
<View style={{ alignItems: 'flex-end' }}>
|
-{fmtAmount(currentMonth?.expense.toString() ?? '0')}
|
||||||
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}>
|
</Text>
|
||||||
{t('home.liabilities')}
|
|
||||||
</Text>
|
|
||||||
<Text style={[theme.typography.h3, { color: 'rgba(255,255,255,0.9)', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
|
|
||||||
{fmtAmount(netWorth.liabilities)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Bento 双格排列 */}
|
|
||||||
{(dueRecurring.length > 0 || currentMonth) && (
|
|
||||||
<View style={styles.bentoRow}>
|
|
||||||
{dueRecurring.length > 0 ? (
|
|
||||||
<Card title={t('home.dueRecurring')} style={styles.bentoCol}>
|
|
||||||
<View style={styles.recurringList}>
|
|
||||||
{dueRecurring.slice(0, 2).map(rec => (
|
|
||||||
<View key={rec.id} style={[styles.recurringItem, { borderBottomColor: theme.colors.divider }]}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, fontWeight: '700', fontFamily: theme.typography.caption.fontFamily }]} numberOfLines={1}>
|
|
||||||
{rec.name}
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => handleConfirmRecurring(rec)}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.confirmBtnMini,
|
|
||||||
{
|
|
||||||
backgroundColor: theme.colors.bgTertiary,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
borderRadius: theme.radii.sm,
|
|
||||||
opacity: pressed ? 0.7 : 1,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={{ color: theme.colors.accent, fontSize: 10, fontWeight: '800', textAlign: 'center' }}>
|
|
||||||
{rec.draft.postings[0]?.amount} CNY
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
<View style={styles.heroStat}>
|
||||||
) : null}
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthIncome')}</Text>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||||||
{currentMonth ? (
|
+{fmtAmount(currentMonth?.income.toString() ?? '0')}
|
||||||
<Card title={`${currentMonth.month.slice(5)} ${t('home.title')}`} style={styles.bentoCol}>
|
</Text>
|
||||||
<View style={styles.bentoStats}>
|
</View>
|
||||||
<View>
|
{budgetRemaining !== null && (
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
<View style={styles.heroStat}>
|
||||||
{t('home.income')}
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.budgetRemaining')}</Text>
|
||||||
</Text>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
|
{fmtAmount(budgetRemaining)}
|
||||||
+{fmtAmount(currentMonth.income.toString())}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View>
|
)}
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
</View>
|
||||||
{t('home.expense')}
|
</Card>
|
||||||
</Text>
|
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
|
<TodoStrip />
|
||||||
-{fmtAmount(currentMonth.expense.toString())}
|
|
||||||
</Text>
|
{/* 月度趋势 */}
|
||||||
</View>
|
{monthlyData.length > 0 && (
|
||||||
</View>
|
<Card title={t('home.monthlyTrend')}>
|
||||||
|
<TrendLine transactions={transactions} />
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
|
{/* 最近交易标题 */}
|
||||||
|
{recentTxs.length > 0 && (
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{t('home.recentTransactions')}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item: tx }) => (
|
||||||
|
<TransactionCard key={tx.id} transaction={tx} categoryId={categoryIdFor(tx)} onPress={() => router.push(`/transaction/${tx.id}`)} />
|
||||||
)}
|
)}
|
||||||
|
ListFooterComponent={
|
||||||
{/* 月度趋势 */}
|
<>
|
||||||
{monthlyData.length > 0 && (
|
{recentTxs.length > 0 && (
|
||||||
<Card title={t('home.monthlyTrend')}>
|
<Pressable onPress={() => router.push('/(tabs)/transactions')} style={styles.viewAll} accessibilityRole="button" accessibilityLabel={t('home.viewAll')}>
|
||||||
<TrendLine transactions={transactions} />
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>{t('home.viewAll')} →</Text>
|
||||||
</Card>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
|
{/* 无交易时的记账引导 */}
|
||||||
{/* 账户与资产余额树 */}
|
{transactions.length === 0 && (
|
||||||
{accountNodes.length > 0 && (
|
<EmptyState
|
||||||
<Card title={t('home.accountTree')}>
|
icon="add-circle-outline"
|
||||||
<AccountTree nodes={accountNodes} />
|
title={t('home.recentEmpty')}
|
||||||
</Card>
|
actionLabel={t('home.newTransaction')}
|
||||||
)}
|
onAction={() => useNumpadUiStore.getState().open()}
|
||||||
</ScrollView>
|
/>
|
||||||
<SpeedDial actions={[
|
)}
|
||||||
{ key: 'new', icon: 'create-outline', label: t('home.newTransaction'), onPress: () => router.push('/transaction/new') },
|
</>
|
||||||
{ key: 'ocr', icon: 'camera-outline', label: t('home.ocrScan'), onPress: () => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } }) },
|
}
|
||||||
{ key: 'import', icon: 'download-outline', label: t('tab.import'), onPress: () => router.push('/import') },
|
/>
|
||||||
]} />
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -220,12 +193,8 @@ export default function HomeScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||||||
content: { padding: 16, paddingBottom: 96 },
|
content: { padding: 16, paddingBottom: 40 },
|
||||||
heroFooter: { flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: 'rgba(255,255,255,0.15)', paddingTop: 10, marginTop: 4 },
|
heroFooter: { flexDirection: 'row', gap: 16, borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10, marginTop: 4 },
|
||||||
bentoRow: { flexDirection: 'row', gap: 12 },
|
heroStat: { gap: 2 },
|
||||||
bentoCol: { flex: 1 },
|
viewAll: { alignItems: 'center', paddingVertical: 6 },
|
||||||
recurringList: { gap: 8 },
|
|
||||||
recurringItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 6, borderBottomWidth: StyleSheet.hairlineWidth },
|
|
||||||
confirmBtnMini: { paddingVertical: 4, paddingHorizontal: 8, borderWidth: StyleSheet.hairlineWidth },
|
|
||||||
bentoStats: { gap: 8 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
+165
-288
@@ -1,11 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 报表页(plan.md「5.2 图表与可视化」+「5.3 年度报告」)。
|
* 报表页(plan.md「5.2 图表与可视化」+「5.3 年度报告」;P4 spec §7.3 重写)。
|
||||||
*
|
*
|
||||||
* 功能:
|
* 功能:
|
||||||
* - 月份切换器(上一月/下一月)
|
* - 单一 anchor 日期 + 周期(周/月/年)统一导航(periodNav),替代三套独立切换器
|
||||||
* - 月度收支/分类占比/热力图/净资产趋势/年度报告
|
* - 收支看板/分类占比/日历/净资产趋势/年度报告
|
||||||
* - 导出报表为图片(react-native-view-shot)
|
* - ⋯ 菜单:AI 月度总结、导出报表为图片(react-native-view-shot)
|
||||||
* - AI 月度总结(generatePlainTextSummary / generateMonthlySummary)
|
|
||||||
*/
|
*/
|
||||||
import React, { useMemo, useRef, useState } from 'react';
|
import React, { useMemo, useRef, useState } from 'react';
|
||||||
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
@@ -18,20 +17,31 @@ import { useLedgerStore } from '../../store/ledgerStore';
|
|||||||
import { useSettingsStore } from '../../store/settingsStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
import { useTheme, createCommonStyles } from '../../theme';
|
import { useTheme, createCommonStyles } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { Button } from '../../components/Button';
|
import { Button } from '../../components/ui/Button';
|
||||||
import { MonthlyReport } from '../../components/charts/MonthlyReport';
|
import { BottomSheet } from '../../components/ui/BottomSheet';
|
||||||
import { CategoryPie } from '../../components/charts/CategoryPie';
|
import { CategoryPie } from '../../components/charts/CategoryPie';
|
||||||
import { CalendarView } from '../../components/CalendarView';
|
import { CalendarView } from '../../components/stats/CalendarView';
|
||||||
import { TransactionCard } from '../../components/TransactionCard';
|
import { TransactionCard } from '../../components/transaction/TransactionCard';
|
||||||
import { NetWorthChart } from '../../components/charts/NetWorthChart';
|
import { NetWorthChart } from '../../components/charts/NetWorthChart';
|
||||||
import { AnnualReport } from '../../components/charts/AnnualReport';
|
import { AnnualReport } from '../../components/charts/AnnualReport';
|
||||||
import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary';
|
import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary';
|
||||||
import { BaseOpenAIProvider } from '../../domain/ai';
|
import { BaseOpenAIProvider } from '../../domain/ai';
|
||||||
|
import { addDecimals, negateDecimal, toDateString } from '../../domain/core/decimal';
|
||||||
|
import { periodRange, shiftAnchor, isoWeekNumber, type ReportPeriod } from '../../domain/stats/periodNav';
|
||||||
|
import { SegmentedControl } from '../../components/ui/SegmentedControl';
|
||||||
|
import { EmptyState } from '../../components/ui/EmptyState';
|
||||||
|
import { PeriodSwitcher } from '../../components/stats/PeriodSwitcher';
|
||||||
|
import { RangeStatsCard } from '../../components/stats/RangeStatsCard';
|
||||||
|
|
||||||
export default function ReportScreen() {
|
export default function ReportScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const commonStyles = createCommonStyles(theme);
|
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||||
|
// 动态样式:使用主题 token 替代硬编码值
|
||||||
|
const dynamicStyles = useMemo(() => ({
|
||||||
|
monthBtn: { borderRadius: theme.radii.lg, borderColor: theme.colors.border },
|
||||||
|
iconBtn: { padding: theme.spacing.xs },
|
||||||
|
}), [theme]);
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const ledger = useLedgerStore(s => s.ledger);
|
const ledger = useLedgerStore(s => s.ledger);
|
||||||
@@ -39,17 +49,14 @@ export default function ReportScreen() {
|
|||||||
const aiApiKey = useSettingsStore(s => s.aiApiKey);
|
const aiApiKey = useSettingsStore(s => s.aiApiKey);
|
||||||
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl);
|
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl);
|
||||||
const aiModel = useSettingsStore(s => s.aiModel);
|
const aiModel = useSettingsStore(s => s.aiModel);
|
||||||
|
const locale = useSettingsStore(s => s.locale);
|
||||||
|
|
||||||
// Tab 状态
|
// 周期 + 单一 anchor 日期(P4:替代旧版年/月/周三套独立切换状态)
|
||||||
const [activeTab, setActiveTab] = useState<'weekly' | 'monthly' | 'annual'>('monthly');
|
const [period, setPeriod] = useState<ReportPeriod>('monthly');
|
||||||
|
const [anchor, setAnchor] = useState(() => toDateString(new Date()));
|
||||||
|
|
||||||
// 月份切换状态
|
// ⋯ 菜单
|
||||||
const now = new Date();
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [viewYear, setViewYear] = useState(now.getFullYear());
|
|
||||||
const [viewMonth, setViewMonth] = useState(now.getMonth() + 1);
|
|
||||||
|
|
||||||
// 周切换状态
|
|
||||||
const [viewWeekDate, setViewWeekDate] = useState(new Date());
|
|
||||||
|
|
||||||
// 日历选中日期用于明细展开
|
// 日历选中日期用于明细展开
|
||||||
const [selectedReportDate, setSelectedReportDate] = useState<string | null>(null);
|
const [selectedReportDate, setSelectedReportDate] = useState<string | null>(null);
|
||||||
@@ -63,116 +70,66 @@ export default function ReportScreen() {
|
|||||||
|
|
||||||
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
|
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
|
||||||
|
|
||||||
// 周报表的时间范围:计算周一和周日
|
// anchor 所在周期的起止日期与周期内交易
|
||||||
const weeklyRange = useMemo(() => {
|
const range = useMemo(() => periodRange(anchor, period), [anchor, period]);
|
||||||
const d = new Date(viewWeekDate);
|
const rangeTxs = useMemo(
|
||||||
const day = d.getDay(); // 0 is Sunday, 1-6 is Mon-Sat
|
() => transactions.filter(tx => tx.date >= range.start && tx.date <= range.end),
|
||||||
const diffToMonday = day === 0 ? -6 : 1 - day;
|
[transactions, range],
|
||||||
|
);
|
||||||
|
|
||||||
const monday = new Date(d);
|
// 周期收支统计(Income 腿取负、Expenses 腿原值,与旧 weeklyStats/monthlyStats 同算法)
|
||||||
monday.setDate(d.getDate() + diffToMonday);
|
const rangeStats = useMemo(() => {
|
||||||
monday.setHours(0,0,0,0);
|
const incomeAmounts: string[] = [];
|
||||||
|
const expenseAmounts: string[] = [];
|
||||||
|
for (const tx of rangeTxs) {
|
||||||
|
for (const p of tx.postings) {
|
||||||
|
if (!p.amount) continue;
|
||||||
|
if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount));
|
||||||
|
if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const income = parseFloat(addDecimals(incomeAmounts.length ? incomeAmounts : ['0']));
|
||||||
|
const expense = parseFloat(addDecimals(expenseAmounts.length ? expenseAmounts : ['0']));
|
||||||
|
return { income, expense, net: income - expense, count: rangeTxs.length };
|
||||||
|
}, [rangeTxs]);
|
||||||
|
|
||||||
const sunday = new Date(monday);
|
// 周期切换器 label
|
||||||
sunday.setDate(monday.getDate() + 6);
|
const periodLabel = useMemo(() => {
|
||||||
sunday.setHours(23,59,59,999);
|
if (period === 'weekly') {
|
||||||
|
const monday = range.start;
|
||||||
|
const sunday = range.end;
|
||||||
|
return `${anchor.slice(0, 4)}年第${isoWeekNumber(anchor)}周 (${Number(monday.slice(5, 7))}/${Number(monday.slice(8))} ~ ${Number(sunday.slice(5, 7))}/${Number(sunday.slice(8))})`;
|
||||||
|
}
|
||||||
|
if (period === 'monthly') return anchor.slice(0, 7);
|
||||||
|
return `${anchor.slice(0, 4)}年`;
|
||||||
|
}, [period, anchor, range]);
|
||||||
|
|
||||||
// 计算 ISO 周数
|
// 净资产趋势的日期序列(基于 anchor 所在月,往前 6 个月)
|
||||||
const date = new Date(monday.getTime());
|
|
||||||
date.setHours(0, 0, 0, 0);
|
|
||||||
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);
|
|
||||||
const week1 = new Date(date.getFullYear(), 0, 4);
|
|
||||||
const weekNum = 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7);
|
|
||||||
|
|
||||||
return {
|
|
||||||
startStr: monday.toISOString().slice(0, 10),
|
|
||||||
endStr: sunday.toISOString().slice(0, 10),
|
|
||||||
label: `${monday.getFullYear()}年第${weekNum}周 (${monday.getMonth()+1}/${monday.getDate()} ~ ${sunday.getMonth()+1}/${sunday.getDate()})`
|
|
||||||
};
|
|
||||||
}, [viewWeekDate]);
|
|
||||||
|
|
||||||
const changeWeek = (days: number) => {
|
|
||||||
setSelectedReportDate(null);
|
|
||||||
const next = new Date(viewWeekDate);
|
|
||||||
next.setDate(next.getDate() + days);
|
|
||||||
setViewWeekDate(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 净资产趋势的日期序列(基于选中月份,往前 6 个月)
|
|
||||||
const netWorthDates = useMemo(() => {
|
const netWorthDates = useMemo(() => {
|
||||||
const dates: string[] = [];
|
const dates: string[] = [];
|
||||||
for (let i = 5; i >= 0; i--) {
|
for (let i = 5; i >= 0; i--) {
|
||||||
const d = new Date(viewYear, viewMonth - 1 - i, 1);
|
const d = new Date(Number(anchor.slice(0, 4)), Number(anchor.slice(5, 7)) - 1 - i, 1);
|
||||||
dates.push(d.toISOString().slice(0, 10));
|
dates.push(toDateString(d));
|
||||||
}
|
}
|
||||||
return dates;
|
return dates;
|
||||||
}, [viewYear, viewMonth]);
|
}, [anchor]);
|
||||||
|
|
||||||
const prevMonth = () => {
|
const shift = (delta: number) => {
|
||||||
setSelectedReportDate(null);
|
setSelectedReportDate(null);
|
||||||
if (viewMonth === 1) { setViewMonth(12); setViewYear(y => y - 1); }
|
setAnchor(a => shiftAnchor(a, period, delta));
|
||||||
else setViewMonth(m => m - 1);
|
|
||||||
};
|
};
|
||||||
const nextMonth = () => {
|
|
||||||
setSelectedReportDate(null);
|
|
||||||
if (viewMonth === 12) { setViewMonth(1); setViewYear(y => y + 1); }
|
|
||||||
else setViewMonth(m => m + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 过滤后的交易
|
|
||||||
const weeklyTxs = useMemo(() => {
|
|
||||||
return transactions.filter(t => t.date >= weeklyRange.startStr && t.date <= weeklyRange.endStr);
|
|
||||||
}, [transactions, weeklyRange]);
|
|
||||||
|
|
||||||
const monthlyTxs = useMemo(() => {
|
|
||||||
const prefix = `${viewYear}-${String(viewMonth).padStart(2, '0')}`;
|
|
||||||
return transactions.filter(t => t.date.startsWith(prefix));
|
|
||||||
}, [transactions, viewYear, viewMonth]);
|
|
||||||
|
|
||||||
const annualTxs = useMemo(() => {
|
|
||||||
return transactions.filter(t => t.date.startsWith(String(viewYear)));
|
|
||||||
}, [transactions, viewYear]);
|
|
||||||
|
|
||||||
// 周收支统计
|
|
||||||
const weeklyStats = useMemo(() => {
|
|
||||||
let income = 0;
|
|
||||||
let expense = 0;
|
|
||||||
for (const t of weeklyTxs) {
|
|
||||||
for (const p of t.postings) {
|
|
||||||
if (!p.amount) continue;
|
|
||||||
const amt = parseFloat(p.amount);
|
|
||||||
if (p.account.startsWith('Income')) income += -amt;
|
|
||||||
if (p.account.startsWith('Expenses')) expense += amt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { income, expense, net: income - expense, count: weeklyTxs.length };
|
|
||||||
}, [weeklyTxs]);
|
|
||||||
|
|
||||||
// 月度收支统计
|
|
||||||
const monthlyStats = useMemo(() => {
|
|
||||||
let income = 0;
|
|
||||||
let expense = 0;
|
|
||||||
for (const t of monthlyTxs) {
|
|
||||||
for (const p of t.postings) {
|
|
||||||
if (!p.amount) continue;
|
|
||||||
const amt = parseFloat(p.amount);
|
|
||||||
if (p.account.startsWith('Income')) income += -amt;
|
|
||||||
if (p.account.startsWith('Expenses')) expense += amt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { income, expense, net: income - expense, count: monthlyTxs.length };
|
|
||||||
}, [monthlyTxs]);
|
|
||||||
|
|
||||||
// 选中日期的交易明细
|
// 选中日期的交易明细
|
||||||
const selectedDayTxs = useMemo(() => {
|
const selectedDayTxs = useMemo(() => {
|
||||||
if (!selectedReportDate) return [];
|
if (!selectedReportDate) return [];
|
||||||
return monthlyTxs.filter(t => t.date.slice(0, 10) === selectedReportDate);
|
return rangeTxs.filter(tx => tx.date.slice(0, 10) === selectedReportDate);
|
||||||
}, [monthlyTxs, selectedReportDate]);
|
}, [rangeTxs, selectedReportDate]);
|
||||||
|
|
||||||
// 千分位格式化金额
|
// 千分位格式化金额
|
||||||
|
const localeTag = locale === 'zh' ? 'zh-CN' : 'en-US';
|
||||||
const fmtAmount = (num: number) => {
|
const fmtAmount = (num: number) => {
|
||||||
const formatted = num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
const formatted = num.toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
return num >= 0 ? `¥${formatted}` : `-¥${Math.abs(num).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
return num >= 0 ? `¥${formatted}` : `-¥${Math.abs(num).toLocaleString(localeTag, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 导出报表为图片
|
// 导出报表为图片
|
||||||
@@ -190,9 +147,11 @@ export default function ReportScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// AI / 纯文本月度总结
|
// AI / 纯文本月度总结(基于 anchor 所在月)
|
||||||
const handleMonthlySummary = async () => {
|
const handleMonthlySummary = async () => {
|
||||||
const stats = calculateMonthlyStats(transactions, viewYear, viewMonth);
|
const year = Number(anchor.slice(0, 4));
|
||||||
|
const month = Number(anchor.slice(5, 7));
|
||||||
|
const stats = calculateMonthlyStats(transactions, year, month);
|
||||||
if (aiEnabled && aiApiKey && aiBaseUrl) {
|
if (aiEnabled && aiApiKey && aiBaseUrl) {
|
||||||
try {
|
try {
|
||||||
const provider = new (class extends BaseOpenAIProvider {})({
|
const provider = new (class extends BaseOpenAIProvider {})({
|
||||||
@@ -202,7 +161,7 @@ export default function ReportScreen() {
|
|||||||
baseUrl: aiBaseUrl,
|
baseUrl: aiBaseUrl,
|
||||||
model: aiModel || 'glm-4-flash',
|
model: aiModel || 'glm-4-flash',
|
||||||
});
|
});
|
||||||
const result = await generateMonthlySummary(transactions, viewYear, viewMonth, provider);
|
const result = await generateMonthlySummary(transactions, year, month, provider);
|
||||||
setSummaryText(result.summary || generatePlainTextSummary(stats));
|
setSummaryText(result.summary || generatePlainTextSummary(stats));
|
||||||
} catch {
|
} catch {
|
||||||
setSummaryText(generatePlainTextSummary(stats));
|
setSummaryText(generatePlainTextSummary(stats));
|
||||||
@@ -213,202 +172,126 @@ export default function ReportScreen() {
|
|||||||
setSummaryModal(true);
|
setSummaryModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const monthLabel = `${viewYear}-${String(viewMonth).padStart(2, '0')}`;
|
const monthLabel = anchor.slice(0, 7);
|
||||||
|
const anchorYear = Number(anchor.slice(0, 4));
|
||||||
|
const anchorMonth = Number(anchor.slice(5, 7));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.report')}</Text>
|
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.report')}</Text>
|
||||||
<View style={styles.headerActions}>
|
<Pressable
|
||||||
<Pressable onPress={handleMonthlySummary} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}>
|
onPress={() => setMenuOpen(true)}
|
||||||
<Ionicons name="sparkles-outline" size={22} color={theme.colors.accent} />
|
style={({ pressed }) => [styles.iconBtn, dynamicStyles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}
|
||||||
</Pressable>
|
accessibilityRole="button"
|
||||||
<Pressable onPress={handleExportImage} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}>
|
accessibilityLabel={t('report.menu')}
|
||||||
<Ionicons name="share-outline" size={22} color={theme.colors.accent} />
|
>
|
||||||
</Pressable>
|
<Ionicons name="ellipsis-horizontal" size={22} color={theme.colors.accent} />
|
||||||
</View>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Tab 选择切换栏 */}
|
{/* Tab 选择切换栏(统一分段选择器) */}
|
||||||
<View style={[styles.tabContainer, { borderBottomColor: theme.colors.divider }]}>
|
<SegmentedControl
|
||||||
{([
|
options={[
|
||||||
{ key: 'weekly', label: t('report.tabWeekly') },
|
{ key: 'weekly', label: t('report.tabWeekly') },
|
||||||
{ key: 'monthly', label: t('report.tabMonthly') },
|
{ key: 'monthly', label: t('report.tabMonthly') },
|
||||||
{ key: 'annual', label: t('report.tabAnnual') }
|
{ key: 'annual', label: t('report.tabAnnual') },
|
||||||
] as const).map(tab => (
|
]}
|
||||||
<Pressable
|
value={period}
|
||||||
key={tab.key}
|
onChange={key => {
|
||||||
onPress={() => {
|
setPeriod(key as ReportPeriod);
|
||||||
setActiveTab(tab.key);
|
setSelectedReportDate(null);
|
||||||
setSelectedReportDate(null);
|
}}
|
||||||
}}
|
/>
|
||||||
style={[
|
|
||||||
styles.tabBtn,
|
|
||||||
activeTab === tab.key && { borderBottomColor: theme.colors.accent }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
theme.typography.body,
|
|
||||||
{
|
|
||||||
color: activeTab === tab.key ? theme.colors.accent : theme.colors.fgSecondary,
|
|
||||||
fontWeight: activeTab === tab.key ? '700' : '400',
|
|
||||||
paddingVertical: 10
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 日期选择切换器 */}
|
{/* 统一周期切换器 */}
|
||||||
{activeTab === 'weekly' && (
|
<PeriodSwitcher label={periodLabel} onPrev={() => shift(-1)} onNext={() => shift(1)} />
|
||||||
<View style={styles.monthSwitcher}>
|
|
||||||
<Pressable onPress={() => changeWeek(-7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
|
|
||||||
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
|
|
||||||
{weeklyRange.label}
|
|
||||||
</Text>
|
|
||||||
<Pressable onPress={() => changeWeek(7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
|
|
||||||
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'monthly' && (
|
|
||||||
<View style={styles.monthSwitcher}>
|
|
||||||
<Pressable onPress={prevMonth} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
|
|
||||||
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
|
|
||||||
{monthLabel}
|
|
||||||
</Text>
|
|
||||||
<Pressable onPress={nextMonth} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
|
|
||||||
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'annual' && (
|
|
||||||
<View style={styles.monthSwitcher}>
|
|
||||||
<Pressable onPress={() => setViewYear(y => y - 1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
|
|
||||||
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
|
|
||||||
{viewYear}年
|
|
||||||
</Text>
|
|
||||||
<Pressable onPress={() => setViewYear(y => y + 1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
|
|
||||||
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
|
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
|
||||||
>
|
>
|
||||||
{/* 数据空值判断 */}
|
{/* 数据空值判断 */}
|
||||||
{((activeTab === 'weekly' && weeklyTxs.length === 0) ||
|
{rangeTxs.length === 0 ? (
|
||||||
(activeTab === 'monthly' && monthlyTxs.length === 0) ||
|
<EmptyState icon="bar-chart-outline" title={t('report.title')} description={t('report.empty')} />
|
||||||
(activeTab === 'annual' && annualTxs.length === 0)) ? (
|
|
||||||
<Card title={t('report.title')}>
|
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
|
||||||
{t('report.empty')}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* 周报表看板 */}
|
{/* 周/月收支看板 */}
|
||||||
{activeTab === 'weekly' && (
|
{period !== 'annual' && (
|
||||||
<>
|
<>
|
||||||
<Card title={t('report.weeklyTitle')}>
|
<RangeStatsCard
|
||||||
<View style={styles.statsRow}>
|
period={period === 'weekly' ? 'weekly' : 'monthly'}
|
||||||
<View style={styles.statItem}>
|
incomeText={fmtAmount(rangeStats.income)}
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyIncome')}</Text>
|
expenseText={fmtAmount(-rangeStats.expense)}
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.income)}</Text>
|
netText={fmtAmount(rangeStats.net)}
|
||||||
</View>
|
count={rangeStats.count}
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyExpense')}</Text>
|
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>-{fmtAmount(weeklyStats.expense)}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyNet')}</Text>
|
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.net)}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
|
||||||
{t('report.weeklyStats', { count: weeklyStats.count })}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<CategoryPie transactions={weeklyTxs} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 月度报表看板 */}
|
|
||||||
{activeTab === 'monthly' && (
|
|
||||||
<>
|
|
||||||
<Card title={`${monthLabel} 收支报告`}>
|
|
||||||
<View style={styles.statsRow}>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyIncome')}</Text>
|
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.income)}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyExpense')}</Text>
|
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>-{fmtAmount(monthlyStats.expense)}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyNet')}</Text>
|
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.net)}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
|
||||||
{t('report.monthlyStats', { count: monthlyStats.count })}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<CategoryPie transactions={monthlyTxs} />
|
|
||||||
|
|
||||||
<CalendarView
|
|
||||||
transactions={transactions}
|
|
||||||
year={viewYear}
|
|
||||||
month={viewMonth}
|
|
||||||
onDayPress={setSelectedReportDate}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedReportDate && selectedReportDate.startsWith(monthLabel) && (
|
<CategoryPie transactions={rangeTxs} />
|
||||||
<Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}>
|
|
||||||
{selectedDayTxs.map(t => (
|
|
||||||
<TransactionCard
|
|
||||||
key={t.id}
|
|
||||||
transaction={t}
|
|
||||||
onPress={() => router.push(`/transaction/${t.id}`)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{selectedDayTxs.length === 0 && (
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 8 }]}>
|
|
||||||
{t('calendar.noDayTx')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<NetWorthChart transactions={transactions} dates={netWorthDates} />
|
{/* 月 Tab 专属:日历 + 选中日期明细 + 净资产趋势 */}
|
||||||
|
{period === 'monthly' && (
|
||||||
|
<>
|
||||||
|
<CalendarView
|
||||||
|
transactions={transactions}
|
||||||
|
year={anchorYear}
|
||||||
|
month={anchorMonth}
|
||||||
|
onDayPress={setSelectedReportDate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedReportDate && selectedReportDate.startsWith(monthLabel) && (
|
||||||
|
<Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}>
|
||||||
|
{selectedDayTxs.map(tx => (
|
||||||
|
<TransactionCard
|
||||||
|
key={tx.id}
|
||||||
|
transaction={tx}
|
||||||
|
onPress={() => router.push(`/transaction/${tx.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{selectedDayTxs.length === 0 && (
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 8 }]}>
|
||||||
|
{t('calendar.noDayTx')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NetWorthChart transactions={transactions} dates={netWorthDates} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 年度报表看板 */}
|
{/* 年度报表看板 */}
|
||||||
{activeTab === 'annual' && (
|
{period === 'annual' && (
|
||||||
<AnnualReport transactions={transactions} year={viewYear} />
|
<AnnualReport transactions={transactions} year={anchorYear} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* ⋯ 菜单 */}
|
||||||
|
<BottomSheet visible={menuOpen} onClose={() => setMenuOpen(false)} title={t('report.menu')}>
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [styles.menuRow, { opacity: pressed ? 0.6 : 1 }]}
|
||||||
|
onPress={() => { setMenuOpen(false); handleMonthlySummary(); }}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('report.aiSummary')}
|
||||||
|
>
|
||||||
|
<Ionicons name="sparkles-outline" size={20} color={theme.colors.accent} />
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{t('report.aiSummary')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [styles.menuRow, { opacity: pressed ? 0.6 : 1 }]}
|
||||||
|
onPress={() => { setMenuOpen(false); handleExportImage(); }}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('report.exportImage')}
|
||||||
|
>
|
||||||
|
<Ionicons name="share-outline" size={20} color={theme.colors.accent} />
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{t('report.exportImage')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</BottomSheet>
|
||||||
|
|
||||||
{/* AI 总结弹窗 */}
|
{/* AI 总结弹窗 */}
|
||||||
<Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}>
|
<Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}>
|
||||||
<View style={commonStyles.modalOverlay}>
|
<View style={commonStyles.modalOverlay}>
|
||||||
@@ -434,13 +317,7 @@ export default function ReportScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
|
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
|
||||||
headerActions: { flexDirection: 'row', gap: 16 },
|
iconBtn: {},
|
||||||
iconBtn: { padding: 4 },
|
|
||||||
monthSwitcher: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20, paddingBottom: 12 },
|
|
||||||
monthBtn: { width: 32, height: 32, borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center' },
|
|
||||||
content: { padding: 16, paddingBottom: 96 },
|
content: { padding: 16, paddingBottom: 96 },
|
||||||
tabContainer: { flexDirection: 'row', justifyContent: 'space-around', borderBottomWidth: StyleSheet.hairlineWidth, paddingHorizontal: 16, marginBottom: 8 },
|
menuRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14 },
|
||||||
tabBtn: { flex: 1, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
|
|
||||||
statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' },
|
|
||||||
statItem: { alignItems: 'center', gap: 4 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
+140
-66
@@ -1,85 +1,144 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { SectionList, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter, type Href } from 'expo-router';
|
import { useRouter, type Href } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { Touchable } from '../../components/ui/Touchable';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
label: string;
|
||||||
|
path: Href;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavSection {
|
||||||
|
title: string;
|
||||||
|
data: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const renderNavLink = (icon: keyof typeof Ionicons.glyphMap, label: string, path: Href) => (
|
// 使用 useMemo 缓存分组数据,避免每次渲染重新创建
|
||||||
<Pressable
|
const sections: NavSection[] = useMemo(() => [
|
||||||
onPress={() => router.push(path)}
|
{
|
||||||
accessibilityRole="link"
|
title: t('settings.groupAccount'),
|
||||||
accessibilityLabel={label}
|
data: [
|
||||||
style={({ pressed }) => [
|
{ icon: 'wallet-outline', label: t('account.title'), path: '/account' },
|
||||||
styles.navRow,
|
{ icon: 'list', label: t('settings.categories'), path: '/category' },
|
||||||
{
|
{ icon: 'pricetags', label: t('settings.tags'), path: '/tag' },
|
||||||
borderBottomColor: theme.colors.divider,
|
{ icon: 'pie-chart', label: t('settings.budgets'), path: '/budget' },
|
||||||
opacity: pressed ? 0.6 : 1,
|
{ icon: 'card-outline', label: t('settings.creditCards'), path: '/credit-card' as Href },
|
||||||
},
|
{ icon: 'text-outline', label: t('remark.title'), path: '/remark-template' as Href },
|
||||||
]}
|
],
|
||||||
>
|
},
|
||||||
<Ionicons name={icon} size={20} color={theme.colors.accent} />
|
{
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 12, fontFamily: theme.typography.body.fontFamily }]}>
|
title: t('settings.groupAutomation'),
|
||||||
{label}
|
data: [
|
||||||
|
{ icon: 'git-branch-outline', label: t('tab.rules'), path: '/rules' },
|
||||||
|
{ icon: 'repeat-outline', label: t('settings.recurringTitle'), path: '/recurring' },
|
||||||
|
{ icon: 'flash-outline', label: t('automation.title'), path: '/automation' as Href },
|
||||||
|
{ icon: 'download-outline', label: t('settings.import'), path: '/import' as Href },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('settings.groupData'),
|
||||||
|
data: [
|
||||||
|
{ icon: 'cloud-upload-outline', label: t('settings.syncBackup'), path: '/settings/sync' },
|
||||||
|
{ icon: 'pulse-outline', label: t('settings.diagnostics'), path: '/settings/diagnostics' as Href },
|
||||||
|
{ icon: 'document-text-outline', label: t('settings.appLogs'), path: '/settings/logs' as Href },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('settings.groupPreferences'),
|
||||||
|
data: [
|
||||||
|
{ icon: 'phone-portrait-outline', label: t('settings.preferencesEntry'), path: '/settings/preferences' },
|
||||||
|
{ icon: 'cog-outline', label: t('settings.llmTitle'), path: '/settings/ai' },
|
||||||
|
{ icon: 'chatbubble-ellipses-outline', label: t('ai.chatTitle'), path: '/ai/chat' as Href },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
], [t]);
|
||||||
|
|
||||||
|
const renderSectionHeader = ({ section }: { section: NavSection }) => (
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontWeight: '600', letterSpacing: 0.3, marginBottom: 6, marginTop: theme.spacing.md }]}>
|
||||||
|
{section.title}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: NavItem }) => {
|
||||||
|
const section = sections.find(s => s.data.includes(item));
|
||||||
|
const isLast = section ? section.data[section.data.length - 1] === item : false;
|
||||||
|
const isFirst = section ? section.data[0] === item : false;
|
||||||
|
return (
|
||||||
|
<Touchable
|
||||||
|
onPress={() => router.push(item.path)}
|
||||||
|
accessibilityRole="link"
|
||||||
|
accessibilityLabel={item.label}
|
||||||
|
style={[
|
||||||
|
styles.navRow,
|
||||||
|
{
|
||||||
|
borderBottomColor: theme.colors.divider,
|
||||||
|
borderBottomWidth: isLast ? 0 : StyleSheet.hairlineWidth,
|
||||||
|
backgroundColor: theme.colors.bgSecondary,
|
||||||
|
// 分组首行圆角顶 + 末行圆角底,配合 overflow hidden 形成圆角卡片组
|
||||||
|
borderTopLeftRadius: isFirst ? theme.radii.lg : 0,
|
||||||
|
borderTopRightRadius: isFirst ? theme.radii.lg : 0,
|
||||||
|
borderBottomLeftRadius: isLast ? theme.radii.lg : 0,
|
||||||
|
borderBottomRightRadius: isLast ? theme.radii.lg : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.navIcon}>
|
||||||
|
<Ionicons name={item.icon} size={20} color={theme.colors.accent} />
|
||||||
|
</View>
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
|
||||||
|
</Touchable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AboutFooter = () => (
|
||||||
|
<View style={{ marginTop: theme.spacing.md }}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontWeight: '600', letterSpacing: 0.3, marginBottom: 6 }]}>
|
||||||
|
{t('settings.aboutTitle')}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
|
<View style={[styles.aboutCard, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, borderColor: theme.colors.border }]}>
|
||||||
</Pressable>
|
<View style={styles.aboutRow}>
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
|
||||||
|
{t('app.name')}
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{t('settings.aboutVersion')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, lineHeight: 18 }]}>
|
||||||
|
{t('app.tagline')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('settings.title')}</Text>
|
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.settings')}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
<SectionList
|
||||||
{/* 数据与基础配置跳转 */}
|
sections={sections}
|
||||||
<Card title={t('settings.dataManagement')}>
|
keyExtractor={(item) => item.label}
|
||||||
<View style={styles.listGroup}>
|
renderSectionHeader={renderSectionHeader}
|
||||||
{renderNavLink('git-branch-outline', t('tab.rules'), '/rules')}
|
renderItem={renderItem}
|
||||||
{renderNavLink('wallet-outline', t('account.title'), '/account')}
|
ListFooterComponent={AboutFooter}
|
||||||
{renderNavLink('list', t('settings.categories'), '/category')}
|
stickySectionHeadersEnabled={false}
|
||||||
{renderNavLink('pricetags', t('settings.tags'), '/tag')}
|
contentContainerStyle={styles.content}
|
||||||
{renderNavLink('pie-chart', t('settings.budgets'), '/budget')}
|
/>
|
||||||
{renderNavLink('repeat-outline', t('settings.recurringTitle'), '/recurring')}
|
|
||||||
{renderNavLink('card-outline', t('settings.creditCards'), '/credit-card' as Href)}
|
|
||||||
{renderNavLink('text-outline', t('remark.title'), '/remark-template' as Href)}
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 系统及高级配置跳转 */}
|
|
||||||
<Card title={t('settings.systemFeaturesTitle')}>
|
|
||||||
<View style={styles.listGroup}>
|
|
||||||
{renderNavLink('cog-outline', t('settings.aiSettingsTitle'), '/settings/ai')}
|
|
||||||
{renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)}
|
|
||||||
{renderNavLink('cloud-upload-outline', t('settings.syncSettingsTitle'), '/settings/sync')}
|
|
||||||
{renderNavLink('phone-portrait-outline', t('settings.preferencesTitle'), '/settings/preferences')}
|
|
||||||
{renderNavLink('flash-outline', t('automation.title'), '/automation' as Href)}
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 关于 */}
|
|
||||||
<Card title={t('settings.aboutTitle')}>
|
|
||||||
<View style={styles.aboutRow}>
|
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
|
|
||||||
{t('app.name')}
|
|
||||||
</Text>
|
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
|
|
||||||
{t('settings.aboutVersion')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, lineHeight: 18 }]}>
|
|
||||||
{t('app.tagline')}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,8 +146,23 @@ export default function SettingsScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||||||
content: { padding: 16, paddingBottom: 64 },
|
content: { paddingHorizontal: 16, paddingBottom: 64 },
|
||||||
listGroup: { borderRadius: 6, overflow: 'hidden' },
|
navRow: {
|
||||||
navRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 12, borderBottomWidth: 1 },
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
// 行内自带左右 padding,图标与文字不再贴边
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
navIcon: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 14,
|
||||||
|
},
|
||||||
|
aboutCard: { padding: 16, borderWidth: StyleSheet.hairlineWidth },
|
||||||
aboutRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
aboutRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||||
});
|
});
|
||||||
|
|||||||
+158
-163
@@ -1,71 +1,121 @@
|
|||||||
/**
|
/**
|
||||||
* 交易列表页(plan.md「1.4 交易搜索页面」)。
|
* 交易页(spec §7.2):搜索优先 + 日期分组时间线。
|
||||||
*
|
* 搜索框常驻;方向 chip;高级筛选收进 FilterSheet(账户/日期/金额区间);
|
||||||
* 功能:
|
* 列表按日期分组(今天/昨天/具体日期 + 当日收支小计);左滑复制/删除。
|
||||||
* - SearchBar 关键字搜索(narration/payee)
|
* 解析诊断已移至 我的 → 数据 → 解析诊断(/settings/diagnostics)。
|
||||||
* - 方向筛选(全部/支出/收入/转账)
|
|
||||||
* - useSearch 多维筛选
|
|
||||||
* - TransactionCard 渲染 + 点击跳详情
|
|
||||||
* - 底部折叠的解析诊断
|
|
||||||
*/
|
*/
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
import { Pressable, SectionList, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
|
import { useMetadataStore } from '../../store/metadataStore';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import { useNumpadUiStore } from '../../store/numpadUiStore';
|
||||||
import { useTheme, createCommonStyles } from '../../theme';
|
import { useTheme, createCommonStyles } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { SearchBar } from '../../components/ui/SearchBar';
|
||||||
import { SearchBar } from '../../components/SearchBar';
|
import { FilterSheet, type AdvancedFilterValues } from '../../components/form/FilterSheet';
|
||||||
import { TransactionCard } from '../../components/TransactionCard';
|
import { SwipeableTransactionCard } from '../../components/transaction/SwipeableTransactionCard';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { groupTransactionsByDate } from '../../components/transaction/transactionGroups';
|
||||||
import { useSearch, type SearchFilters } from '../../hooks/useSearch';
|
import { useSearch, type SearchFilters } from '../../hooks/useSearch';
|
||||||
|
import { toDateString } from '../../domain/core/decimal';
|
||||||
|
import type { Transaction } from '../../domain/core/types';
|
||||||
|
import { EmptyState } from '../../components/ui/EmptyState';
|
||||||
|
import { useToast } from '../../components/ui/Toast';
|
||||||
|
|
||||||
type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';
|
type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';
|
||||||
|
|
||||||
|
const EMPTY_FILTERS: AdvancedFilterValues = { account: '', dateFrom: '', dateTo: '', amountMin: '', amountMax: '' };
|
||||||
|
|
||||||
export default function TransactionsScreen() {
|
export default function TransactionsScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const commonStyles = createCommonStyles(theme);
|
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const ledger = useLedgerStore(s => s.ledger);
|
const ledger = useLedgerStore(s => s.ledger);
|
||||||
|
const deleteTransaction = useLedgerStore(s => s.deleteTransaction);
|
||||||
|
const restoreTransaction = useLedgerStore(s => s.restoreTransaction);
|
||||||
|
const categories = useMetadataStore(s => s.categories);
|
||||||
|
const recentSearches = useSettingsStore(s => s.recentSearches);
|
||||||
|
const addRecentSearch = useSettingsStore(s => s.addRecentSearch);
|
||||||
|
const clearRecentSearches = useSettingsStore(s => s.clearRecentSearches);
|
||||||
|
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [direction, setDirection] = useState<DirectionFilter>('all');
|
const [direction, setDirection] = useState<DirectionFilter>('all');
|
||||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
const [advanced, setAdvanced] = useState<AdvancedFilterValues>(EMPTY_FILTERS);
|
||||||
const [accountFilter, setAccountFilter] = useState('');
|
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
|
||||||
const [dateTo, setDateTo] = useState('');
|
|
||||||
|
|
||||||
|
// 性能说明:每次 ledger 变化时全量重排序 O(n log n)。
|
||||||
|
// 当前可接受:移动端账本通常 < 5000 笔,排序耗时 < 10ms。
|
||||||
|
// 若未来数据量增大,可考虑:
|
||||||
|
// 1. 在 ledgerStore 层维护已排序的 transactions 数组
|
||||||
|
// 2. 新增交易时使用二分插入而非全量重排
|
||||||
const allTransactions = useMemo(() => {
|
const allTransactions = useMemo(() => {
|
||||||
if (!ledger?.transactions) return [];
|
if (!ledger?.transactions) return [];
|
||||||
return ledger.transactions
|
return ledger.transactions
|
||||||
.map((tx, idx) => ({ tx, idx }))
|
.map((tx, idx) => ({ tx, idx }))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
|
||||||
const dateCompare = b.tx.date.localeCompare(a.tx.date);
|
|
||||||
if (dateCompare !== 0) return dateCompare;
|
|
||||||
return b.idx - a.idx;
|
|
||||||
})
|
|
||||||
.map(item => item.tx);
|
.map(item => item.tx);
|
||||||
}, [ledger]);
|
}, [ledger]);
|
||||||
const allAccounts = useMemo(() => {
|
|
||||||
const accts = new Set<string>();
|
|
||||||
for (const tx of allTransactions) {
|
|
||||||
for (const p of tx.postings) accts.add(p.account);
|
|
||||||
}
|
|
||||||
return Array.from(accts).sort();
|
|
||||||
}, [allTransactions]);
|
|
||||||
|
|
||||||
const filters: SearchFilters = useMemo(() => ({
|
const filters: SearchFilters = useMemo(() => ({
|
||||||
keyword: keyword || undefined,
|
keyword: keyword || undefined,
|
||||||
direction: direction === 'all' ? undefined : direction,
|
direction: direction === 'all' ? undefined : direction,
|
||||||
account: accountFilter || undefined,
|
account: advanced.account || undefined,
|
||||||
dateFrom: dateFrom || undefined,
|
dateFrom: advanced.dateFrom || undefined,
|
||||||
dateTo: dateTo || undefined,
|
dateTo: advanced.dateTo || undefined,
|
||||||
}), [keyword, direction, accountFilter, dateFrom, dateTo]);
|
amountMin: advanced.amountMin || undefined,
|
||||||
|
amountMax: advanced.amountMax || undefined,
|
||||||
|
}), [keyword, direction, advanced]);
|
||||||
|
|
||||||
const filtered = useSearch(allTransactions, filters);
|
const filtered = useSearch(allTransactions, filters);
|
||||||
|
const sections = useMemo(() =>
|
||||||
|
groupTransactionsByDate(filtered).map(g => ({ ...g, data: g.items })),
|
||||||
|
[filtered]);
|
||||||
|
|
||||||
|
const advancedActive = advanced.account !== '' || advanced.dateFrom !== '' || advanced.dateTo !== '' || advanced.amountMin !== '' || advanced.amountMax !== '';
|
||||||
|
|
||||||
|
const todayStr = toDateString(new Date());
|
||||||
|
const yesterdayStr = toDateString(new Date(Date.now() - 86400000));
|
||||||
|
const dateLabel = (date: string) =>
|
||||||
|
date === todayStr ? t('transactions.today') : date === yesterdayStr ? t('transactions.yesterday') : date;
|
||||||
|
|
||||||
|
const categoryIdFor = useCallback((tx: Transaction): string | undefined => {
|
||||||
|
const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
|
||||||
|
return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
// 左滑:复制(预填今天的录入页)
|
||||||
|
const handleDuplicate = (tx: Transaction) => {
|
||||||
|
const draftJson = JSON.stringify({
|
||||||
|
date: todayStr,
|
||||||
|
payee: tx.payee,
|
||||||
|
narration: tx.narration,
|
||||||
|
tags: tx.tags,
|
||||||
|
// cost/price 不带:复制是新建交易,丢 cost 属可接受简化(含 cost 的交易可从编辑入口进高级模式)
|
||||||
|
postings: tx.postings.map(p => ({ account: p.account, amount: p.amount, currency: p.currency })),
|
||||||
|
});
|
||||||
|
useNumpadUiStore.getState().open({ draftJson });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 左滑:删除(确认后从 main.bean 移除)
|
||||||
|
// 删除:非阻断 toast + 撤销(取代阻断式 Alert 确认)
|
||||||
|
const handleDelete = (tx: Transaction) => {
|
||||||
|
const name = tx.narration || tx.payee || tx.date.slice(0, 10);
|
||||||
|
deleteTransaction(tx.raw)
|
||||||
|
.then(() => {
|
||||||
|
showToast(t('transactions.deleted', { name }), 'success', {
|
||||||
|
label: t('common.undo'),
|
||||||
|
onPress: () => {
|
||||||
|
restoreTransaction(tx.raw).catch(e => showToast(String(e), 'error'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(e => showToast(String(e), 'error'));
|
||||||
|
};
|
||||||
|
|
||||||
const directionTabs: { key: DirectionFilter; label: string }[] = [
|
const directionTabs: { key: DirectionFilter; label: string }[] = [
|
||||||
{ key: 'all', label: t('transactions.filterAll') },
|
{ key: 'all', label: t('transactions.filterAll') },
|
||||||
@@ -80,150 +130,93 @@ export default function TransactionsScreen() {
|
|||||||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.transactions')}</Text>
|
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.transactions')}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SearchBar
|
<SearchBar value={keyword} onChangeText={setKeyword} placeholder={t('transactions.searchPlaceholder')} onSubmit={() => addRecentSearch(keyword)} />
|
||||||
value={keyword}
|
{keyword.trim() === '' && recentSearches.length > 0 && (
|
||||||
onChangeText={setKeyword}
|
<View style={[{ flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: 8, paddingHorizontal: 16, marginBottom: 8 }]}>
|
||||||
placeholder={t('transactions.searchPlaceholder')}
|
<Ionicons name="time-outline" size={14} color={theme.colors.fgSecondary} />
|
||||||
/>
|
{recentSearches.map(s => (
|
||||||
|
<Pressable key={s} onPress={() => setKeyword(s)} style={commonStyles.chip}>
|
||||||
{/* 方向筛选 */}
|
<Text style={commonStyles.chipText}>{s}</Text>
|
||||||
<View style={styles.filterRow}>
|
</Pressable>
|
||||||
{directionTabs.map(tab => (
|
))}
|
||||||
<Pressable
|
<Pressable onPress={() => clearRecentSearches()} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('transactions.clearHistory')}>
|
||||||
key={tab.key}
|
<Ionicons name="close-circle-outline" size={16} color={theme.colors.fgSecondary} />
|
||||||
onPress={() => setDirection(tab.key)}
|
|
||||||
style={[
|
|
||||||
commonStyles.chip,
|
|
||||||
direction === tab.key && commonStyles.chipActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
commonStyles.chipText,
|
|
||||||
direction === tab.key && commonStyles.chipTextActive,
|
|
||||||
]}>
|
|
||||||
{tab.label}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 高级筛选切换 */}
|
|
||||||
<View style={[styles.filterRow, { paddingBottom: 0 }]}>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
commonStyles.chip,
|
|
||||||
showAdvancedFilters && commonStyles.chipActive,
|
|
||||||
{ opacity: pressed ? 0.8 : 1 },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
commonStyles.chipText,
|
|
||||||
showAdvancedFilters && commonStyles.chipTextActive,
|
|
||||||
{ fontSize: 12 },
|
|
||||||
]}>
|
|
||||||
{t('transactions.advancedFilter')}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 高级筛选面板 */}
|
|
||||||
{showAdvancedFilters && (
|
|
||||||
<View style={{ paddingHorizontal: 16, paddingBottom: 8, gap: 6 }}>
|
|
||||||
{/* 账户筛选 */}
|
|
||||||
<TextInput
|
|
||||||
value={accountFilter}
|
|
||||||
onChangeText={setAccountFilter}
|
|
||||||
placeholder={t('transactions.accountFilterPlaceholder')}
|
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
|
||||||
style={commonStyles.input}
|
|
||||||
/>
|
|
||||||
{/* 日期范围 */}
|
|
||||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
||||||
<TextInput
|
|
||||||
value={dateFrom}
|
|
||||||
onChangeText={setDateFrom}
|
|
||||||
placeholder={t('transactions.dateFrom')}
|
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
|
||||||
style={[commonStyles.input, { flex: 1 }]}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
value={dateTo}
|
|
||||||
onChangeText={setDateTo}
|
|
||||||
placeholder={t('transactions.dateTo')}
|
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
|
||||||
style={[commonStyles.input, { flex: 1 }]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FlatList
|
{/* 方向筛选 + 高级筛选入口 */}
|
||||||
data={filtered}
|
<View style={styles.filterRow}>
|
||||||
keyExtractor={(item) => item.id}
|
{directionTabs.map(tab => (
|
||||||
|
<Pressable key={tab.key} onPress={() => setDirection(tab.key)}
|
||||||
|
style={[commonStyles.chip, direction === tab.key && commonStyles.chipActive]}>
|
||||||
|
<Text style={[commonStyles.chipText, direction === tab.key && commonStyles.chipTextActive]}>{tab.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
<View style={styles.flex1} />
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setFilterSheetOpen(true)}
|
||||||
|
style={[commonStyles.chip, advancedActive && commonStyles.chipActive, styles.filterBtn]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('transactions.filter')}
|
||||||
|
>
|
||||||
|
<Ionicons name="options-outline" size={14} color={advancedActive ? theme.colors.fgInverse : theme.colors.fgSecondary} />
|
||||||
|
<Text style={[commonStyles.chipText, advancedActive && commonStyles.chipTextActive]}>{t('transactions.filter')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SectionList
|
||||||
|
sections={sections}
|
||||||
|
keyExtractor={(item, index) => `${item.id}-${index}`}
|
||||||
renderItem={({ item: tx }) => (
|
renderItem={({ item: tx }) => (
|
||||||
<TransactionCard
|
<SwipeableTransactionCard
|
||||||
transaction={tx}
|
transaction={tx}
|
||||||
|
categoryId={categoryIdFor(tx)}
|
||||||
|
showDate={false}
|
||||||
|
highlight={keyword}
|
||||||
onPress={() => router.push(`/transaction/${tx.id}`)}
|
onPress={() => router.push(`/transaction/${tx.id}`)}
|
||||||
|
onDuplicate={handleDuplicate}
|
||||||
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
ListHeaderComponent={
|
renderSectionHeader={({ section }) => (
|
||||||
<View>
|
<View style={styles.sectionHeader}>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingHorizontal: 16, paddingBottom: 8 }]}>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
|
||||||
{filtered.length === allTransactions.length
|
{dateLabel(section.date)}
|
||||||
? t('transactions.countTotal', { count: filtered.length })
|
</Text>
|
||||||
: t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })}
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
|
||||||
|
{t('transactions.daySummary', { income: `+${section.income}`, expense: `-${section.expense}` })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingBottom: 8 }]}>
|
||||||
|
{filtered.length === allTransactions.length
|
||||||
|
? t('transactions.countTotal', { count: filtered.length })
|
||||||
|
: t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })}
|
||||||
|
</Text>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<Card title={t('transactions.noMatchTitle')}>
|
<EmptyState
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
icon={allTransactions.length === 0 ? 'receipt-outline' : 'search-outline'}
|
||||||
{allTransactions.length === 0
|
title={t('transactions.noMatchTitle')}
|
||||||
? t('transactions.noMatchEmpty')
|
description={allTransactions.length === 0 ? t('transactions.noMatchEmpty') : t('transactions.noMatchFiltered')}
|
||||||
: t('transactions.noMatchFiltered')}
|
/>
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
}
|
|
||||||
ListFooterComponent={
|
|
||||||
<View>
|
|
||||||
{/* 诊断(折叠) */}
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setShowDiagnostics(!showDiagnostics)}
|
|
||||||
style={[styles.diagToggle, { flexDirection: 'row', alignItems: 'center', gap: 4 }]}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={showDiagnostics ? 'chevron-down-outline' : 'chevron-forward-outline'}
|
|
||||||
size={14}
|
|
||||||
color={theme.colors.fgSecondary}
|
|
||||||
/>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{t('diagnostics.title')}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
{showDiagnostics && (
|
|
||||||
<Card title={t('diagnostics.title')}>
|
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
|
||||||
{t('diagnostics.syntaxErrors', { count: ledger?.diagnostics.length ?? 0 })}
|
|
||||||
</Text>
|
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
|
||||||
{t('diagnostics.unsupported', { count: ledger?.unsupported.length ?? 0 })}
|
|
||||||
</Text>
|
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
|
||||||
{t('diagnostics.balanceAssertions', { count: ledger?.balances.length ?? 0 })}
|
|
||||||
</Text>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
|
||||||
{t('diagnostics.hint')}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
}
|
}
|
||||||
contentContainerStyle={styles.content}
|
contentContainerStyle={styles.content}
|
||||||
|
stickySectionHeadersEnabled={false}
|
||||||
initialNumToRender={10}
|
initialNumToRender={10}
|
||||||
maxToRenderPerBatch={10}
|
maxToRenderPerBatch={10}
|
||||||
windowSize={5}
|
windowSize={5}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FilterSheet
|
||||||
|
visible={filterSheetOpen}
|
||||||
|
onClose={() => setFilterSheetOpen(false)}
|
||||||
|
values={advanced}
|
||||||
|
onChange={setAdvanced}
|
||||||
|
onReset={() => setAdvanced(EMPTY_FILTERS)}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -231,7 +224,9 @@ export default function TransactionsScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
|
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
|
||||||
filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, paddingBottom: 8 },
|
filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, paddingBottom: 8, alignItems: 'center' },
|
||||||
|
filterBtn: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||||
|
flex1: { flex: 1 },
|
||||||
content: { padding: 16, paddingTop: 8 },
|
content: { padding: 16, paddingTop: 8 },
|
||||||
diagToggle: { paddingVertical: 12, alignItems: 'center' },
|
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export function ErrorScreen({ error, resetError }: ErrorBoundaryProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
{data.stack && (
|
{data.stack && (
|
||||||
<View style={[styles.stackBox, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
|
<View style={[styles.stackBox, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
|
||||||
|
{/* monospace 有意保留:堆栈文本需要等宽对齐(P5 审计允许项) */}
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]} numberOfLines={10}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]} numberOfLines={10}>
|
||||||
{data.stack}
|
{data.stack}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
+175
-160
@@ -3,47 +3,73 @@ import { StatusBar } from 'expo-status-bar';
|
|||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { AppState, DeviceEventEmitter, Platform, Text, View } from 'react-native';
|
import { AppState, DeviceEventEmitter, Platform, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
import { ThemeProvider, useTheme } from '../theme';
|
import { ThemeProvider, useTheme } from '../theme';
|
||||||
import { useLedgerStore } from '../store/ledgerStore';
|
import { useLedgerStore } from '../store/ledgerStore';
|
||||||
import { useImportStore } from '../store/importStore';
|
import { useImportStore } from '../store/importStore';
|
||||||
import { useSettingsStore } from '../store/settingsStore';
|
import { useSettingsStore } from '../store/settingsStore';
|
||||||
import { useMetadataStore } from '../store/metadataStore';
|
import { useMetadataStore } from '../store/metadataStore';
|
||||||
import { useAutomationStore } from '../store/automationStore';
|
import { parseDecimal } from '../domain/core/decimal';
|
||||||
import { FileSystemBackend } from '../services/fileSystemBackend';
|
import { FileSystemBackend } from '../services/data/fileSystemBackend';
|
||||||
|
import { useNumpadUiStore } from '../store/numpadUiStore';
|
||||||
import { useT } from '../i18n';
|
import { useT } from '../i18n';
|
||||||
import { LockScreen } from '../components/LockScreen';
|
import { LockScreen } from '../components/layout/LockScreen';
|
||||||
|
import { NumpadSheetHost } from '../components/form/NumpadSheetHost';
|
||||||
|
import { ToastProvider } from '../components/ui/Toast';
|
||||||
import { OnboardingScreen } from './_onboarding';
|
import { OnboardingScreen } from './_onboarding';
|
||||||
import { setupDeepLinking } from '../services/deepLink';
|
import { setupDeepLinking } from '../services/deepLink';
|
||||||
import { initPersistence } from '../store/storePersistence';
|
import { initPersistence, flushPersistence } from '../store/storePersistence';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { parseNotification } from '../services/notification';
|
import { parseNotification } from '../services/notification';
|
||||||
import { parseSms } from '../services/sms';
|
import { parseSms } from '../services/sms';
|
||||||
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts } from '../services/automationPipeline';
|
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts, loadProcessedTxKeys, ensureBillingListenerRegistered, removeBillingListener } from '../services/automation/automationPipeline';
|
||||||
import { getAccessibilityBridge } from '../services/accessibilityBridge';
|
import { getAccessibilityBridge } from '../services/automation/accessibilityBridge';
|
||||||
|
import { pushFloatingUiConfig } from '../services/automation/floatingUiConfig';
|
||||||
|
import { ensureOcrModels } from '../services/ocr/modelDownloader';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import {
|
import { sanitizeLogText } from '../utils/sanitize';
|
||||||
useFonts,
|
import { expoLogBackend } from '../services/logBackend';
|
||||||
Quicksand_400Regular,
|
|
||||||
Quicksand_500Medium,
|
|
||||||
Quicksand_600SemiBold,
|
|
||||||
Quicksand_700Bold,
|
|
||||||
} from '@expo-google-fonts/quicksand';
|
|
||||||
import {
|
|
||||||
Caveat_400Regular,
|
|
||||||
Caveat_500Medium,
|
|
||||||
Caveat_600SemiBold,
|
|
||||||
Caveat_700Bold,
|
|
||||||
} from '@expo-google-fonts/caveat';
|
|
||||||
|
|
||||||
let lastSignature = '';
|
let lastSignature = '';
|
||||||
let lastProcessedTime = 0;
|
let lastProcessedTime = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原生浮层 UI 配置同步(P6 FloatingUiConfig 契约):
|
||||||
|
* 启动 / 主题切换 / 语言切换时向原生推送 colors + labels。
|
||||||
|
* 挂在 ThemeProvider 内部,渲染 null。
|
||||||
|
*/
|
||||||
|
function FloatingUiConfigSyncer() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const locale = useSettingsStore(s => s.locale);
|
||||||
|
const t = useT();
|
||||||
|
useEffect(() => {
|
||||||
|
// useT() 每次渲染返回新 bind,故依赖用 locale 而非 t;
|
||||||
|
// locale 变化时 useT 内部已将 i18n.locale 指向新语言
|
||||||
|
void pushFloatingUiConfig(theme, t);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- t 每次渲染都是新引用,用 locale 代替
|
||||||
|
}, [theme, locale]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 原生悬浮窗跳转 App 事件类型 */
|
||||||
|
interface NativeOpenAppEvent {
|
||||||
|
draftId?: string;
|
||||||
|
confirmed?: boolean;
|
||||||
|
account?: string;
|
||||||
|
category?: string;
|
||||||
|
amount?: string;
|
||||||
|
time?: string;
|
||||||
|
direction?: string;
|
||||||
|
merchant?: string;
|
||||||
|
narration?: string;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */
|
/** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */
|
||||||
const SAMPLE_LEDGER = {
|
const SAMPLE_LEDGER = {
|
||||||
path: 'main.bean',
|
path: 'main.bean',
|
||||||
content: `option "operating_currency" "CNY"
|
content: `option "operating_currency" "CNY"
|
||||||
include "mobile.bean"
|
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,27 +79,23 @@ function AppShell() {
|
|||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [fontsLoaded, fontError] = useFonts({
|
|
||||||
Quicksand_400Regular,
|
|
||||||
Quicksand_500Medium,
|
|
||||||
Quicksand_600SemiBold,
|
|
||||||
Quicksand_700Bold,
|
|
||||||
Caveat_400Regular,
|
|
||||||
Caveat_500Medium,
|
|
||||||
Caveat_600SemiBold,
|
|
||||||
Caveat_700Bold,
|
|
||||||
});
|
|
||||||
const loadLedger = useLedgerStore(s => s.loadLedger);
|
const loadLedger = useLedgerStore(s => s.loadLedger);
|
||||||
const setContext = useImportStore(s => s.setContext);
|
const setContext = useImportStore(s => s.setContext);
|
||||||
const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
|
const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
|
||||||
const onboardingCompleted = useSettingsStore(s => s.onboardingCompleted);
|
|
||||||
const setOnboardingCompleted = useSettingsStore(s => s.setOnboardingCompleted);
|
const setOnboardingCompleted = useSettingsStore(s => s.setOnboardingCompleted);
|
||||||
const [phase, setPhase] = useState<'loading' | 'onboarding' | 'locked' | 'ready'>('loading');
|
const [phase, setPhase] = useState<'loading' | 'onboarding' | 'locked' | 'ready'>('loading');
|
||||||
const [privacyOverlay, setPrivacyOverlay] = useState(false);
|
const [privacyOverlay, setPrivacyOverlay] = useState(false);
|
||||||
|
|
||||||
const lastMainFileRef = useRef<{ modificationTime?: number; size?: number } | null>(null);
|
const lastMainFileRef = useRef<{ modificationTime?: number; size?: number } | null>(null);
|
||||||
|
const persistenceInitRef = useRef(false);
|
||||||
|
const lastCheckTimeRef = useRef(0);
|
||||||
|
|
||||||
const checkAndReloadLedger = useCallback(async () => {
|
const checkAndReloadLedger = useCallback(async () => {
|
||||||
|
// 节流:2 秒内不重复检查
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastCheckTimeRef.current < 2000) return;
|
||||||
|
lastCheckTimeRef.current = now;
|
||||||
|
|
||||||
const mainPath = FileSystem.documentDirectory + 'main.bean';
|
const mainPath = FileSystem.documentDirectory + 'main.bean';
|
||||||
try {
|
try {
|
||||||
const info = await FileSystem.getInfoAsync(mainPath);
|
const info = await FileSystem.getInfoAsync(mainPath);
|
||||||
@@ -105,32 +127,30 @@ function AppShell() {
|
|||||||
}, [loadLedger, setContext]);
|
}, [loadLedger, setContext]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (persistenceInitRef.current) return;
|
||||||
|
persistenceInitRef.current = true;
|
||||||
// 1. 初始化持久化并还原数据
|
// 1. 初始化持久化并还原数据
|
||||||
initPersistence().then(async () => {
|
initPersistence().then(async () => {
|
||||||
const mainPath = FileSystem.documentDirectory + 'main.bean';
|
// 初始化磁盘日志持久化系统
|
||||||
const mobilePath = FileSystem.documentDirectory + 'mobile.bean';
|
logger.initFileBackend(expoLogBackend).catch(e => {
|
||||||
|
logger.warn('layout', '磁盘日志初始化失败', e);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
// 加载已处理的交易键(跨会话去重)
|
||||||
// 一次性合并迁移:将旧的 mobile.bean 内容追加到 main.bean 并物理删除它
|
await loadProcessedTxKeys();
|
||||||
const mobileInfo = await FileSystem.getInfoAsync(mobilePath);
|
|
||||||
if (mobileInfo.exists) {
|
// OCR 模型改为按需下载(用户触发 OCR 时才下载,不在启动时自动下载 ~30MB)
|
||||||
const mobileContent = await FileSystem.readAsStringAsync(mobilePath);
|
// 若已有模型则静默初始化原生引擎
|
||||||
if (mobileContent.trim()) {
|
const settings = useSettingsStore.getState();
|
||||||
const mainInfo = await FileSystem.getInfoAsync(mainPath);
|
if (settings.ocrModelDir) {
|
||||||
let mainContent = '';
|
ensureOcrModels().catch((e) => { logger.warn('layout', '[OCR模型] 静默初始化失败', e); });
|
||||||
if (mainInfo.exists) {
|
|
||||||
mainContent = await FileSystem.readAsStringAsync(mainPath);
|
|
||||||
}
|
|
||||||
const mergedContent = `${mainContent.trimEnd()}\n\n${mobileContent.trim()}\n`.trim();
|
|
||||||
await FileSystem.writeAsStringAsync(mainPath, mergedContent);
|
|
||||||
logger.info('layout', '已成功将旧 mobile.bean 交易追加合并至 main.bean');
|
|
||||||
}
|
|
||||||
await FileSystem.deleteAsync(mobilePath, { idempotent: true });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('layout', '合并旧 mobile.bean 数据失败', e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mainPath = FileSystem.documentDirectory + 'main.bean';
|
||||||
|
|
||||||
|
// 架构说明:main.bean 作为主账本文件,包含所有 Beancount 指令(open/close/option/include 等),
|
||||||
|
// App 直接对其进行读写与解析。
|
||||||
|
|
||||||
// 2. 检查并读取本地真实主账本
|
// 2. 检查并读取本地真实主账本
|
||||||
FileSystem.getInfoAsync(mainPath).then(async (info) => {
|
FileSystem.getInfoAsync(mainPath).then(async (info) => {
|
||||||
let content = SAMPLE_LEDGER.content;
|
let content = SAMPLE_LEDGER.content;
|
||||||
@@ -147,6 +167,14 @@ function AppShell() {
|
|||||||
|
|
||||||
const completed = useSettingsStore.getState().onboardingCompleted;
|
const completed = useSettingsStore.getState().onboardingCompleted;
|
||||||
const locked = useSettingsStore.getState().appLockEnabled;
|
const locked = useSettingsStore.getState().appLockEnabled;
|
||||||
|
const floatingEnabled = useSettingsStore.getState().floatingBallEnabled;
|
||||||
|
|
||||||
|
// 开启/初始化时同步悬浮球开关至原生无障碍服务
|
||||||
|
const bridge = getAccessibilityBridge();
|
||||||
|
if (bridge) {
|
||||||
|
bridge.setFloatingBallEnabled(floatingEnabled).catch((e) => { logger.debug('layout', '[悬浮球] 同步开关失败', e); });
|
||||||
|
}
|
||||||
|
|
||||||
if (!completed) {
|
if (!completed) {
|
||||||
setPhase('onboarding');
|
setPhase('onboarding');
|
||||||
} else if (locked) {
|
} else if (locked) {
|
||||||
@@ -156,7 +184,7 @@ function AppShell() {
|
|||||||
}
|
}
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
// 加载失败时仍进入引导流程(而非跳过),让用户有机会重新导入账本
|
// 加载失败时仍进入引导流程(而非跳过),让用户有机会重新导入账本
|
||||||
console.warn('[layout] Ledger load failed:', e);
|
logger.warn('layout', `Ledger load failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
setPhase('onboarding');
|
setPhase('onboarding');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -168,7 +196,7 @@ function AppShell() {
|
|||||||
if (phase === 'ready') {
|
if (phase === 'ready') {
|
||||||
const cleanup = setupDeepLinking((action) => {
|
const cleanup = setupDeepLinking((action) => {
|
||||||
if (action.type === 'add-transaction') {
|
if (action.type === 'add-transaction') {
|
||||||
router.push('/transaction/new');
|
useNumpadUiStore.getState().open();
|
||||||
} else if (action.type === 'open-tab') {
|
} else if (action.type === 'open-tab') {
|
||||||
if (action.tab === 'home') router.push('/(tabs)');
|
if (action.tab === 'home') router.push('/(tabs)');
|
||||||
else if (action.tab === 'transactions') router.push('/(tabs)/transactions');
|
else if (action.tab === 'transactions') router.push('/(tabs)/transactions');
|
||||||
@@ -178,7 +206,7 @@ function AppShell() {
|
|||||||
} else if (action.type === 'import-csv') {
|
} else if (action.type === 'import-csv') {
|
||||||
router.push('/import');
|
router.push('/import');
|
||||||
} else if (action.type === 'ocr-camera' || action.type === 'ocr-image') {
|
} else if (action.type === 'ocr-camera' || action.type === 'ocr-image') {
|
||||||
router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } });
|
useNumpadUiStore.getState().open({ autoOcr: true });
|
||||||
} else if (action.type === 'voice-input') {
|
} else if (action.type === 'voice-input') {
|
||||||
router.push('/(tabs)/settings');
|
router.push('/(tabs)/settings');
|
||||||
}
|
}
|
||||||
@@ -193,24 +221,29 @@ function AppShell() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== 'ready' || Platform.OS !== 'android') return;
|
if (phase !== 'ready' || Platform.OS !== 'android') return;
|
||||||
|
|
||||||
|
// 注册全局 billingConfirmed 监听器(浮窗确认入账回调)
|
||||||
|
ensureBillingListenerRegistered();
|
||||||
|
|
||||||
const subscriptions = [
|
const subscriptions = [
|
||||||
DeviceEventEmitter.addListener('billingNotification', (event) => {
|
DeviceEventEmitter.addListener('billingNotification', (event) => {
|
||||||
try {
|
try {
|
||||||
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}, 内容: ${event.text}`);
|
const safeText = sanitizeLogText(event.text || '');
|
||||||
|
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}`, { raw: safeText });
|
||||||
const bill = parseNotification(event);
|
const bill = parseNotification(event);
|
||||||
if (bill) {
|
if (bill) {
|
||||||
logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${event.text}`);
|
logger.debug('layout', `通知解析成功,进入账单管道`);
|
||||||
handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`);
|
handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`);
|
||||||
} else {
|
} else {
|
||||||
logger.debug('layout', `通知未匹配为账单: [${event.title}] ${event.text}`);
|
logger.debug('layout', `通知未匹配为账单: [${event.title}]`, { raw: safeText });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('layout', '通知解析失败', e);
|
logger.error('layout', '[通知] 解析失败', e);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
DeviceEventEmitter.addListener('billingSms', (event) => {
|
DeviceEventEmitter.addListener('billingSms', (event) => {
|
||||||
try {
|
try {
|
||||||
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}, 内容: ${event.body}`);
|
const safeBody = sanitizeLogText(event.body || '');
|
||||||
|
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}`, { raw: safeBody });
|
||||||
const smsEvent = {
|
const smsEvent = {
|
||||||
sender: event.sender || event.address || '',
|
sender: event.sender || event.address || '',
|
||||||
body: event.body,
|
body: event.body,
|
||||||
@@ -218,139 +251,111 @@ function AppShell() {
|
|||||||
};
|
};
|
||||||
const bill = parseSms(smsEvent);
|
const bill = parseSms(smsEvent);
|
||||||
if (bill) {
|
if (bill) {
|
||||||
logger.debug('layout', `短信解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始短信: ${event.body}`);
|
logger.debug('layout', `短信解析成功,进入账单管道`);
|
||||||
handleIncomingBillEvent('sms', bill, event.body);
|
handleIncomingBillEvent('sms', bill, event.body);
|
||||||
} else {
|
} else {
|
||||||
logger.debug('layout', `短信未匹配为账单: ${event.body}`);
|
logger.debug('layout', `短信未匹配为账单`, { raw: safeBody });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('layout', '短信解析失败', e);
|
logger.error('layout', '[短信] 解析失败', e);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
DeviceEventEmitter.addListener('billingScreenshot', (event) => {
|
DeviceEventEmitter.addListener('billingScreenshot', async (event) => {
|
||||||
logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
|
|
||||||
processScreenshotEvent(event).catch((e: unknown) => {
|
|
||||||
logger.error('layout', '截图 OCR 处理失败', e);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
DeviceEventEmitter.addListener('billingOpenApp', (res) => {
|
|
||||||
try {
|
try {
|
||||||
logger.info('layout', `收到原生悬浮窗跳转 App 请求: ${JSON.stringify(res)}`);
|
logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
|
||||||
if (res.draftId) {
|
await processScreenshotEvent(event);
|
||||||
try {
|
} catch (e) {
|
||||||
const { pendingDrafts } = require('../services/automationPipeline');
|
logger.error('layout', `[截图] OCR 处理失败 (包名: ${event.packageName}, 是否有Base64: ${Boolean(event.imageBase64)})`, e instanceof Error ? { message: e.message, stack: e.stack } : String(e));
|
||||||
pendingDrafts.delete(res.draftId);
|
}
|
||||||
} catch (err) {
|
}),
|
||||||
// 忽略
|
DeviceEventEmitter.addListener('billingOpenApp', async (event) => {
|
||||||
|
try {
|
||||||
|
const res = event as NativeOpenAppEvent;
|
||||||
|
logger.info('layout', `[悬浮窗跳转] 收到请求: 商户=${res.merchant ?? '无'}, 金额=${res.amount ?? '无'}, confirmed=${res.confirmed ?? false}`);
|
||||||
|
if (res.confirmed) {
|
||||||
|
// 已由原生悬浮窗确认入账,前端仅记录日志或提示
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.amount && !res.merchant) {
|
||||||
|
logger.warn('layout', '[悬浮窗跳转] 数据不完整,跳过', { draftId: res.draftId, amount: res.amount, merchant: res.merchant });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const amt = res.amount ?? '0';
|
||||||
|
try {
|
||||||
|
parseDecimal(amt);
|
||||||
|
} catch {
|
||||||
|
if (amt.length > 0) {
|
||||||
|
logger.warn('layout', 'billingOpenApp 金额格式错误', amt);
|
||||||
|
} else {
|
||||||
|
logger.warn('layout', 'billingOpenApp 金额无效', amt);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const amt = res.amount || '0';
|
const direction = res.direction ?? 'expense';
|
||||||
const currency = 'CNY';
|
const categoryAccount = res.category ?? 'Expenses:未分类';
|
||||||
let postings: any[] = [];
|
const sourceAccount = res.account ?? 'Assets:未知';
|
||||||
if (res.direction === 'expense') {
|
const currency = res.currency ?? 'CNY';
|
||||||
postings = [
|
const postings = [
|
||||||
{ account: res.account, amount: `-${amt}`, currency },
|
{ account: sourceAccount, amount: direction === 'expense' ? `-${amt}` : amt, currency },
|
||||||
{ account: res.category, amount: amt, currency }
|
{ account: categoryAccount, amount: direction === 'expense' ? amt : `-${amt}`, currency }
|
||||||
];
|
];
|
||||||
} else if (res.direction === 'income') {
|
|
||||||
postings = [
|
|
||||||
{ account: res.account, amount: amt, currency },
|
|
||||||
{ account: res.category, amount: `-${amt}`, currency }
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
postings = [
|
|
||||||
{ account: res.account, amount: `-${amt}`, currency },
|
|
||||||
{ account: res.category, amount: amt, currency }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
const draft = {
|
const draft = {
|
||||||
date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0],
|
date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0],
|
||||||
payee: res.merchant || undefined,
|
payee: res.merchant || undefined,
|
||||||
narration: res.narration || '',
|
narration: res.narration || '',
|
||||||
postings
|
postings
|
||||||
};
|
};
|
||||||
router.push({
|
useNumpadUiStore.getState().open({ draftJson: JSON.stringify(draft) });
|
||||||
pathname: '/transaction/new',
|
|
||||||
params: { draftJson: JSON.stringify(draft) }
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('layout', '处理悬浮窗跳转 App 失败', e);
|
logger.error('layout', '[悬浮窗跳转] 处理失败', e);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
DeviceEventEmitter.addListener('billingPageRemembered', (event) => {
|
DeviceEventEmitter.addListener('billingPageRemembered', (event) => {
|
||||||
logger.info('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`);
|
logger.debug('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`, { texts: (event.texts ?? []).map(sanitizeLogText) });
|
||||||
logger.info('layout', `[无障碍调试] 记住该页面时捕获的所有文本内容: ${JSON.stringify(event.texts)}`);
|
|
||||||
}),
|
}),
|
||||||
DeviceEventEmitter.addListener('billingDebugNodes', async (event) => {
|
DeviceEventEmitter.addListener('billingDebugNodes', async (event) => {
|
||||||
const { package: pkg, activity, texts, isManual } = event;
|
const { package: pkg, activity, texts, isManual } = event;
|
||||||
const sigKey = `${pkg}|${activity}`;
|
|
||||||
|
|
||||||
logger.info('layout', `[无障碍监听] 页面特征信号: ${sigKey}`);
|
// 仅处理手动触发(悬浮球点击),自动监听已移除(微信 8.0.52+ 混淆节点文本,自动监听无意义)
|
||||||
logger.info('layout', `[无障碍监听] 页面提取到的文本内容: ${JSON.stringify(texts)}`);
|
if (!isManual) return;
|
||||||
|
|
||||||
|
const sigKey = `${pkg}|${activity}`;
|
||||||
|
logger.info('layout', `[无障碍识别] 手动触发 | 页面: ${sigKey} | 节点数: ${texts?.length ?? 0}`, { texts: (texts ?? []).map(sanitizeLogText) });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bridge = getAccessibilityBridge();
|
const bridge = getAccessibilityBridge();
|
||||||
if (!isManual) {
|
if (!texts || texts.length === 0) {
|
||||||
if (bridge) {
|
logger.info('layout', '[无障碍识别] 节点文本为空(可能是微信等混淆应用),降级触发 OCR 截图识别');
|
||||||
const signatures = await bridge.getPageSignatures();
|
bridge?.triggerManualOcr();
|
||||||
const isWhitelisted = signatures.some(s => s.signature === sigKey);
|
return;
|
||||||
if (!isWhitelisted) {
|
|
||||||
logger.info('layout', `[无障碍监听] 页面 ${sigKey} 未在自动记账白名单中,拒绝弹窗`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 防抖:3秒内避免对同一页面重复触发记账/截图
|
|
||||||
if (sigKey === lastSignature && Date.now() - lastProcessedTime < 3000) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastSignature = sigKey;
|
|
||||||
lastProcessedTime = Date.now();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pkg === 'com.tencent.mm') {
|
// 尝试直接文本解析(传递完整文本,非截断)
|
||||||
// 微信:识别是否是详情页
|
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
|
||||||
const isDetail = texts.includes('交易单号') || texts.includes('退款单号') || texts.includes('本服务由财付通提供');
|
if (!success) {
|
||||||
if (isDetail) {
|
logger.info('layout', '[无障碍识别] 直接文本解析未成功,降级触发 OCR 截图识别');
|
||||||
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
|
|
||||||
if (!success) {
|
|
||||||
logger.info('layout', '[无障碍] 微信详情页直接文本解析未成功,降级触发 OCR 识别');
|
|
||||||
bridge?.triggerManualOcr();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.info('layout', '[无障碍] 微信非详情页面,不触发记账与截图');
|
|
||||||
}
|
|
||||||
} else if (pkg === 'com.eg.android.AlipayGphone') {
|
|
||||||
// 支付宝:识别是否是详情页
|
|
||||||
const isDetail = texts.includes('创建时间') || texts.includes('账单详情') || texts.includes('对此订单有疑问');
|
|
||||||
if (isDetail) {
|
|
||||||
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
|
|
||||||
if (!success) {
|
|
||||||
logger.info('layout', '[无障碍] 支付宝详情页直接文本解析未成功,降级触发 OCR 识别');
|
|
||||||
bridge?.triggerManualOcr();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.info('layout', '[无障碍] 支付宝非详情页面,不触发记账与截图');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 其他白名单应用(如各大银行、QQ、TIM等),直接通过截图 + OCR 识别
|
|
||||||
logger.info('layout', `[无障碍] 检测到白名单应用 ${pkg},直接触发 OCR 识别`);
|
|
||||||
bridge?.triggerManualOcr();
|
bridge?.triggerManualOcr();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('layout', '无障碍调试及直接解析执行失败', e);
|
logger.error('layout', `[无障碍识别] 执行失败 (页面: ${sigKey})`, e);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
return () => subscriptions.forEach(s => s.remove());
|
return () => {
|
||||||
|
subscriptions.forEach(s => s.remove());
|
||||||
|
removeBillingListener();
|
||||||
|
};
|
||||||
}, [phase]);
|
}, [phase]);
|
||||||
|
|
||||||
// 5. 隐私模糊(plan.md「0.4 隐私模糊」):App 进入后台/非活跃时遮盖内容
|
// 5. 隐私模糊(plan.md「0.4 隐私模糊」):App 进入后台/非活跃时遮盖内容
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sub = AppState.addEventListener('change', (state) => {
|
const sub = AppState.addEventListener('change', (state) => {
|
||||||
// 切到后台/非活跃时立即遮盖,回到前台时移除
|
// 切到后台/非活跃时立即遮盖并同步持久化,回到前台时移除
|
||||||
setPrivacyOverlay(state !== 'active');
|
setPrivacyOverlay(state !== 'active');
|
||||||
|
if (state !== 'active') {
|
||||||
|
flushPersistence().catch(e => logger.error('layout', 'Failed to flush persistence on background', e));
|
||||||
|
}
|
||||||
if (state === 'active' && phase === 'ready') {
|
if (state === 'active' && phase === 'ready') {
|
||||||
checkAndReloadLedger();
|
checkAndReloadLedger();
|
||||||
}
|
}
|
||||||
@@ -359,7 +364,7 @@ function AppShell() {
|
|||||||
}, [phase, checkAndReloadLedger]);
|
}, [phase, checkAndReloadLedger]);
|
||||||
|
|
||||||
// loading 状态
|
// loading 状态
|
||||||
if (phase === 'loading' || (!fontsLoaded && !fontError)) {
|
if (phase === 'loading') {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary, alignItems: 'center', justifyContent: 'center' }}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Text style={{ color: theme.colors.fgSecondary }}>{t('app.name')}</Text>
|
<Text style={{ color: theme.colors.fgSecondary }}>{t('app.name')}</Text>
|
||||||
@@ -390,7 +395,6 @@ function AppShell() {
|
|||||||
<StatusBar style={isDark ? 'light' : 'dark'} />
|
<StatusBar style={isDark ? 'light' : 'dark'} />
|
||||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}>
|
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}>
|
||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="transaction/new" options={{ headerShown: true, title: t('home.newTransaction'), headerTintColor: theme.colors.fgPrimary, headerStyle: { backgroundColor: theme.colors.bgPrimary } }} />
|
|
||||||
<Stack.Screen name="transaction/[id]" options={{ headerShown: false }} />
|
<Stack.Screen name="transaction/[id]" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="category/index" options={{ headerShown: false }} />
|
<Stack.Screen name="category/index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="tag/index" options={{ headerShown: false }} />
|
<Stack.Screen name="tag/index" options={{ headerShown: false }} />
|
||||||
@@ -401,25 +405,36 @@ function AppShell() {
|
|||||||
<Stack.Screen name="settings/ai" options={{ headerShown: false }} />
|
<Stack.Screen name="settings/ai" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="settings/sync" options={{ headerShown: false }} />
|
<Stack.Screen name="settings/sync" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="settings/preferences" options={{ headerShown: false }} />
|
<Stack.Screen name="settings/preferences" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="settings/diagnostics" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="recurring/index" options={{ headerShown: false }} />
|
<Stack.Screen name="recurring/index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="ai/chat" options={{ headerShown: false }} />
|
<Stack.Screen name="ai/chat" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="remark-template/index" options={{ headerShown: false }} />
|
<Stack.Screen name="remark-template/index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="automation/index" options={{ headerShown: false }} />
|
<Stack.Screen name="automation/index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="credit-card/index" options={{ headerShown: false }} />
|
<Stack.Screen name="credit-card/index" options={{ headerShown: false }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
{/* 隐私遮罩(plan.md「0.4」):App 切后台时遮盖内容 */}
|
<NumpadSheetHost />
|
||||||
|
{/* 隐私遮罩(plan.md「0.4」):App 切后台时遮盖内容并拦截所有手势 */}
|
||||||
{privacyOverlay && (
|
{privacyOverlay && (
|
||||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: theme.colors.bgPrimary }} />
|
<View
|
||||||
|
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: theme.colors.bgPrimary, zIndex: 99999 }}
|
||||||
|
pointerEvents="auto"
|
||||||
|
accessible={false}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根布局:包裹 ThemeProvider。 */
|
/** 根布局:GestureHandlerRootView(左滑手势)包裹 ThemeProvider。 */
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<AppShell />
|
<ThemeProvider>
|
||||||
</ThemeProvider>
|
<ToastProvider>
|
||||||
|
<FloatingUiConfigSyncer />
|
||||||
|
<AppShell />
|
||||||
|
</ToastProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-3
@@ -12,14 +12,16 @@
|
|||||||
* 完成后标记 onboarding 已完成(持久化),后续启动不再显示。
|
* 完成后标记 onboarding 已完成(持久化),后续启动不再显示。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { AppState, Platform, PermissionsAndroid, Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../theme';
|
import { useTheme } from '../theme';
|
||||||
import { useT } from '../i18n';
|
import { useT } from '../i18n';
|
||||||
import { useSettingsStore, type Locale, type ThemeMode } from '../store/settingsStore';
|
import { useSettingsStore, type Locale, type ThemeMode } from '../store/settingsStore';
|
||||||
import { Button } from '../components/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { getAccessibilityBridge } from '../services/automation/accessibilityBridge';
|
||||||
|
import { Card } from '../components/ui/Card';
|
||||||
|
|
||||||
export interface OnboardingStep {
|
export interface OnboardingStep {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -52,6 +54,68 @@ export function OnboardingScreen({ onComplete }: OnboardingProps) {
|
|||||||
const current = steps[step];
|
const current = steps[step];
|
||||||
const isLast = step === steps.length - 1;
|
const isLast = step === steps.length - 1;
|
||||||
|
|
||||||
|
// ── 权限状态管理(P7 Task 2) ──
|
||||||
|
const [permStates, setPermStates] = useState<Record<string, boolean | null>>({
|
||||||
|
accessibility: null, notification: null, sms: null, storage: null, overlay: null,
|
||||||
|
});
|
||||||
|
const permsChecked = !Object.values(permStates).some(v => v === null);
|
||||||
|
const allGranted = permsChecked && Object.values(permStates).every(Boolean);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS !== 'android') return;
|
||||||
|
|
||||||
|
const checkAllPermissions = async () => {
|
||||||
|
if (current.key !== 'permissions') return;
|
||||||
|
const bridge = getAccessibilityBridge();
|
||||||
|
let a = false, n = false, o = false, s = false, st = false;
|
||||||
|
if (bridge) {
|
||||||
|
try { a = await bridge.isServiceRunning(); } catch { /* false */ }
|
||||||
|
try { n = await bridge.isNotificationListenerEnabled(); } catch { /* false */ }
|
||||||
|
try { o = await bridge.canDrawOverlays(); } catch { /* false */ }
|
||||||
|
}
|
||||||
|
try { s = await PermissionsAndroid.check('android.permission.RECEIVE_SMS') as boolean; } catch { /* false */ }
|
||||||
|
const storagePerm = parseInt(String(Platform.Version), 10) >= 33
|
||||||
|
? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
|
||||||
|
try { st = await PermissionsAndroid.check(storagePerm) as boolean; } catch { /* false */ }
|
||||||
|
setPermStates({ accessibility: a, notification: n, overlay: o, sms: s, storage: st });
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAllPermissions();
|
||||||
|
|
||||||
|
// 从系统设置返回后重新检查
|
||||||
|
const sub = AppState.addEventListener('change', (state) => {
|
||||||
|
if (state === 'active') checkAllPermissions();
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [current.key]);
|
||||||
|
|
||||||
|
const handlePermAction = async (key: string) => {
|
||||||
|
const storagePerm = parseInt(String(Platform.Version), 10) >= 33
|
||||||
|
? 'android.permission.READ_MEDIA_IMAGES'
|
||||||
|
: 'android.permission.READ_EXTERNAL_STORAGE';
|
||||||
|
switch (key) {
|
||||||
|
case 'accessibility':
|
||||||
|
await Linking.sendIntent('android.settings.ACCESSIBILITY_SETTINGS');
|
||||||
|
break;
|
||||||
|
case 'notification':
|
||||||
|
await Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS');
|
||||||
|
break;
|
||||||
|
case 'sms': {
|
||||||
|
const result = await PermissionsAndroid.request('android.permission.RECEIVE_SMS');
|
||||||
|
setPermStates(p => ({ ...p, sms: result === PermissionsAndroid.RESULTS.GRANTED }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'storage': {
|
||||||
|
const result = await PermissionsAndroid.request(storagePerm);
|
||||||
|
setPermStates(p => ({ ...p, storage: result === PermissionsAndroid.RESULTS.GRANTED }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'overlay':
|
||||||
|
await Linking.sendIntent('android.settings.action.MANAGE_OVERLAY_PERMISSION');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
@@ -97,6 +161,55 @@ export function OnboardingScreen({ onComplete }: OnboardingProps) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{current.key === 'permissions' && Platform.OS === 'android' && (
|
||||||
|
<View style={{ width: '100%', marginTop: 24 }}>
|
||||||
|
<Card>
|
||||||
|
{[
|
||||||
|
{ key: 'accessibility', icon: 'accessibility-outline' as const, label: t('onboarding.permAccessibility') },
|
||||||
|
{ key: 'notification', icon: 'notifications-outline' as const, label: t('onboarding.permNotification') },
|
||||||
|
{ key: 'sms', icon: 'chatbubble-outline' as const, label: t('onboarding.permSms') },
|
||||||
|
{ key: 'storage', icon: 'images-outline' as const, label: t('onboarding.permStorage') },
|
||||||
|
{ key: 'overlay', icon: 'tablet-landscape-outline' as const, label: t('onboarding.permOverlay') },
|
||||||
|
].map(item => {
|
||||||
|
const status = permStates[item.key];
|
||||||
|
const granted = status === true;
|
||||||
|
const statusColor = granted ? theme.colors.success : theme.colors.fgSecondary;
|
||||||
|
const statusText = status === null ? '...' : granted ? t('onboarding.permGranted') : t('onboarding.permNotGranted');
|
||||||
|
return (
|
||||||
|
<View key={item.key} style={styles.permRow}>
|
||||||
|
<View style={styles.permLeft}>
|
||||||
|
<Ionicons name={item.icon} size={20} color={granted ? theme.colors.success : theme.colors.fgSecondary} />
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, marginLeft: 12 }]}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.permRight}>
|
||||||
|
<Text style={[theme.typography.caption, { color: statusColor, marginRight: 8 }]}>
|
||||||
|
{statusText}
|
||||||
|
</Text>
|
||||||
|
{!granted && !allGranted && (
|
||||||
|
<Button
|
||||||
|
label={item.key === 'sms' || item.key === 'storage' ? t('onboarding.permRequest') : t('onboarding.permOpenSettings')}
|
||||||
|
onPress={() => handlePermAction(item.key)}
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Card>
|
||||||
|
{allGranted && (
|
||||||
|
<View style={styles.allGrantedWrap}>
|
||||||
|
<Ionicons name="checkmark-circle" size={48} color={theme.colors.success} />
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.success, marginTop: 8 }]}>
|
||||||
|
{t('onboarding.permAllDone')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{current.key === 'security' && (
|
{current.key === 'security' && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setAppLockEnabled(!appLockEnabled)}
|
onPress={() => setAppLockEnabled(!appLockEnabled)}
|
||||||
@@ -146,4 +259,9 @@ const styles = StyleSheet.create({
|
|||||||
dots: { flexDirection: 'row', justifyContent: 'center', gap: 6, marginBottom: 12 },
|
dots: { flexDirection: 'row', justifyContent: 'center', gap: 6, marginBottom: 12 },
|
||||||
dot: { width: 8, height: 8, borderRadius: 4 },
|
dot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
actions: { flexDirection: 'row', gap: 12, justifyContent: 'flex-end' },
|
actions: { flexDirection: 'row', gap: 12, justifyContent: 'flex-end' },
|
||||||
|
// 权限清单(P7 Task 2)
|
||||||
|
permRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
|
||||||
|
permLeft: { flexDirection: 'row', alignItems: 'center', flex: 1 },
|
||||||
|
permRight: { flexDirection: 'row', alignItems: 'center' },
|
||||||
|
allGrantedWrap: { alignItems: 'center', marginTop: 24 },
|
||||||
});
|
});
|
||||||
|
|||||||
+54
-103
@@ -1,22 +1,26 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { Alert, FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { FormModal, type FormField } from '../../components/FormModal';
|
import { AccountCreateModal } from '../../components/account/AccountCreateModal';
|
||||||
import { addDecimals } from '../../domain/decimal';
|
import { FormModal } from '../../components/form/FormModal';
|
||||||
import { computeAccountBalances } from '../../domain/ledger';
|
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||||
|
import { toDateString } from '../../domain/core/decimal';
|
||||||
|
import { computeAccountBalances } from '../../domain/core/ledger';
|
||||||
|
import { SegmentedControl } from '../../components/ui/SegmentedControl';
|
||||||
|
import { EmptyState } from '../../components/ui/EmptyState';
|
||||||
|
import { useToast } from '../../components/ui/Toast';
|
||||||
|
|
||||||
type TabType = 'assets' | 'liabilities' | 'expenses' | 'income';
|
type TabType = 'assets' | 'liabilities' | 'expenses' | 'income';
|
||||||
|
|
||||||
export default function AccountScreen() {
|
export default function AccountScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
const ledger = useLedgerStore(s => s.ledger);
|
const ledger = useLedgerStore(s => s.ledger);
|
||||||
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
|
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
|
||||||
@@ -56,7 +60,7 @@ export default function AccountScreen() {
|
|||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
await autoCloseAccount(accountName);
|
await autoCloseAccount(accountName);
|
||||||
Alert.alert(t('account.closeSuccess'), t('account.closeSuccessDesc'));
|
showToast(t('account.closeSuccessDesc'), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Alert.alert(t('account.closeFail'), e instanceof Error ? e.message : String(e));
|
Alert.alert(t('account.closeFail'), e instanceof Error ? e.message : String(e));
|
||||||
}
|
}
|
||||||
@@ -69,7 +73,6 @@ export default function AccountScreen() {
|
|||||||
const handleAddAccount = async (values: Record<string, string>) => {
|
const handleAddAccount = async (values: Record<string, string>) => {
|
||||||
const typeVal = values.type?.trim();
|
const typeVal = values.type?.trim();
|
||||||
const nameVal = values.name?.trim();
|
const nameVal = values.name?.trim();
|
||||||
const currencyVal = values.currency?.trim() || 'CNY';
|
|
||||||
|
|
||||||
if (!typeVal || !nameVal) {
|
if (!typeVal || !nameVal) {
|
||||||
Alert.alert(t('account.addFail'), t('account.addFailEmpty'));
|
Alert.alert(t('account.addFail'), t('account.addFailEmpty'));
|
||||||
@@ -84,7 +87,7 @@ export default function AccountScreen() {
|
|||||||
.map((seg, idx) => {
|
.map((seg, idx) => {
|
||||||
if (idx === 0) return seg; // 保留顶级前缀如 Assets
|
if (idx === 0) return seg; // 保留顶级前缀如 Assets
|
||||||
// 将中英文括号和中括号转换为 - 连字符
|
// 将中英文括号和中括号转换为 - 连字符
|
||||||
let cleaned = seg.replace(/[\(\)()\[\]]/g, '-');
|
let cleaned = seg.replace(/[()()[\]]/g, '-');
|
||||||
cleaned = cleaned.replace(/-+/g, '-');
|
cleaned = cleaned.replace(/-+/g, '-');
|
||||||
cleaned = cleaned.replace(/^-|-$/g, '');
|
cleaned = cleaned.replace(/^-|-$/g, '');
|
||||||
cleaned = cleaned.replace(/[^\w\u4e00-\u9fa5-]/g, '');
|
cleaned = cleaned.replace(/[^\w\u4e00-\u9fa5-]/g, '');
|
||||||
@@ -102,7 +105,7 @@ export default function AccountScreen() {
|
|||||||
try {
|
try {
|
||||||
await autoOpenAccounts([sanitized]);
|
await autoOpenAccounts([sanitized]);
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
Alert.alert(t('account.openSuccess'), t('account.openSuccessDesc', { name: sanitized }));
|
showToast(t('account.openSuccessDesc', { name: sanitized }), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Alert.alert(t('account.openFail'), e instanceof Error ? e.message : String(e));
|
Alert.alert(t('account.openFail'), e instanceof Error ? e.message : String(e));
|
||||||
}
|
}
|
||||||
@@ -111,7 +114,7 @@ export default function AccountScreen() {
|
|||||||
const handleConfirmAdjust = async (values: Record<string, string>) => {
|
const handleConfirmAdjust = async (values: Record<string, string>) => {
|
||||||
if (!adjustingAccount) return;
|
if (!adjustingAccount) return;
|
||||||
const balanceVal = values.balance?.trim();
|
const balanceVal = values.balance?.trim();
|
||||||
const dateVal = values.date?.trim() || new Date().toISOString().slice(0, 10);
|
const dateVal = values.date?.trim() || toDateString(new Date());
|
||||||
|
|
||||||
if (!balanceVal) {
|
if (!balanceVal) {
|
||||||
Alert.alert(t('account.adjustFail'), t('account.adjustFailEmpty'));
|
Alert.alert(t('account.adjustFail'), t('account.adjustFailEmpty'));
|
||||||
@@ -121,7 +124,7 @@ export default function AccountScreen() {
|
|||||||
try {
|
try {
|
||||||
await adjustAccountBalance(adjustingAccount, balanceVal, dateVal);
|
await adjustAccountBalance(adjustingAccount, balanceVal, dateVal);
|
||||||
setAdjustingAccount(null);
|
setAdjustingAccount(null);
|
||||||
Alert.alert(t('account.adjustSuccess'), t('account.adjustSuccessDesc', { name: adjustingAccount, balance: balanceVal, currency: 'CNY' }));
|
showToast(t('account.adjustSuccessDesc', { name: adjustingAccount, balance: balanceVal, currency: 'CNY' }), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Alert.alert(t('account.adjustFail'), e instanceof Error ? e.message : String(e));
|
Alert.alert(t('account.adjustFail'), e instanceof Error ? e.message : String(e));
|
||||||
}
|
}
|
||||||
@@ -134,90 +137,36 @@ export default function AccountScreen() {
|
|||||||
{ key: 'income', label: t('account.tabIncome') },
|
{ key: 'income', label: t('account.tabIncome') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const fields: FormField[] = [
|
|
||||||
{
|
|
||||||
key: 'type',
|
|
||||||
label: t('account.fieldType'),
|
|
||||||
placeholder: 'Assets / Liabilities / Expenses / Income',
|
|
||||||
defaultValue: tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
label: t('account.fieldName'),
|
|
||||||
placeholder: t('account.fieldNamePlaceholder'),
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'currency',
|
|
||||||
label: t('account.fieldCurrency'),
|
|
||||||
placeholder: 'CNY',
|
|
||||||
defaultValue: 'CNY',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
|
||||||
{/* 头部导航 */}
|
<ScreenHeader title={t('account.title')} />
|
||||||
<View style={styles.header}>
|
|
||||||
<Pressable onPress={() => router.back()}>
|
|
||||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
|
||||||
{t('account.title')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Tab 选项卡 */}
|
{/* Tab 选项卡(统一分段选择器) */}
|
||||||
<View style={[styles.tabBar, { borderBottomColor: theme.colors.border }]}>
|
<SegmentedControl
|
||||||
{tabs.map(item => {
|
options={tabs}
|
||||||
const isActive = tab === item.key;
|
value={tab}
|
||||||
return (
|
onChange={key => setTab(key as TabType)}
|
||||||
<Pressable
|
/>
|
||||||
key={item.key}
|
|
||||||
accessibilityRole="tab"
|
|
||||||
accessibilityState={{ selected: isActive }}
|
|
||||||
accessibilityLabel={item.label}
|
|
||||||
style={[
|
|
||||||
styles.tabItem,
|
|
||||||
isActive && { borderBottomColor: theme.colors.accent, borderBottomWidth: 2 },
|
|
||||||
]}
|
|
||||||
onPress={() => setTab(item.key)}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
theme.typography.bodySmall,
|
|
||||||
{ color: isActive ? theme.colors.accent : theme.colors.fgSecondary, fontWeight: isActive ? '700' : '400' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 账户列表 */}
|
{/* 账户列表 */}
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<FlatList
|
||||||
<Pressable onPress={() => setIsAdding(true)} style={styles.addBtn}>
|
data={filteredAccounts}
|
||||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
keyExtractor={account => account}
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>
|
renderItem={({ item: account }) => {
|
||||||
{t('account.addNew')}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{filteredAccounts.map(account => {
|
|
||||||
const shortName = account.slice(prefix.length);
|
const shortName = account.slice(prefix.length);
|
||||||
|
const rootType = account.split(':')[0] as 'Assets' | 'Liabilities' | 'Expenses' | 'Income' | 'Equity';
|
||||||
|
const localizedRoot = t(`account.rootLabels.${rootType}`);
|
||||||
const balance = balances.get(account) ?? '0.00';
|
const balance = balances.get(account) ?? '0.00';
|
||||||
const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY';
|
const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={account} title={shortName}>
|
<Card title={shortName}>
|
||||||
<View style={styles.infoRow}>
|
<View style={styles.infoRow}>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
{t('account.fullName')}
|
{t('account.fullName')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
||||||
{account}
|
{account} {localizedRoot ? `(${localizedRoot})` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.infoRow}>
|
<View style={styles.infoRow}>
|
||||||
@@ -237,7 +186,7 @@ export default function AccountScreen() {
|
|||||||
<View style={styles.cardActions}>
|
<View style={styles.cardActions}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setAdjustingAccount(account)}
|
onPress={() => setAdjustingAccount(account)}
|
||||||
style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]}
|
style={[styles.actionBtn, { borderColor: theme.colors.border, borderRadius: theme.radii.sm, marginRight: 8 }]}
|
||||||
>
|
>
|
||||||
<Ionicons name="create-outline" size={14} color={theme.colors.accent} />
|
<Ionicons name="create-outline" size={14} color={theme.colors.accent} />
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
|
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
|
||||||
@@ -246,7 +195,7 @@ export default function AccountScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => handleCloseAccount(account)}
|
onPress={() => handleCloseAccount(account)}
|
||||||
style={[styles.closeBtn, { borderColor: theme.colors.border }]}
|
style={[styles.closeBtn, { borderColor: theme.colors.border, borderRadius: theme.radii.sm }]}
|
||||||
>
|
>
|
||||||
<Ionicons name="close-circle-outline" size={14} color={theme.colors.error} />
|
<Ionicons name="close-circle-outline" size={14} color={theme.colors.error} />
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
|
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
|
||||||
@@ -256,20 +205,25 @@ export default function AccountScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
}}
|
||||||
|
ListHeaderComponent={
|
||||||
{filteredAccounts.length === 0 && (
|
<Pressable onPress={() => setIsAdding(true)} style={styles.addBtn}>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}>
|
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
||||||
{t('account.empty')}
|
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>
|
||||||
</Text>
|
{t('account.addNew')}
|
||||||
)}
|
</Text>
|
||||||
</ScrollView>
|
</Pressable>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<EmptyState icon="wallet-outline" title={t('account.empty')} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 新开户对话框 */}
|
{/* 新开户对话框 */}
|
||||||
<FormModal
|
<AccountCreateModal
|
||||||
visible={isAdding}
|
visible={isAdding}
|
||||||
title={t('account.addModalTitle')}
|
defaultType={tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income'}
|
||||||
fields={fields}
|
|
||||||
onConfirm={handleAddAccount}
|
onConfirm={handleAddAccount}
|
||||||
onCancel={() => setIsAdding(false)}
|
onCancel={() => setIsAdding(false)}
|
||||||
/>
|
/>
|
||||||
@@ -289,7 +243,7 @@ export default function AccountScreen() {
|
|||||||
key: 'date',
|
key: 'date',
|
||||||
label: t('account.fieldDate'),
|
label: t('account.fieldDate'),
|
||||||
placeholder: 'YYYY-MM-DD',
|
placeholder: 'YYYY-MM-DD',
|
||||||
defaultValue: new Date().toISOString().slice(0, 10),
|
defaultValue: toDateString(new Date()),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onConfirm={handleConfirmAdjust}
|
onConfirm={handleConfirmAdjust}
|
||||||
@@ -301,13 +255,10 @@ export default function AccountScreen() {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
content: { padding: 16 },
|
||||||
tabBar: { flexDirection: 'row', borderBottomWidth: 1, paddingHorizontal: 8 },
|
|
||||||
tabItem: { flex: 1, alignItems: 'center', paddingVertical: 12 },
|
|
||||||
content: { padding: 16, gap: 12 },
|
|
||||||
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
|
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
|
||||||
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' },
|
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' },
|
||||||
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
|
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
|
||||||
actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
|
actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, paddingHorizontal: 8, paddingVertical: 4 },
|
||||||
closeBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
|
closeBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, paddingHorizontal: 8, paddingVertical: 4 },
|
||||||
});
|
});
|
||||||
|
|||||||
+34
-32
@@ -13,12 +13,14 @@ import React, { useState, useCallback, useRef } from 'react';
|
|||||||
import { FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
import { FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { useSettingsStore } from '../../store/settingsStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai';
|
import { useNumpadUiStore } from '../../store/numpadUiStore';
|
||||||
|
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider } from '../../domain/ai';
|
||||||
import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant';
|
import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant';
|
||||||
|
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||||
|
import { SkeletonText } from '../../components/ui/Skeleton';
|
||||||
|
|
||||||
interface UiMessage {
|
interface UiMessage {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
@@ -43,9 +45,6 @@ function buildProvider(get: ReturnType<typeof useSettingsStore.getState>): AiPro
|
|||||||
export default function AIChatScreen() {
|
export default function AIChatScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
|
||||||
const aiEnabled = useSettingsStore(s => s.aiEnabled);
|
|
||||||
const aiApiKey = useSettingsStore(s => s.aiApiKey);
|
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [messages, setMessages] = useState<UiMessage[]>([
|
const [messages, setMessages] = useState<UiMessage[]>([
|
||||||
@@ -107,71 +106,75 @@ export default function AIChatScreen() {
|
|||||||
{
|
{
|
||||||
backgroundColor: isUser ? theme.colors.accent : theme.colors.bgTertiary,
|
backgroundColor: isUser ? theme.colors.accent : theme.colors.bgTertiary,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.radii.md,
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<Text style={{ color: isUser ? theme.colors.fgInverse : theme.colors.fgPrimary, fontSize: 15, lineHeight: 20 }}>
|
<Text style={[theme.typography.bodySmall, { color: isUser ? theme.colors.fgInverse : theme.colors.fgPrimary }]}>
|
||||||
{item.content}
|
{item.content}
|
||||||
</Text>
|
</Text>
|
||||||
{/* 账单卡片 */}
|
{/* 账单卡片 */}
|
||||||
{item.billCards && item.billCards.map((card, i) => (
|
{item.billCards && item.billCards.map((card, i) => {
|
||||||
|
// 方向符号 + 金额上色,与全局方向色约定一致(TransactionCard:income 绿 / expense 红 / transfer 蓝)
|
||||||
|
const dirColor = card.type === 'income' ? theme.colors.financial.income
|
||||||
|
: card.type === 'transfer' ? theme.colors.financial.transfer
|
||||||
|
: theme.colors.financial.expense;
|
||||||
|
const dirSign = card.type === 'income' ? '+' : card.type === 'transfer' ? '⇄' : '-';
|
||||||
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={i}
|
key={i}
|
||||||
onPress={() => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } })}
|
onPress={() => useNumpadUiStore.getState().open({ autoOcr: true })}
|
||||||
style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent }]}
|
style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent, borderRadius: theme.radii.sm }]}
|
||||||
>
|
>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
|
<Text style={[theme.typography.bodySmall, { color: dirColor, fontWeight: '700' }]}>
|
||||||
{card.type === 'income' ? '💰' : card.type === 'transfer' ? '🔄' : '💸'} {card.amount} {card.currency}
|
{dirSign} {card.amount} {card.currency}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
{card.counterparty} · {card.narration}
|
{card.counterparty} · {card.narration}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontFamily: theme.typography.caption.fontFamily }]}>
|
<Text style={[theme.typography.caption, { color: theme.colors.accent }]}>
|
||||||
{t('ai.tapToRecord')}
|
{t('ai.tapToRecord')}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons name="arrow-forward" size={12} color={theme.colors.accent} />
|
<Ionicons name="arrow-forward" size={12} color={theme.colors.accent} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
|
||||||
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
|
<ScreenHeader title={t('ai.chatTitle')} />
|
||||||
<Pressable onPress={() => router.back()}>
|
|
||||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
|
||||||
{t('ai.chatTitle')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
data={messages}
|
data={messages}
|
||||||
keyExtractor={(item, index) => `${index}`}
|
keyExtractor={(item, index) => `${index}`}
|
||||||
renderItem={renderMessage}
|
renderItem={renderMessage}
|
||||||
contentContainerStyle={{ padding: 16, gap: 8 }}
|
contentContainerStyle={{ padding: 16, gap: theme.spacing.md }}
|
||||||
onContentSizeChange={() => listRef.current?.scrollToEnd()}
|
onContentSizeChange={() => listRef.current?.scrollToEnd()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', paddingBottom: 4 }]}>
|
<View style={{ paddingHorizontal: 16, paddingBottom: 4 }}>
|
||||||
{t('ai.thinking')}
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: theme.spacing.xs }]}>
|
||||||
</Text>
|
{t('ai.thinking')}
|
||||||
|
</Text>
|
||||||
|
<SkeletonText lines={2} lineHeight={12} />
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||||
<View style={[styles.inputRow, { backgroundColor: theme.colors.bgSecondary, borderTopColor: theme.colors.border }]}>
|
<View style={[styles.inputRow, { backgroundColor: theme.colors.bgSecondary, borderTopColor: theme.colors.border }]}>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={input}
|
value={input}
|
||||||
onChangeText={setInput}
|
onChangeText={setInput}
|
||||||
placeholder={t('ai.inputPlaceholder')}
|
placeholder={t('ai.inputPlaceholder')}
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
style={[styles.input, { color: theme.colors.fgPrimary }]}
|
style={[styles.input, { color: theme.colors.fgPrimary, fontSize: theme.typography.body.fontSize }]}
|
||||||
multiline
|
multiline
|
||||||
/>
|
/>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -192,11 +195,10 @@ export default function AIChatScreen() {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, borderBottomWidth: 1 },
|
|
||||||
msgRow: { flexDirection: 'row', maxWidth: '100%' },
|
msgRow: { flexDirection: 'row', maxWidth: '100%' },
|
||||||
bubble: { maxWidth: '85%', borderRadius: 12, padding: 12, borderWidth: 1 },
|
bubble: { maxWidth: '85%', padding: 12, borderWidth: 1 },
|
||||||
billCard: { marginTop: 8, padding: 10, borderRadius: 8, borderWidth: 1 },
|
billCard: { marginTop: 8, padding: 10, borderWidth: 1 },
|
||||||
inputRow: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingVertical: 8, borderTopWidth: 1, gap: 8 },
|
inputRow: { flexDirection: 'row', alignItems: 'flex-end', paddingHorizontal: 12, paddingVertical: 8, borderTopWidth: 1, gap: 8 },
|
||||||
input: { flex: 1, maxHeight: 100, fontSize: 15, paddingVertical: 8 },
|
input: { flex: 1, maxHeight: 100, paddingVertical: 8 },
|
||||||
sendBtn: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
|
sendBtn: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
|
||||||
});
|
});
|
||||||
|
|||||||
+336
-120
@@ -5,7 +5,7 @@
|
|||||||
* - 显示 5 个通道(通知/短信/截图/OCR/手动)的检测统计
|
* - 显示 5 个通道(通知/短信/截图/OCR/手动)的检测统计
|
||||||
* - 展示检测到的事件列表
|
* - 展示检测到的事件列表
|
||||||
* - "处理全部" → BillPipeline → 草稿
|
* - "处理全部" → BillPipeline → 草稿
|
||||||
* - 逐条确认/拒绝草稿 → 写入 mobile.bean
|
* - 逐条确认/拒绝草稿 → 写入 main.bean
|
||||||
* - 截图监控开关(调原生 ScreenshotMonitor 模块)
|
* - 截图监控开关(调原生 ScreenshotMonitor 模块)
|
||||||
* - 无障碍服务控制:
|
* - 无障碍服务控制:
|
||||||
* - 服务状态(已连接/未启用)
|
* - 服务状态(已连接/未启用)
|
||||||
@@ -15,47 +15,47 @@
|
|||||||
* - 已记住页面列表(可删除)
|
* - 已记住页面列表(可删除)
|
||||||
* - 支付 App 白名单展示
|
* - 支付 App 白名单展示
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View } from 'react-native';
|
import { ActivityIndicator, Alert, AppState, Linking, NativeModules, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, View, Switch } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { useAutomationStore } from '../../store/automationStore';
|
import { useRouter } from 'expo-router';
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
import { useMetadataStore } from '../../store/metadataStore';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { Card } from '../../components/Card';
|
import { Button } from '../../components/ui/Button';
|
||||||
import { Button } from '../../components/Button';
|
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||||
import type { AutomationSource } from '../../store/automationStore';
|
|
||||||
import {
|
import {
|
||||||
getAccessibilityBridge,
|
getAccessibilityBridge,
|
||||||
getPackageLabel,
|
getPackageLabel,
|
||||||
type PageSignature,
|
type PageSignature,
|
||||||
} from '../../services/accessibilityBridge';
|
} from '../../services/automation/accessibilityBridge';
|
||||||
|
import { ensureOcrModels, downloadOcrModels } from '../../services/ocr/modelDownloader';
|
||||||
const SOURCE_LABELS: Record<AutomationSource, string> = {
|
import { logger } from '../../utils/logger';
|
||||||
notification: 'automation.sourceNotification',
|
|
||||||
sms: 'automation.sourceSms',
|
|
||||||
screenshot: 'automation.sourceScreenshot',
|
|
||||||
ocr: 'automation.sourceOcr',
|
|
||||||
manual: 'automation.sourceManual',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AutomationScreen() {
|
export default function AutomationScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const detected = useAutomationStore(s => s.detected);
|
const floatingBallEnabled = useSettingsStore(s => s.floatingBallEnabled);
|
||||||
const drafts = useAutomationStore(s => s.drafts);
|
const setFloatingBallEnabled = useSettingsStore(s => s.setFloatingBallEnabled);
|
||||||
const stats = useAutomationStore(s => s.stats);
|
|
||||||
const processAll = useAutomationStore(s => s.processAll);
|
|
||||||
const confirmDraft = useAutomationStore(s => s.confirmDraft);
|
|
||||||
const rejectDraft = useAutomationStore(s => s.rejectDraft);
|
|
||||||
|
|
||||||
const ledger = useLedgerStore(s => s.ledger);
|
// 自动记账层级
|
||||||
const addTransaction = useLedgerStore(s => s.addTransaction);
|
const layer1RuleEnabled = useSettingsStore(s => s.layer1RuleEnabled);
|
||||||
|
const setLayer1RuleEnabled = useSettingsStore(s => s.setLayer1RuleEnabled);
|
||||||
|
const layer2OcrEnabled = useSettingsStore(s => s.layer2OcrEnabled);
|
||||||
|
const setLayer2OcrEnabled = useSettingsStore(s => s.setLayer2OcrEnabled);
|
||||||
|
const layer3AiEnabled = useSettingsStore(s => s.layer3AiEnabled);
|
||||||
|
const setLayer3AiEnabled = useSettingsStore(s => s.setLayer3AiEnabled);
|
||||||
|
const ocrModelVersion = useSettingsStore(s => s.ocrModelVersion);
|
||||||
|
const aiEnabled = useSettingsStore(s => s.aiEnabled);
|
||||||
|
const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
|
||||||
|
const dedupEnabled = useSettingsStore(s => s.dedupEnabled);
|
||||||
|
const setDedupEnabled = useSettingsStore(s => s.setDedupEnabled);
|
||||||
|
const transferRecognitionEnabled = useSettingsStore(s => s.transferRecognitionEnabled);
|
||||||
|
const setTransferRecognitionEnabled = useSettingsStore(s => s.setTransferRecognitionEnabled);
|
||||||
|
|
||||||
// 无障碍服务状态
|
// 无障碍服务状态
|
||||||
const [serviceRunning, setServiceRunning] = useState(false);
|
const [serviceRunning, setServiceRunning] = useState(false);
|
||||||
@@ -64,7 +64,62 @@ export default function AutomationScreen() {
|
|||||||
const [screenshotActive, setScreenshotActive] = useState(false);
|
const [screenshotActive, setScreenshotActive] = useState(false);
|
||||||
const [topApp, setTopApp] = useState<{ package: string; activity: string } | null>(null);
|
const [topApp, setTopApp] = useState<{ package: string; activity: string } | null>(null);
|
||||||
|
|
||||||
const sourceEntries = useMemo(() => Object.entries(stats) as [AutomationSource, number][], [stats]);
|
// 权限状态
|
||||||
|
const [notifEnabled, setNotifEnabled] = useState<boolean | null>(null);
|
||||||
|
const [smsGranted, setSmsGranted] = useState<boolean | null>(null);
|
||||||
|
const [storageGranted, setStorageGranted] = useState<boolean | null>(null);
|
||||||
|
const [overlayGranted, setOverlayGranted] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
// OCR 模型安装/下载状态
|
||||||
|
const [modelStatus, setModelStatus] = useState<'idle' | 'installing' | 'verifying' | 'done' | 'error'>('idle');
|
||||||
|
const [modelProgress, setModelProgress] = useState(0);
|
||||||
|
|
||||||
|
const handleInstallModel = async () => {
|
||||||
|
setModelStatus('installing');
|
||||||
|
try {
|
||||||
|
await ensureOcrModels((p) => setModelProgress(p));
|
||||||
|
setModelStatus('done');
|
||||||
|
} catch (e) {
|
||||||
|
setModelStatus('error');
|
||||||
|
Alert.alert(t('common.error'), String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRedownloadModel = async () => {
|
||||||
|
setModelStatus('installing');
|
||||||
|
setModelProgress(0);
|
||||||
|
try {
|
||||||
|
await downloadOcrModels(undefined, (p) => setModelProgress(p));
|
||||||
|
setModelStatus('done');
|
||||||
|
} catch (e) {
|
||||||
|
setModelStatus('error');
|
||||||
|
Alert.alert(t('automation.ocrModelDownloadFail'), String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleL2Toggle = (val: boolean) => {
|
||||||
|
if (val && !ocrModelVersion) {
|
||||||
|
Alert.alert(t('automation.ocrModelTitle'), t('automation.ocrModelNotInstalled'), [
|
||||||
|
{ text: t('common.cancel'), onPress: () => setLayer2OcrEnabled(false) },
|
||||||
|
{ text: t('automation.ocrModelInstall'), onPress: handleInstallModel },
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLayer2OcrEnabled(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
|
const handleL3Toggle = (val: boolean) => {
|
||||||
|
if (val && (!aiEnabled || !aiApiKey)) {
|
||||||
|
Alert.alert(t('automation.layer3Ai'), t('automation.layer3AiDisabled'), [
|
||||||
|
{ text: t('common.cancel'), style: 'cancel' },
|
||||||
|
{ text: t('common.confirm'), onPress: () => router.push('/settings/ai') },
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLayer3AiEnabled(val);
|
||||||
|
};
|
||||||
|
|
||||||
// 刷新无障碍状态
|
// 刷新无障碍状态
|
||||||
const refreshAccessibilityState = useCallback(async () => {
|
const refreshAccessibilityState = useCallback(async () => {
|
||||||
@@ -81,8 +136,8 @@ export default function AutomationScreen() {
|
|||||||
setPageSignatures(sigs);
|
setPageSignatures(sigs);
|
||||||
setPaymentPackages(pkgs.map(pkg => ({ package: pkg, label: getPackageLabel(pkg) })));
|
setPaymentPackages(pkgs.map(pkg => ({ package: pkg, label: getPackageLabel(pkg) })));
|
||||||
setTopApp(top && top.package ? top : null);
|
setTopApp(top && top.package ? top : null);
|
||||||
} catch {
|
} catch (e) {
|
||||||
// 静默
|
logger.debug('automation', '[刷新无障碍状态] 失败', e);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -100,27 +155,56 @@ export default function AutomationScreen() {
|
|||||||
};
|
};
|
||||||
}, [refreshAccessibilityState]);
|
}, [refreshAccessibilityState]);
|
||||||
|
|
||||||
const handleProcess = async () => {
|
// 检查权限状态(挂载 + 从系统设置返回前台时重新检查)
|
||||||
if (!ledger) return;
|
useEffect(() => {
|
||||||
const rules = useMetadataStore.getState().rules;
|
if (Platform.OS !== 'android') return;
|
||||||
const categories = useMetadataStore.getState().categories;
|
|
||||||
const history = ledger.transactions;
|
const checkPermissions = () => {
|
||||||
try {
|
const bridge = getAccessibilityBridge();
|
||||||
await processAll(ledger, rules, categories, history);
|
if (bridge && typeof bridge.isNotificationListenerEnabled === 'function') {
|
||||||
} catch (e) {
|
bridge.isNotificationListenerEnabled().then(setNotifEnabled).catch(() => setNotifEnabled(false));
|
||||||
Alert.alert(String(e));
|
}
|
||||||
}
|
if (bridge && typeof bridge.canDrawOverlays === 'function') {
|
||||||
|
bridge.canDrawOverlays().then(setOverlayGranted).catch(() => setOverlayGranted(false));
|
||||||
|
}
|
||||||
|
const smsPerm = 'android.permission.RECEIVE_SMS';
|
||||||
|
PermissionsAndroid.check(smsPerm).then(async (granted) => {
|
||||||
|
if (granted) { setSmsGranted(true); return; }
|
||||||
|
try {
|
||||||
|
const result = await PermissionsAndroid.request(smsPerm);
|
||||||
|
setSmsGranted(result === PermissionsAndroid.RESULTS.GRANTED);
|
||||||
|
} catch { setSmsGranted(false); }
|
||||||
|
}).catch(() => setSmsGranted(false));
|
||||||
|
const storagePerm = Number(Platform.Version) >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
|
||||||
|
PermissionsAndroid.check(storagePerm).then(g => setStorageGranted(g as boolean)).catch(() => setStorageGranted(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
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((e) => { logger.debug('automation', '[打开通知设置] 失败', e); });
|
||||||
};
|
};
|
||||||
|
|
||||||
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((e) => { logger.debug('automation', '[打开悬浮窗设置] 失败', e); });
|
||||||
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 = () => {
|
||||||
@@ -215,8 +299,8 @@ export default function AutomationScreen() {
|
|||||||
try {
|
try {
|
||||||
await bridge.removePageSignature(sig);
|
await bridge.removePageSignature(sig);
|
||||||
refreshAccessibilityState();
|
refreshAccessibilityState();
|
||||||
} catch {
|
} catch (e) {
|
||||||
// 静默
|
logger.warn('automation', `[删除页面签名] 失败: ${sig}`, e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -235,8 +319,8 @@ export default function AutomationScreen() {
|
|||||||
try {
|
try {
|
||||||
await bridge.clearPageSignatures();
|
await bridge.clearPageSignatures();
|
||||||
refreshAccessibilityState();
|
refreshAccessibilityState();
|
||||||
} catch {
|
} catch (e) {
|
||||||
// 静默
|
logger.warn('automation', '[清空页面签名] 失败', e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -245,47 +329,11 @@ export default function AutomationScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<ScreenHeader title={t('automation.title')} />
|
||||||
<Pressable onPress={() => router.back()}>
|
|
||||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
|
||||||
{t('automation.title')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FlatList
|
<ScrollView ref={scrollRef} contentContainerStyle={styles.content}>
|
||||||
data={drafts}
|
|
||||||
keyExtractor={(item, index) => item.draft.sourceEventId ?? `${item.draft.date}-${index}`}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<Card title={`${item.draft.date} · ${item.draft.narration}`}>
|
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
|
|
||||||
{item.draft.postings.map(p => `${p.account} ${p.amount} ${p.currency ?? ''}`).join('\n')}
|
|
||||||
</Text>
|
|
||||||
<View style={{ flexDirection: 'row', gap: 8, marginTop: 8 }}>
|
|
||||||
<Button label={t('automation.confirmDraft')} onPress={() => handleConfirm(index)} />
|
|
||||||
<Button label={t('automation.rejectDraft')} onPress={() => rejectDraft(index)} variant="secondary" />
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
ListHeaderComponent={
|
|
||||||
<View style={{ gap: 12, marginBottom: 12 }}>
|
<View style={{ gap: 12, marginBottom: 12 }}>
|
||||||
{/* 通道统计 */}
|
|
||||||
<Card title={t('automation.channelStats')}>
|
|
||||||
<View style={styles.statsRow}>
|
|
||||||
{sourceEntries.map(([source, count]) => (
|
|
||||||
<View key={source} style={styles.statItem}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
|
||||||
{t(SOURCE_LABELS[source])}
|
|
||||||
</Text>
|
|
||||||
<Text style={[theme.typography.h3, { color: count > 0 ? theme.colors.accent : theme.colors.fgSecondary }]}>
|
|
||||||
{count}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 无障碍服务状态与控制 */}
|
{/* 无障碍服务状态与控制 */}
|
||||||
{Platform.OS === 'android' && (
|
{Platform.OS === 'android' && (
|
||||||
@@ -303,6 +351,25 @@ export default function AutomationScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 悬浮球开关 */}
|
||||||
|
<View style={[styles.switchRow, { marginBottom: 8 }]}>
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
||||||
|
{t('automation.floatingBall')}
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
value={floatingBallEnabled}
|
||||||
|
onValueChange={async (val) => {
|
||||||
|
setFloatingBallEnabled(val);
|
||||||
|
const bridge = getAccessibilityBridge();
|
||||||
|
if (bridge) {
|
||||||
|
try { await bridge.setFloatingBallEnabled(val); } catch (e) { logger.debug('automation', '[悬浮球] 同步开关失败', e); }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
||||||
|
thumbColor={theme.colors.fgInverse}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<View style={{ gap: 8 }}>
|
<View style={{ gap: 8 }}>
|
||||||
{!serviceRunning && (
|
{!serviceRunning && (
|
||||||
@@ -382,51 +449,200 @@ export default function AutomationScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 检测到的事件 */}
|
{/* --- Card A: 自动记账层级 --- */}
|
||||||
<Card title={t('automation.detectedEvents', { count: detected.length })}>
|
<Card title={t('automation.autoBookkeeping')}>
|
||||||
{detected.length === 0 ? (
|
{/* L1 规则匹配 */}
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
|
||||||
content: { padding: 16, paddingBottom: 64 },
|
content: { padding: 16, paddingBottom: 64 },
|
||||||
statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
|
|
||||||
statItem: { alignItems: 'center', gap: 4 },
|
|
||||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
statusDot: { width: 8, height: 8, borderRadius: 4 },
|
statusDot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
|
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
|
layerRow: { flexDirection: 'row', alignItems: 'center' },
|
||||||
chip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 },
|
chip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 },
|
||||||
|
progressBar: { height: 4, borderRadius: 2, overflow: 'hidden' },
|
||||||
|
progressFill: { height: '100%', borderRadius: 2 },
|
||||||
sigRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 4 },
|
sigRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 4 },
|
||||||
|
permRow: { flexDirection: 'row', alignItems: 'center' },
|
||||||
});
|
});
|
||||||
|
|||||||
+85
-125
@@ -3,27 +3,23 @@
|
|||||||
*
|
*
|
||||||
* 功能:预算列表(含进度条) + 添加/编辑/删除。
|
* 功能:预算列表(含进度条) + 添加/编辑/删除。
|
||||||
* 进度计算调用 calculateBudgetProgress 纯函数,展示已用/剩余/百分比。
|
* 进度计算调用 calculateBudgetProgress 纯函数,展示已用/剩余/百分比。
|
||||||
|
* P5:套 ManagementScreen 模板,消除手写头部/新增按钮/弹窗样板。
|
||||||
*/
|
*/
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { FormModal, type FormField } from '../../components/FormModal';
|
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||||
import { calculateBudgetProgress } from '../../domain/budgets';
|
import { calculateBudgetProgress } from '../../domain/finance/budgets';
|
||||||
import type { Budget } from '../../domain/budgets';
|
import { toDateString } from '../../domain/core/decimal';
|
||||||
|
import type { Budget } from '../../domain/finance/budgets';
|
||||||
type ModalMode = { type: 'add' } | { type: 'edit'; budget: Budget } | null;
|
|
||||||
|
|
||||||
export default function BudgetScreen() {
|
export default function BudgetScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const budgets = useMetadataStore(s => s.budgets);
|
const budgets = useMetadataStore(s => s.budgets);
|
||||||
const addBudget = useMetadataStore(s => s.addBudget);
|
const addBudget = useMetadataStore(s => s.addBudget);
|
||||||
@@ -31,130 +27,94 @@ export default function BudgetScreen() {
|
|||||||
const removeBudget = useMetadataStore(s => s.removeBudget);
|
const removeBudget = useMetadataStore(s => s.removeBudget);
|
||||||
const ledger = useLedgerStore(s => s.ledger);
|
const ledger = useLedgerStore(s => s.ledger);
|
||||||
|
|
||||||
const [modal, setModal] = useState<ModalMode>(null);
|
|
||||||
|
|
||||||
const transactions = ledger?.transactions ?? [];
|
const transactions = ledger?.transactions ?? [];
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
// 用本地时区格式化,避免 toISOString 的 UTC 偏移导致负时区日期错位
|
||||||
|
const today = toDateString(new Date());
|
||||||
const handleDelete = (budget: Budget) => {
|
|
||||||
Alert.alert(t('budget.deleteTitle'), t('budget.deleteConfirm', { name: budget.name }), [
|
|
||||||
{ text: t('common.cancel'), style: 'cancel' },
|
|
||||||
{ text: t('common.delete'), style: 'destructive', onPress: () => removeBudget(budget.id) },
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = (values: Record<string, string>) => {
|
|
||||||
const name = values.name?.trim() || t('budget.unnamed');
|
|
||||||
const amount = values.amount?.trim() || '0';
|
|
||||||
const period = (values.period as Budget['period']) || 'monthly';
|
|
||||||
const categoryAccount = values.categoryAccount?.trim() || undefined;
|
|
||||||
const startDate = values.startDate?.trim() || today;
|
|
||||||
|
|
||||||
const amtNum = parseFloat(amount);
|
|
||||||
if (isNaN(amtNum) || amtNum <= 0) {
|
|
||||||
Alert.alert(t('budget.invalidAmountTitle'), t('budget.invalidAmountDesc'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modal?.type === 'add') {
|
|
||||||
addBudget({ id: generateId('bud'), name, amount, period, categoryAccount, startDate });
|
|
||||||
} else if (modal?.type === 'edit') {
|
|
||||||
updateBudget(modal.budget.id, { name, amount, period, categoryAccount, startDate });
|
|
||||||
}
|
|
||||||
setModal(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const periodLabel = (p: string) => p === 'monthly' ? t('budget.periodMonthly') : p === 'weekly' ? t('budget.periodWeekly') : t('budget.periodYearly');
|
const periodLabel = (p: string) => p === 'monthly' ? t('budget.periodMonthly') : p === 'weekly' ? t('budget.periodWeekly') : t('budget.periodYearly');
|
||||||
|
|
||||||
const editFields: FormField[] = modal?.type === 'edit' ? [
|
|
||||||
{ key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder'), defaultValue: modal.budget.name },
|
|
||||||
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), defaultValue: modal.budget.amount, keyboardType: 'decimal-pad' },
|
|
||||||
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly', defaultValue: modal.budget.period },
|
|
||||||
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder'), defaultValue: modal.budget.categoryAccount ?? '' },
|
|
||||||
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01', defaultValue: modal.budget.startDate },
|
|
||||||
] : [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<ManagementScreen<Budget>
|
||||||
<View style={styles.header}>
|
title={t('budget.title')}
|
||||||
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
|
items={budgets}
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('budget.title')}</Text>
|
keyExtractor={budget => budget.id}
|
||||||
</View>
|
addLabel={t('budget.add')}
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
emptyText={t('budget.empty')}
|
||||||
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
|
renderItem={(budget, { openEdit, confirmDelete }) => {
|
||||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
const progress = calculateBudgetProgress(budget, transactions, today);
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('budget.add')}</Text>
|
return (
|
||||||
</Pressable>
|
<Pressable onPress={openEdit} onLongPress={confirmDelete}>
|
||||||
{budgets.map(budget => {
|
<Card title={budget.name}>
|
||||||
const progress = calculateBudgetProgress(budget, transactions, today);
|
<View style={styles.budgetRow}>
|
||||||
return (
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
||||||
<Pressable
|
{t('budget.yuanPer', { amount: budget.amount, period: periodLabel(budget.period) })}
|
||||||
key={budget.id}
|
</Text>
|
||||||
onPress={() => setModal({ type: 'edit', budget })}
|
<Text style={[theme.typography.caption, {
|
||||||
onLongPress={() => handleDelete(budget)}
|
color: progress.overBudget ? theme.colors.error : theme.colors.fgSecondary,
|
||||||
>
|
}]}>
|
||||||
<Card title={budget.name}>
|
{t('budget.used', { spent: progress.spent, pct: progress.percentage.toFixed(0) })}
|
||||||
<View style={styles.budgetRow}>
|
</Text>
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
</View>
|
||||||
{t('budget.yuanPer', { amount: budget.amount, period: periodLabel(budget.period) })}
|
{/* 进度条 */}
|
||||||
</Text>
|
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary }]}>
|
||||||
<Text style={[theme.typography.caption, {
|
<View style={[
|
||||||
color: progress.overBudget ? theme.colors.error : theme.colors.fgSecondary,
|
styles.bar,
|
||||||
}]}>
|
{
|
||||||
{t('budget.used', { spent: progress.spent, pct: progress.percentage.toFixed(0) })}
|
width: `${Math.min(progress.percentage, 100)}%`,
|
||||||
</Text>
|
backgroundColor: progress.overBudget ? theme.colors.error : theme.colors.accent,
|
||||||
</View>
|
},
|
||||||
{/* 进度条 */}
|
]} />
|
||||||
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary }]}>
|
</View>
|
||||||
<View style={[
|
{budget.categoryAccount && (
|
||||||
styles.bar,
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
|
||||||
{
|
{budget.categoryAccount}
|
||||||
width: `${Math.min(progress.percentage, 100)}%`,
|
</Text>
|
||||||
backgroundColor: progress.overBudget ? theme.colors.error : theme.colors.accent,
|
)}
|
||||||
},
|
</Card>
|
||||||
]} />
|
</Pressable>
|
||||||
</View>
|
);
|
||||||
{budget.categoryAccount && (
|
}}
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
|
formTitle={editing => (editing ? t('budget.editTitle') : t('budget.add'))}
|
||||||
{budget.categoryAccount}
|
formFields={editing => [
|
||||||
</Text>
|
{ key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder'), defaultValue: editing?.name },
|
||||||
)}
|
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), defaultValue: editing?.amount, keyboardType: 'decimal-pad' },
|
||||||
</Card>
|
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly', defaultValue: editing?.period },
|
||||||
</Pressable>
|
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder'), defaultValue: editing?.categoryAccount ?? '' },
|
||||||
);
|
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01', defaultValue: editing?.startDate },
|
||||||
})}
|
]}
|
||||||
{budgets.length === 0 && (
|
onSubmit={(values, editing) => {
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
|
const name = values.name?.trim() || t('budget.unnamed');
|
||||||
{t('budget.empty')}
|
const amount = values.amount?.trim() || '0';
|
||||||
</Text>
|
const period = (values.period as Budget['period']) || 'monthly';
|
||||||
)}
|
const categoryAccount = values.categoryAccount?.trim() || undefined;
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
const startDate = values.startDate?.trim() || today;
|
||||||
|
|
||||||
|
const amtNum = parseFloat(amount);
|
||||||
|
if (isNaN(amtNum) || amtNum <= 0) {
|
||||||
|
Alert.alert(t('budget.invalidAmountTitle'), t('budget.invalidAmountDesc'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
updateBudget(editing.id, { name, amount, period, categoryAccount, startDate });
|
||||||
|
} else {
|
||||||
|
addBudget({ id: generateId('bud'), name, amount, period, categoryAccount, startDate });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
onDelete={budget => removeBudget(budget.id)}
|
||||||
|
deleteConfirmText={budget => t('budget.deleteConfirm', { name: budget.name })}
|
||||||
|
deleteConfirmTitle={t('budget.deleteTitle')}
|
||||||
|
footer={
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
||||||
{t('common.clickEditLongDelete')}
|
{t('common.clickEditLongDelete')}
|
||||||
</Text>
|
</Text>
|
||||||
</ScrollView>
|
}
|
||||||
|
/>
|
||||||
<FormModal
|
|
||||||
visible={modal !== null}
|
|
||||||
title={modal?.type === 'edit' ? t('budget.editTitle') : t('budget.add')}
|
|
||||||
fields={modal?.type === 'edit' ? editFields : [
|
|
||||||
{ key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder') },
|
|
||||||
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), keyboardType: 'decimal-pad' },
|
|
||||||
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly' },
|
|
||||||
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder') },
|
|
||||||
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01' },
|
|
||||||
]}
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={() => setModal(null)}
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
|
||||||
content: { padding: 16, gap: 12 },
|
|
||||||
addBtn: { flexDirection: 'row', alignItems: 'center' },
|
|
||||||
budgetRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
budgetRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||||
barWrap: { height: 8, borderRadius: 4, overflow: 'hidden', marginTop: 8 },
|
barWrap: { height: 8, borderRadius: 4, overflow: 'hidden', marginTop: 8 },
|
||||||
bar: { height: '100%', borderRadius: 4 },
|
bar: { height: '100%', borderRadius: 4 },
|
||||||
|
|||||||
+75
-108
@@ -1,135 +1,102 @@
|
|||||||
/**
|
/**
|
||||||
* 分类管理页面(plan.md「1.1 category/index」+ 决策 1 双轨制)。
|
* 分类管理页面(plan.md「1.1 category/index」+ 决策 1 双轨制)。
|
||||||
*
|
*
|
||||||
* 功能:分类列表(支出/收入分组)+ 添加/编辑/删除。
|
* 功能:分类列表(支出/收入 chips 切换)+ 添加/编辑/删除。
|
||||||
* 数据来源:metadataStore(持久化),linkedAccount 映射到 Beancount 账户。
|
* 数据来源:metadataStore(持久化),linkedAccount 映射到 Beancount 账户。
|
||||||
|
* P5:套 ManagementScreen 模板,headerContent 放支出/收入 chips。
|
||||||
*/
|
*/
|
||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { useTheme, createCommonStyles } from '../../theme';
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useTheme } from '../../theme';
|
|
||||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { FormModal, type FormField } from '../../components/FormModal';
|
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||||
import type { Category } from '../../domain/categories';
|
import { Touchable } from '../../components/ui/Touchable';
|
||||||
|
import type { Category } from '../../domain/taxonomy/categories';
|
||||||
type ModalMode = { type: 'add'; catType: 'expense' | 'income' } | { type: 'edit'; category: Category } | null;
|
|
||||||
|
|
||||||
export default function CategoryScreen() {
|
export default function CategoryScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||||
|
|
||||||
const categories = useMetadataStore(s => s.categories);
|
const categories = useMetadataStore(s => s.categories);
|
||||||
const addCategory = useMetadataStore(s => s.addCategory);
|
const addCategory = useMetadataStore(s => s.addCategory);
|
||||||
const updateCategory = useMetadataStore(s => s.updateCategory);
|
const updateCategory = useMetadataStore(s => s.updateCategory);
|
||||||
const removeCategory = useMetadataStore(s => s.removeCategory);
|
const removeCategory = useMetadataStore(s => s.removeCategory);
|
||||||
|
|
||||||
const [modal, setModal] = useState<ModalMode>(null);
|
/** 当前展示的分类类型(支出/收入),新增分支按此落库。 */
|
||||||
|
const [catType, setCatType] = useState<'expense' | 'income'>('expense');
|
||||||
const expenseCats = categories.filter(c => c.type === 'expense');
|
|
||||||
const incomeCats = categories.filter(c => c.type === 'income');
|
|
||||||
|
|
||||||
const handleDelete = (cat: Category) => {
|
|
||||||
Alert.alert(t('category.deleteTitle'), t('category.deleteConfirm', { name: cat.name }), [
|
|
||||||
{ text: t('common.cancel'), style: 'cancel' },
|
|
||||||
{ text: t('common.delete'), style: 'destructive', onPress: () => removeCategory(cat.id) },
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = (values: Record<string, string>) => {
|
|
||||||
if (modal?.type === 'add') {
|
|
||||||
addCategory({
|
|
||||||
id: generateId('cat'),
|
|
||||||
name: values.name || t('common.untitled'),
|
|
||||||
type: modal.catType,
|
|
||||||
linkedAccount: values.linkedAccount || 'Expenses:Uncategorized',
|
|
||||||
keywords: values.keywords ? values.keywords.split(/[,,\s]+/).filter(Boolean) : [],
|
|
||||||
});
|
|
||||||
} else if (modal?.type === 'edit') {
|
|
||||||
updateCategory(modal.category.id, {
|
|
||||||
name: values.name || modal.category.name,
|
|
||||||
linkedAccount: values.linkedAccount || modal.category.linkedAccount,
|
|
||||||
keywords: values.keywords ? values.keywords.split(/[,,\s]+/).filter(Boolean) : [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setModal(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderRow = (cat: Category) => (
|
|
||||||
<Pressable
|
|
||||||
key={cat.id}
|
|
||||||
onPress={() => setModal({ type: 'edit', category: cat })}
|
|
||||||
onLongPress={() => handleDelete(cat)}
|
|
||||||
style={({ pressed }) => [styles.row, { borderTopColor: theme.colors.divider, opacity: pressed ? 0.6 : 1 }]}
|
|
||||||
>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{cat.name}</Text>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
|
|
||||||
{cat.keywords.length > 0 && (
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
|
|
||||||
{t('category.keywordsLabel')}: {cat.keywords.join(', ')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
|
|
||||||
const editFields: FormField[] = modal?.type === 'edit' ? [
|
|
||||||
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder'), defaultValue: modal.category.name },
|
|
||||||
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder'), defaultValue: modal.category.linkedAccount },
|
|
||||||
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder'), defaultValue: modal.category.keywords.join(', ') },
|
|
||||||
] : [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<ManagementScreen<Category>
|
||||||
<View style={styles.header}>
|
title={t('category.title')}
|
||||||
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
|
items={categories.filter(c => c.type === catType)}
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('category.title')}</Text>
|
keyExtractor={cat => cat.id}
|
||||||
</View>
|
addLabel={catType === 'income' ? t('category.addIncome') : t('category.addExpense')}
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
headerContent={
|
||||||
<Card title={t('category.expense')}>
|
<View style={styles.chipRow}>
|
||||||
<Pressable onPress={() => setModal({ type: 'add', catType: 'expense' })} style={styles.addBtn}>
|
{(['expense', 'income'] as const).map(tp => (
|
||||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
<Touchable
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('category.addExpense')}</Text>
|
key={tp}
|
||||||
</Pressable>
|
onPress={() => setCatType(tp)}
|
||||||
{expenseCats.map(renderRow)}
|
style={[commonStyles.chip, catType === tp && commonStyles.chipActive]}
|
||||||
|
>
|
||||||
|
<Text style={[commonStyles.chipText, catType === tp && commonStyles.chipTextActive]}>
|
||||||
|
{tp === 'expense' ? t('category.expense') : t('category.income')}
|
||||||
|
</Text>
|
||||||
|
</Touchable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={(cat, { openEdit, confirmDelete }) => (
|
||||||
|
<Card title={cat.name} onPress={openEdit} onLongPress={confirmDelete}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
|
||||||
|
{cat.keywords.length > 0 && (
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
|
||||||
|
{t('category.keywordsLabel')}: {cat.keywords.join(', ')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
<Card title={t('category.income')}>
|
)}
|
||||||
<Pressable onPress={() => setModal({ type: 'add', catType: 'income' })} style={styles.addBtn}>
|
formTitle={editing => (editing
|
||||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
? t('category.editTitle')
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('category.addIncome')}</Text>
|
: catType === 'income' ? t('category.addIncome') : t('category.addExpense'))}
|
||||||
</Pressable>
|
formFields={editing => [
|
||||||
{incomeCats.map(renderRow)}
|
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder'), defaultValue: editing?.name },
|
||||||
</Card>
|
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder'), defaultValue: editing?.linkedAccount },
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder'), defaultValue: editing?.keywords.join(', ') },
|
||||||
|
]}
|
||||||
|
onSubmit={(values, editing) => {
|
||||||
|
if (editing) {
|
||||||
|
updateCategory(editing.id, {
|
||||||
|
name: values.name || editing.name,
|
||||||
|
linkedAccount: values.linkedAccount || editing.linkedAccount,
|
||||||
|
keywords: values.keywords ? values.keywords.split(/[,,\s]+/).filter(Boolean) : [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addCategory({
|
||||||
|
id: generateId('cat'),
|
||||||
|
name: values.name || t('common.untitled'),
|
||||||
|
type: catType,
|
||||||
|
linkedAccount: values.linkedAccount || (catType === 'income' ? 'Income:未分类' : 'Expenses:未分类'),
|
||||||
|
keywords: values.keywords ? values.keywords.split(/[,,\s]+/).filter(Boolean) : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
onDelete={cat => removeCategory(cat.id)}
|
||||||
|
deleteConfirmText={cat => t('category.deleteConfirm', { name: cat.name })}
|
||||||
|
deleteConfirmTitle={t('category.deleteTitle')}
|
||||||
|
footer={
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
||||||
{t('common.clickEditLongDelete')}
|
{t('common.clickEditLongDelete')}
|
||||||
</Text>
|
</Text>
|
||||||
</ScrollView>
|
}
|
||||||
|
/>
|
||||||
<FormModal
|
|
||||||
visible={modal !== null}
|
|
||||||
title={modal?.type === 'edit' ? t('category.editTitle') : t('category.addTitle', { type: modal?.catType === 'income' ? t('category.income') : t('category.expense') })}
|
|
||||||
fields={modal?.type === 'edit' ? editFields : [
|
|
||||||
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder') },
|
|
||||||
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder') },
|
|
||||||
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder') },
|
|
||||||
]}
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={() => setModal(null)}
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
chipRow: { flexDirection: 'row', gap: 8 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
|
||||||
content: { padding: 16, gap: 12 },
|
|
||||||
row: { flexDirection: 'row', alignItems: 'center', borderTopWidth: 1, paddingTop: 10 },
|
|
||||||
addBtn: { flexDirection: 'row', alignItems: 'center', paddingBottom: 10 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
+186
-131
@@ -1,29 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* 信用卡管理页面(plan.md「1.1 credit-card/index」+ 决策 1 双轨制)。
|
* 信用卡管理页面(plan.md「1.1 credit-card/index」+ 决策 1 双轨制)。
|
||||||
*
|
*
|
||||||
* 功能:信用卡列表(银行/尾号/账单日/还款日/额度) + 添加/编辑/删除。
|
* 功能:信用卡列表(银行/尾号/账单日/还款日/额度/账单盒) + 添加/编辑/删除。
|
||||||
* linkedAccount 映射到 Beancount 的 Liabilities 账户。
|
* linkedAccount 映射到 Beancount 的 Liabilities 账户。
|
||||||
|
* P5:套 ManagementScreen 模板,富 renderItem 直套。
|
||||||
*/
|
*/
|
||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { FormModal, type FormField } from '../../components/FormModal';
|
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||||
import type { CreditCard } from '../../domain/creditCards';
|
import { AccountCreateModal } from '../../components/account/AccountCreateModal';
|
||||||
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards';
|
import { Touchable } from '../../components/ui/Touchable';
|
||||||
|
import type { CreditCard } from '../../domain/finance/creditCards';
|
||||||
type ModalMode = { type: 'add' } | { type: 'edit'; card: CreditCard } | null;
|
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/finance/creditCards';
|
||||||
|
|
||||||
export default function CreditCardScreen() {
|
export default function CreditCardScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const creditCards = useMetadataStore(s => s.creditCards);
|
const creditCards = useMetadataStore(s => s.creditCards);
|
||||||
const addCreditCard = useMetadataStore(s => s.addCreditCard);
|
const addCreditCard = useMetadataStore(s => s.addCreditCard);
|
||||||
@@ -31,150 +28,208 @@ export default function CreditCardScreen() {
|
|||||||
const removeCreditCard = useMetadataStore(s => s.removeCreditCard);
|
const removeCreditCard = useMetadataStore(s => s.removeCreditCard);
|
||||||
|
|
||||||
const ledger = useLedgerStore(s => s.ledger);
|
const ledger = useLedgerStore(s => s.ledger);
|
||||||
const transactions = ledger?.transactions ?? [];
|
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
|
||||||
|
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger?.transactions]);
|
||||||
|
|
||||||
const [modal, setModal] = useState<ModalMode>(null);
|
const [isQuickAddingAccount, setIsQuickAddingAccount] = useState(false);
|
||||||
|
|
||||||
/** 计算某账户的当前余额(从交易过账汇总)。 */
|
/** 过滤出所有以 Liabilities 开头的信用负债账户。 */
|
||||||
const getAccountBalance = (account: string): string => {
|
const liabilityAccounts = useMemo(() => {
|
||||||
let balance = 0;
|
if (!ledger?.accounts) return [];
|
||||||
|
const accountNames = Array.from(ledger.accounts.keys());
|
||||||
|
return accountNames.filter((name: string) => name.startsWith('Liabilities'));
|
||||||
|
}, [ledger?.accounts]);
|
||||||
|
|
||||||
|
const liabilityOptions = useMemo(() => {
|
||||||
|
return liabilityAccounts.map((name: string) => ({
|
||||||
|
label: name.replace(/^Liabilities:/, ''),
|
||||||
|
value: name,
|
||||||
|
}));
|
||||||
|
}, [liabilityAccounts]);
|
||||||
|
|
||||||
|
/** 预计算所有账户余额 Map(避免在渲染循环中重复遍历)。 */
|
||||||
|
const accountBalances = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
for (const tx of transactions) {
|
for (const tx of transactions) {
|
||||||
for (const p of tx.postings) {
|
for (const p of tx.postings) {
|
||||||
if (p.account === account && p.amount) {
|
if (p.account && p.amount) {
|
||||||
balance += parseFloat(p.amount);
|
const prev = parseFloat(map.get(p.account) ?? '0');
|
||||||
|
map.set(p.account, (prev + parseFloat(p.amount)).toFixed(2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return String(balance.toFixed(2));
|
return map;
|
||||||
};
|
}, [transactions]);
|
||||||
|
|
||||||
const handleDelete = (card: CreditCard) => {
|
const getAccountBalance = (account: string): string => accountBalances.get(account) ?? '0.00';
|
||||||
Alert.alert(t('creditCard.deleteTitle'), t('creditCard.deleteConfirm', { name: card.name }), [
|
|
||||||
{ text: t('common.cancel'), style: 'cancel' },
|
|
||||||
{ text: t('common.delete'), style: 'destructive', onPress: () => removeCreditCard(card.id) },
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = (values: Record<string, string>) => {
|
const handleQuickAddAccount = (values: Record<string, string>) => {
|
||||||
const card: Omit<CreditCard, 'id'> = {
|
const type = values.type || 'Liabilities';
|
||||||
name: values.name?.trim() || t('creditCard.unnamed'),
|
const name = values.name?.trim() || 'CreditCard';
|
||||||
lastFour: values.lastFour?.trim() || '0000',
|
const fullAccountName = `${type}:${name}`;
|
||||||
bankName: values.bankName?.trim() || 'UNKNOWN',
|
autoOpenAccounts([fullAccountName]);
|
||||||
billingDay: parseInt(values.billingDay, 10) || 1,
|
setIsQuickAddingAccount(false);
|
||||||
paymentDay: parseInt(values.paymentDay, 10) || 1,
|
|
||||||
creditLimit: values.creditLimit?.trim() || '0',
|
|
||||||
currency: values.currency?.trim() || 'CNY',
|
|
||||||
linkedAccount: values.linkedAccount?.trim() || 'Liabilities:CreditCard',
|
|
||||||
};
|
|
||||||
if (modal?.type === 'add') {
|
|
||||||
addCreditCard({ ...card, id: generateId('cc') });
|
|
||||||
} else if (modal?.type === 'edit') {
|
|
||||||
updateCreditCard(modal.card.id, card);
|
|
||||||
}
|
|
||||||
setModal(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fieldsFor = (card?: CreditCard): FormField[] => [
|
|
||||||
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: card?.name },
|
|
||||||
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: 'CMB', defaultValue: card?.bankName },
|
|
||||||
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: card?.lastFour, keyboardType: 'numeric' },
|
|
||||||
{ key: 'billingDay', label: t('creditCard.fieldBillingDay'), placeholder: '5', defaultValue: card ? String(card.billingDay) : '', keyboardType: 'numeric' },
|
|
||||||
{ key: 'paymentDay', label: t('creditCard.fieldPaymentDay'), placeholder: '25', defaultValue: card ? String(card.paymentDay) : '', keyboardType: 'numeric' },
|
|
||||||
{ key: 'creditLimit', label: t('creditCard.fieldLimit'), placeholder: '10000', defaultValue: card?.creditLimit, keyboardType: 'decimal-pad' },
|
|
||||||
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: card?.currency },
|
|
||||||
{ key: 'linkedAccount', label: t('creditCard.fieldLinkedAccount'), placeholder: 'Liabilities:CreditCard:CMB', defaultValue: card?.linkedAccount },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<>
|
||||||
<View style={styles.header}>
|
<ManagementScreen<CreditCard>
|
||||||
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
|
title={t('creditCard.title')}
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('creditCard.title')}</Text>
|
items={creditCards}
|
||||||
</View>
|
keyExtractor={card => card.id}
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
addLabel={t('creditCard.add')}
|
||||||
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
|
emptyText={t('creditCard.empty')}
|
||||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
renderItem={(card, { openEdit, confirmDelete }) => {
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('creditCard.add')}</Text>
|
|
||||||
</Pressable>
|
|
||||||
{creditCards.map(card => {
|
|
||||||
const period = calculateBillingPeriod(card, new Date());
|
const period = calculateBillingPeriod(card, new Date());
|
||||||
const statementAmount = calculateStatementAmount(transactions, card, period);
|
const statementAmount = calculateStatementAmount(transactions, card, period);
|
||||||
const currentBalance = getAccountBalance(card.linkedAccount);
|
const currentBalance = getAccountBalance(card.linkedAccount);
|
||||||
const availableCredit = calculateAvailableCredit(card, currentBalance);
|
const availableCredit = calculateAvailableCredit(card, currentBalance);
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Card title={card.name} onPress={openEdit} onLongPress={confirmDelete}>
|
||||||
key={card.id}
|
<View style={styles.infoRow}>
|
||||||
onPress={() => setModal({ type: 'edit', card })}
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text>
|
||||||
onLongPress={() => handleDelete(card)}
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
|
||||||
>
|
</View>
|
||||||
<Card title={card.name}>
|
<View style={styles.infoRow}>
|
||||||
<View style={styles.infoRow}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBillingDay')}</Text>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.billingDay} {t('creditCard.daySuffix')}</Text>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
|
</View>
|
||||||
</View>
|
<View style={styles.infoRow}>
|
||||||
<View style={styles.infoRow}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelPaymentDay')}</Text>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBillingDay')}</Text>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.paymentDay} {t('creditCard.daySuffix')}</Text>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.billingDay} {t('creditCard.daySuffix')}</Text>
|
</View>
|
||||||
</View>
|
<View style={styles.infoRow}>
|
||||||
<View style={styles.infoRow}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelLimit')}</Text>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelPaymentDay')}</Text>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.creditLimit} {card.currency}</Text>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.paymentDay} {t('creditCard.daySuffix')}</Text>
|
</View>
|
||||||
</View>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>{card.linkedAccount}</Text>
|
||||||
<View style={styles.infoRow}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelLimit')}</Text>
|
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.creditLimit} {card.currency}</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>{card.linkedAccount}</Text>
|
|
||||||
|
|
||||||
{/* 账单周期与应还 */}
|
{/* 账单周期与应还 */}
|
||||||
<View style={[styles.billingBox, { borderColor: theme.colors.divider }]}>
|
<View style={[styles.billingBox, { borderColor: theme.colors.divider }]}>
|
||||||
<View style={styles.infoRow}>
|
<View style={styles.infoRow}>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.billingPeriod')}</Text>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.billingPeriod')}</Text>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{period.periodStart} ~ {period.periodEnd}</Text>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{period.periodStart} ~ {period.periodEnd}</Text>
|
||||||
</View>
|
|
||||||
<View style={styles.infoRow}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.dueDate')}</Text>
|
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.error }]}>{period.dueDate}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.infoRow}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.statementAmount')}</Text>
|
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{statementAmount} {card.currency}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.infoRow}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.availableCredit')}</Text>
|
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent }]}>{availableCredit} {card.currency}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
<View style={styles.infoRow}>
|
||||||
</Pressable>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.dueDate')}</Text>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.error }]}>{period.dueDate}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.statementAmount')}</Text>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{statementAmount} {card.currency}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.availableCredit')}</Text>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent }]}>{availableCredit} {card.currency}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
}}
|
||||||
{creditCards.length === 0 && (
|
formTitle={editing => (editing ? t('creditCard.editTitle') : t('creditCard.add'))}
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
|
onValuesChange={(changedKey: string, newValue: string, currentValues: Record<string, string>) => {
|
||||||
{t('creditCard.empty')}
|
if (changedKey === 'linkedAccount' && newValue) {
|
||||||
</Text>
|
const shortName = newValue.includes(':') ? newValue.slice(newValue.lastIndexOf(':') + 1) : newValue;
|
||||||
)}
|
const updates: Record<string, string> = {};
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
if (shortName) {
|
||||||
{t('common.clickEditLongDelete')}
|
updates.name = shortName;
|
||||||
</Text>
|
if (shortName.includes('招商') || shortName.toUpperCase().includes('CMB')) updates.bankName = '招商银行';
|
||||||
</ScrollView>
|
else if (shortName.includes('支付宝') || shortName.includes('花呗')) updates.bankName = '支付宝';
|
||||||
|
else if (shortName.includes('微信') || shortName.includes('微粒贷')) updates.bankName = '微信';
|
||||||
|
else if (shortName.includes('建设') || shortName.toUpperCase().includes('CCB')) updates.bankName = '建设银行';
|
||||||
|
else if (shortName.includes('工商') || shortName.toUpperCase().includes('ICBC')) updates.bankName = '工商银行';
|
||||||
|
else if (shortName.includes('中国银行') || shortName.toUpperCase().includes('BOC')) updates.bankName = '中国银行';
|
||||||
|
else if (shortName.includes('农业') || shortName.toUpperCase().includes('ABC')) updates.bankName = '农业银行';
|
||||||
|
else if (shortName.includes('交通') || shortName.toUpperCase().includes('BOCOM')) updates.bankName = '交通银行';
|
||||||
|
else if (!currentValues.bankName) updates.bankName = shortName;
|
||||||
|
}
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
formFields={editing => [
|
||||||
|
// Row 1: linkedAccount (1.5) + name (1.0)
|
||||||
|
liabilityOptions.length > 0
|
||||||
|
? {
|
||||||
|
key: 'linkedAccount',
|
||||||
|
label: t('creditCard.fieldLinkedAccount'),
|
||||||
|
type: 'dropdown',
|
||||||
|
options: liabilityOptions,
|
||||||
|
placeholder: '请选择关联信用账户',
|
||||||
|
defaultValue: editing?.linkedAccount ?? '',
|
||||||
|
flex: 1.5,
|
||||||
|
row: 1,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
key: 'linkedAccount',
|
||||||
|
label: t('creditCard.fieldLinkedAccount'),
|
||||||
|
placeholder: 'Liabilities:CreditCard:CMB',
|
||||||
|
defaultValue: editing?.linkedAccount ?? '',
|
||||||
|
flex: 1.5,
|
||||||
|
row: 1,
|
||||||
|
},
|
||||||
|
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: editing?.name, flex: 1.0, row: 1 },
|
||||||
|
|
||||||
<FormModal
|
// Row 2: bankName (1.0) + lastFour (1.0) + currency (1.0)
|
||||||
visible={modal !== null}
|
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: '招商银行', defaultValue: editing?.bankName, flex: 1.0, row: 2 },
|
||||||
title={modal?.type === 'edit' ? t('creditCard.editTitle') : t('creditCard.add')}
|
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: editing?.lastFour, keyboardType: 'numeric', flex: 1.0, row: 2 },
|
||||||
fields={fieldsFor(modal?.type === 'edit' ? modal.card : undefined)}
|
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: editing?.currency ?? 'CNY', flex: 1.0, row: 2 },
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={() => setModal(null)}
|
// 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>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
|
||||||
|
{/* 快捷新建负债账户弹窗 */}
|
||||||
|
<AccountCreateModal
|
||||||
|
visible={isQuickAddingAccount}
|
||||||
|
defaultType="Liabilities"
|
||||||
|
onConfirm={handleQuickAddAccount}
|
||||||
|
onCancel={() => setIsQuickAddingAccount(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
|
||||||
content: { padding: 16, gap: 12 },
|
|
||||||
addBtn: { flexDirection: 'row', alignItems: 'center' },
|
|
||||||
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
|
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
|
||||||
billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
|
billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
|
||||||
|
quickAddBtn: { paddingVertical: 6, alignItems: 'center', marginTop: 4 },
|
||||||
});
|
});
|
||||||
|
|||||||
+107
-41
@@ -1,39 +1,42 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import type { DuplicateDetail, DedupConfidence } from '../../domain/transaction/dedup';
|
||||||
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
|
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
// @ts-ignore
|
|
||||||
|
// 实例化纯 JS GBK 解码器(Hermes 原生 TextDecoder 仅支持 UTF-8,需强迫 text-encoding-gbk 返回其纯 JS 实现)
|
||||||
const GbkTextDecoder = (() => {
|
const GbkTextDecoder = (() => {
|
||||||
// @ts-ignore
|
const g = globalThis as Record<string, unknown>;
|
||||||
const origDecoder = global.TextDecoder;
|
const origDecoder = g.TextDecoder;
|
||||||
// @ts-ignore
|
const origEncoder = g.TextEncoder;
|
||||||
const origEncoder = global.TextEncoder;
|
try {
|
||||||
// @ts-ignore
|
g.TextDecoder = undefined;
|
||||||
global.TextDecoder = undefined;
|
g.TextEncoder = undefined;
|
||||||
// @ts-ignore
|
const dec = require('text-encoding-gbk').TextDecoder;
|
||||||
global.TextEncoder = undefined;
|
return dec;
|
||||||
const dec = require('text-encoding-gbk').TextDecoder;
|
} catch {
|
||||||
// @ts-ignore
|
return origDecoder;
|
||||||
global.TextDecoder = origDecoder;
|
} finally {
|
||||||
// @ts-ignore
|
g.TextDecoder = origDecoder;
|
||||||
global.TextEncoder = origEncoder;
|
g.TextEncoder = origEncoder;
|
||||||
return dec;
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
import { useImportStore } from '../../store/importStore';
|
import { useImportStore } from '../../store/importStore';
|
||||||
import { useMetadataStore } from '../../store/metadataStore';
|
import { useMetadataStore } from '../../store/metadataStore';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { Button } from '../../components/Button';
|
import { Button } from '../../components/ui/Button';
|
||||||
import { DedupBanner } from '../../components/DedupBanner';
|
import { DedupBanner } from '../../components/transaction/DedupBanner';
|
||||||
import { classifyWithCategories } from '../../domain/rules';
|
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||||
import { validateTransaction } from '../../domain/ledger';
|
import { classifyWithCategories } from '../../domain/rules/rules';
|
||||||
import type { ImportedEvent } from '../../domain/types';
|
import { validateTransaction } from '../../domain/core/ledger';
|
||||||
|
import type { ImportedEvent } from '../../domain/core/types';
|
||||||
|
|
||||||
|
|
||||||
// 纯 JS 实现 Base64 解码为字节数组
|
// 纯 JS 实现 Base64 解码为字节数组
|
||||||
@@ -67,7 +70,6 @@ function base64ToBytes(base64: string): Uint8Array {
|
|||||||
export default function ImportScreen() {
|
export default function ImportScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
|
||||||
const ledger = useLedgerStore(s => s.ledger);
|
const ledger = useLedgerStore(s => s.ledger);
|
||||||
const addTransaction = useLedgerStore(s => s.addTransaction);
|
const addTransaction = useLedgerStore(s => s.addTransaction);
|
||||||
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
|
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
|
||||||
@@ -117,7 +119,7 @@ export default function ImportScreen() {
|
|||||||
try {
|
try {
|
||||||
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
||||||
content = utf8Decoder.decode(bytes);
|
content = utf8Decoder.decode(bytes);
|
||||||
} catch (e) {
|
} catch {
|
||||||
// UTF-8 校验失败,回退到国标 GBK 解码(利用 text-encoding-gbk 保证 Hermes 兼容)
|
// UTF-8 校验失败,回退到国标 GBK 解码(利用 text-encoding-gbk 保证 Hermes 兼容)
|
||||||
try {
|
try {
|
||||||
const gbkDecoder = new GbkTextDecoder('gbk');
|
const gbkDecoder = new GbkTextDecoder('gbk');
|
||||||
@@ -143,7 +145,7 @@ export default function ImportScreen() {
|
|||||||
// 6. 自动执行 Pipeline,用户无需手动查找运行按钮
|
// 6. 自动执行 Pipeline,用户无需手动查找运行按钮
|
||||||
if (ledger) {
|
if (ledger) {
|
||||||
setStatus(t('importFlow.pipelineRunning'));
|
setStatus(t('importFlow.pipelineRunning'));
|
||||||
processEvents(ledger, []).then(result => {
|
processEvents(ledger, ledger.transactions || []).then(result => {
|
||||||
setManualDuplicates(result.duplicates);
|
setManualDuplicates(result.duplicates);
|
||||||
setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length }));
|
setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length }));
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
@@ -158,7 +160,7 @@ export default function ImportScreen() {
|
|||||||
|
|
||||||
const runPipeline = () => {
|
const runPipeline = () => {
|
||||||
if (!ledger) return;
|
if (!ledger) return;
|
||||||
processEvents(ledger, []).then(result => {
|
processEvents(ledger, ledger.transactions || []).then(result => {
|
||||||
setManualDuplicates(result.duplicates);
|
setManualDuplicates(result.duplicates);
|
||||||
setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount }));
|
setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount }));
|
||||||
}).catch(e => setStatus(String(e)));
|
}).catch(e => setStatus(String(e)));
|
||||||
@@ -245,7 +247,7 @@ export default function ImportScreen() {
|
|||||||
confirmDrafts(indices);
|
confirmDrafts(indices);
|
||||||
|
|
||||||
const successCount = drafts.length;
|
const successCount = drafts.length;
|
||||||
const failCount = pendingDrafts.length - successCount;
|
const failCount = failedPayees.length;
|
||||||
|
|
||||||
if (failCount === 0) {
|
if (failCount === 0) {
|
||||||
setStatus(t('importFlow.batchAllSuccess', { count: successCount }));
|
setStatus(t('importFlow.batchAllSuccess', { count: successCount }));
|
||||||
@@ -269,11 +271,59 @@ export default function ImportScreen() {
|
|||||||
const rules = useMetadataStore.getState().rules;
|
const rules = useMetadataStore.getState().rules;
|
||||||
const categories = useMetadataStore.getState().categories;
|
const categories = useMetadataStore.getState().categories;
|
||||||
const classification = classifyWithCategories(event, rules, categories, ledger);
|
const classification = classifyWithCategories(event, rules, categories, ledger);
|
||||||
await addTransaction(classification.draft);
|
await addTransaction(classification.draft, undefined, true);
|
||||||
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
|
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
|
||||||
setStatus(t('importFlow.forcedImport'));
|
setStatus(t('importFlow.forcedImport'));
|
||||||
|
Alert.alert('记账成功', `已成功将「${event.counterparty || event.memo || '交易'}」单独记入账本`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus(String(err));
|
setStatus(String(err));
|
||||||
|
Alert.alert('记账失败', String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkDuplicate = async (event: ImportedEvent) => {
|
||||||
|
if (!ledger) return;
|
||||||
|
try {
|
||||||
|
const rules = useMetadataStore.getState().rules;
|
||||||
|
const categories = useMetadataStore.getState().categories;
|
||||||
|
const classification = classifyWithCategories(event, rules, categories, ledger);
|
||||||
|
|
||||||
|
// 寻找对应的历史匹配交易
|
||||||
|
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === event.id);
|
||||||
|
const matchedTx = detail?.matchedWith?.rawTransaction;
|
||||||
|
|
||||||
|
const linkTag = matchedTx?.links?.[0] || 'lnk-' + Math.random().toString(36).slice(2, 8);
|
||||||
|
|
||||||
|
// 为新草稿添加链接
|
||||||
|
const newDraft = {
|
||||||
|
...classification.draft,
|
||||||
|
links: Array.from(new Set([...(classification.draft.links || []), linkTag])),
|
||||||
|
};
|
||||||
|
|
||||||
|
await addTransaction(newDraft, undefined, true);
|
||||||
|
|
||||||
|
// 如果有历史匹配交易且它还没有此 linkTag,同步写回历史交易 raw
|
||||||
|
if (matchedTx && !matchedTx.links.includes(linkTag)) {
|
||||||
|
const currentRaw = matchedTx.raw;
|
||||||
|
const firstLineEnd = currentRaw.indexOf('\n');
|
||||||
|
const header = firstLineEnd !== -1 ? currentRaw.slice(0, firstLineEnd) : currentRaw;
|
||||||
|
const rest = firstLineEnd !== -1 ? currentRaw.slice(firstLineEnd) : '';
|
||||||
|
const updatedHeader = `${header} ^${linkTag}`;
|
||||||
|
const updatedRaw = `${updatedHeader}${rest}`;
|
||||||
|
|
||||||
|
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
|
||||||
|
if (mobileBean.includes(currentRaw)) {
|
||||||
|
const newContent = mobileBean.replace(currentRaw, updatedRaw);
|
||||||
|
await replaceMobileBean(newContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
|
||||||
|
setStatus(`已成功关联交易 (标签 ^${linkTag})`);
|
||||||
|
Alert.alert('关联成功', `已成功将「${event.counterparty || event.memo || '交易'}」与历史账单关联(关联标签 ^${linkTag})`);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(String(err));
|
||||||
|
Alert.alert('关联失败', String(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -283,16 +333,11 @@ export default function ImportScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<ScreenHeader title={t('tab.import')} />
|
||||||
<Pressable onPress={() => router.back()}>
|
|
||||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, marginLeft: 8 }]}>{t('tab.import')}</Text>
|
|
||||||
</View>
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={pendingDrafts}
|
data={pendingDrafts}
|
||||||
keyExtractor={(item, index) => `${item.draft.sourceEventId || index}-${index}`}
|
keyExtractor={(item, index) => `${item.draft.sourceEventIds?.[0] || index}-${index}`}
|
||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
<Card title={`${item.draft.date} · ${item.isTransfer ? t('importFlow.transfer') : t('importFlow.trade')} · ${item.draft.postings[0]?.amount ?? ''}`}>
|
<Card title={`${item.draft.date} · ${item.isTransfer ? t('importFlow.transfer') : t('importFlow.trade')} · ${item.draft.postings[0]?.amount ?? ''}`}>
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{item.draft.narration}</Text>
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{item.draft.narration}</Text>
|
||||||
@@ -349,23 +394,45 @@ export default function ImportScreen() {
|
|||||||
{showDuplicates && (
|
{showDuplicates && (
|
||||||
<View style={styles.duplicatesList}>
|
<View style={styles.duplicatesList}>
|
||||||
{manualDuplicates.map((item) => {
|
{manualDuplicates.map((item) => {
|
||||||
|
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === item.id);
|
||||||
|
const matchedTx = detail?.matchedWith?.rawTransaction;
|
||||||
|
const reason = detail?.reason || t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') });
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
isDuplicate: true,
|
isDuplicate: true,
|
||||||
confidence: 'medium' as const,
|
confidence: (detail?.confidence ?? 'medium') as DedupConfidence,
|
||||||
reason: t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') })
|
reason,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={item.id} title={`${item.occurredAt} · ${item.counterparty}`}>
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
title={`${item.occurredAt} · ${item.counterparty || t('importFlow.unknownSource')}`}
|
||||||
|
onPress={() => handleLinkDuplicate(item)}
|
||||||
|
>
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
||||||
{item.memo || t('importFlow.noDesc')}
|
{item.memo || t('importFlow.noDesc')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
|
||||||
{t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })}
|
{t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{matchedTx && (
|
||||||
|
<View style={{ backgroundColor: `${theme.colors.accent}12`, padding: 8, borderRadius: 8, marginVertical: 4 }}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontWeight: '700' }]}>
|
||||||
|
已匹配到的账本历史交易(可关联):
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, marginTop: 2 }]}>
|
||||||
|
{matchedTx.date} {matchedTx.payee ? matchedTx.payee + ' - ' : ''}{matchedTx.narration} ({matchedTx.postings[0]?.amount} {matchedTx.postings[0]?.currency})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<DedupBanner
|
<DedupBanner
|
||||||
result={result}
|
result={result}
|
||||||
onAccept={() => handleAcceptDuplicate(item)}
|
onAccept={() => handleAcceptDuplicate(item)}
|
||||||
onReject={() => handleRejectDuplicate(item)}
|
onReject={() => handleRejectDuplicate(item)}
|
||||||
|
onLink={() => handleLinkDuplicate(item)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -390,10 +457,9 @@ export default function ImportScreen() {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
|
||||||
content: { padding: 16, paddingBottom: 64 },
|
content: { padding: 16, paddingBottom: 64 },
|
||||||
buttonCol: { gap: 8, marginTop: 4 },
|
buttonCol: { gap: 8, marginTop: 4 },
|
||||||
mono: { fontFamily: 'monospace', lineHeight: 20, marginTop: 8 },
|
mono: { fontVariant: ['tabular-nums'], lineHeight: 20, marginTop: 8 },
|
||||||
collapseHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
|
collapseHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
|
||||||
duplicatesList: { gap: 12, marginTop: 8 },
|
duplicatesList: { gap: 12, marginTop: 8 },
|
||||||
});
|
});
|
||||||
|
|||||||
+124
-160
@@ -1,191 +1,155 @@
|
|||||||
import React, { useState } from 'react';
|
/**
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
* 周期性账单管理页面。
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
*
|
||||||
import { useRouter } from 'expo-router';
|
* 功能:周期账单列表(频率/下次日期/资金流向/金额) + 添加/编辑/删除。
|
||||||
|
* P5:套 ManagementScreen 模板;卡片底部保留显式编辑/删除按钮
|
||||||
|
* (调 handlers.openEdit/confirmDelete),不用长按。
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { FormModal, type FormField } from '../../components/FormModal';
|
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||||
import type { RecurringTransaction } from '../../domain/recurring';
|
import { toDateString } from '../../domain/core/decimal';
|
||||||
|
import type { RecurringTransaction } from '../../domain/finance/recurring';
|
||||||
type ModalMode = { type: 'add' } | { type: 'edit'; item: RecurringTransaction } | null;
|
|
||||||
|
|
||||||
export default function RecurringScreen() {
|
export default function RecurringScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
|
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
|
||||||
const addRecurring = useMetadataStore(s => s.addRecurringTransaction);
|
const addRecurring = useMetadataStore(s => s.addRecurringTransaction);
|
||||||
const updateRecurring = useMetadataStore(s => s.updateRecurringTransaction);
|
const updateRecurring = useMetadataStore(s => s.updateRecurringTransaction);
|
||||||
const removeRecurring = useMetadataStore(s => s.removeRecurringTransaction);
|
const removeRecurring = useMetadataStore(s => s.removeRecurringTransaction);
|
||||||
|
|
||||||
const [modal, setModal] = useState<ModalMode>(null);
|
// 用本地时区格式化,避免 toISOString 的 UTC 偏移导致负时区日期错位
|
||||||
|
const today = toDateString(new Date());
|
||||||
const handleDelete = (item: RecurringTransaction) => {
|
|
||||||
Alert.alert(t('recurring.deleteTitle'), t('recurring.deleteConfirm', { name: item.name }), [
|
|
||||||
{ text: t('common.cancel'), style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: t('common.delete'),
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: () => removeRecurring(item.id),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = (values: Record<string, string>) => {
|
|
||||||
const nameVal = values.name?.trim();
|
|
||||||
const fromAccount = values.fromAccount?.trim();
|
|
||||||
const toAccount = values.toAccount?.trim();
|
|
||||||
const amountVal = values.amount?.trim();
|
|
||||||
const freq = (values.frequency?.trim() || 'monthly') as any;
|
|
||||||
const dateVal = values.nextDueDate?.trim() || new Date().toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
if (!nameVal || !fromAccount || !toAccount || !amountVal) {
|
|
||||||
Alert.alert(t('recurring.inputError'), t('recurring.inputErrorDesc'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const draft = {
|
|
||||||
date: dateVal,
|
|
||||||
narration: nameVal,
|
|
||||||
postings: [
|
|
||||||
{ account: fromAccount, amount: `-${amountVal}`, currency: 'CNY' },
|
|
||||||
{ account: toAccount, amount: amountVal, currency: 'CNY' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (modal?.type === 'add') {
|
|
||||||
addRecurring({
|
|
||||||
id: 'rec_' + generateId(),
|
|
||||||
name: nameVal,
|
|
||||||
draft,
|
|
||||||
frequency: freq,
|
|
||||||
interval: 1,
|
|
||||||
nextDueDate: dateVal,
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
setModal(null);
|
|
||||||
Alert.alert(t('recurring.addSuccess'), t('recurring.addSuccessDesc', { name: nameVal }));
|
|
||||||
} else if (modal?.type === 'edit') {
|
|
||||||
updateRecurring(modal.item.id, {
|
|
||||||
name: nameVal,
|
|
||||||
draft,
|
|
||||||
frequency: freq,
|
|
||||||
nextDueDate: dateVal,
|
|
||||||
});
|
|
||||||
setModal(null);
|
|
||||||
Alert.alert(t('recurring.editSuccess'), t('recurring.editSuccessDesc', { name: nameVal }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldsFor = (item?: RecurringTransaction): FormField[] => [
|
|
||||||
{ key: 'name', label: t('recurring.fieldName'), placeholder: t('recurring.fieldNamePlaceholder'), defaultValue: item?.name },
|
|
||||||
{ key: 'fromAccount', label: t('recurring.fieldFromAccount'), placeholder: '例如: Assets:支付宝余额', defaultValue: item?.draft.postings[0]?.account?.replace(/^-/, '') || 'Assets:支付宝余额' },
|
|
||||||
{ key: 'toAccount', label: t('recurring.fieldToAccount'), placeholder: '例如: Expenses:Shopping', defaultValue: item?.draft.postings[1]?.account || 'Expenses:Shopping' },
|
|
||||||
{ key: 'amount', label: t('recurring.fieldAmount'), placeholder: '例如: 6.00', defaultValue: item?.draft.postings[1]?.amount },
|
|
||||||
{ key: 'frequency', label: t('recurring.fieldFrequency'), placeholder: 'monthly', defaultValue: item?.frequency || 'monthly' },
|
|
||||||
{ key: 'nextDueDate', label: t('recurring.fieldNextDue'), placeholder: 'YYYY-MM-DD', defaultValue: item?.nextDueDate || new Date().toISOString().slice(0, 10) },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<ManagementScreen<RecurringTransaction>
|
||||||
<View style={styles.header}>
|
title={t('recurring.title')}
|
||||||
<Pressable onPress={() => router.back()}>
|
items={recurringTransactions}
|
||||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
keyExtractor={item => item.id}
|
||||||
</Pressable>
|
addLabel={t('recurring.add')}
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
emptyText={t('recurring.empty')}
|
||||||
{t('recurring.title')}
|
renderItem={(item, { openEdit, confirmDelete }) => {
|
||||||
</Text>
|
const amount = item.draft.postings[1]?.amount || '0.00';
|
||||||
</View>
|
const fromAcc = item.draft.postings[0]?.account || '';
|
||||||
|
const toAcc = item.draft.postings[1]?.account || '';
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
return (
|
||||||
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
|
<Card title={item.name}>
|
||||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
<View style={styles.infoRow}>
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text>
|
||||||
{t('recurring.add')}
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
||||||
</Text>
|
{item.frequency === 'monthly' ? t('recurring.freqMonthly') : item.frequency === 'weekly' ? t('recurring.freqWeekly') : item.frequency === 'yearly' ? t('recurring.freqYearly') : t('recurring.freqDaily')}
|
||||||
</Pressable>
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.nextDue')}</Text>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{item.nextDueDate}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.flow')}</Text>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
||||||
|
{fromAcc.split(':').pop()} ➔ {toAcc.split(':').pop()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.perAmount')}</Text>
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.financial.expense, fontWeight: '700' }]}>{amount} CNY</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
{recurringTransactions.map(item => {
|
{/* 显式编辑/删除按钮(本页不用长按手势,交互更显式) */}
|
||||||
const amount = item.draft.postings[1]?.amount || '0.00';
|
<View style={styles.cardActions}>
|
||||||
const fromAcc = item.draft.postings[0]?.account || '';
|
<Pressable
|
||||||
const toAcc = item.draft.postings[1]?.account || '';
|
onPress={openEdit}
|
||||||
|
style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]}
|
||||||
return (
|
>
|
||||||
<Card key={item.id} title={item.name}>
|
<Ionicons name="create-outline" size={14} color={theme.colors.accent} />
|
||||||
<View style={styles.infoRow}>
|
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text>
|
{t('recurring.edit')}
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
|
||||||
{item.frequency === 'monthly' ? t('recurring.freqMonthly') : item.frequency === 'weekly' ? t('recurring.freqWeekly') : item.frequency === 'yearly' ? t('recurring.freqYearly') : t('recurring.freqDaily')}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</Pressable>
|
||||||
<View style={styles.infoRow}>
|
<Pressable
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.nextDue')}</Text>
|
onPress={confirmDelete}
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{item.nextDueDate}</Text>
|
style={[styles.actionBtn, { borderColor: theme.colors.border }]}
|
||||||
</View>
|
>
|
||||||
<View style={styles.infoRow}>
|
<Ionicons name="trash-outline" size={14} color={theme.colors.error} />
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.flow')}</Text>
|
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
{t('recurring.delete')}
|
||||||
{fromAcc.split(':').pop()} ➔ {toAcc.split(':').pop()}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</Pressable>
|
||||||
<View style={styles.infoRow}>
|
</View>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.perAmount')}</Text>
|
</Card>
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.financial.expense, fontWeight: '700' }]}>{amount} CNY</Text>
|
);
|
||||||
</View>
|
}}
|
||||||
|
formTitle={editing => (editing ? t('recurring.editTitle') : t('recurring.addTitle'))}
|
||||||
|
formFields={editing => [
|
||||||
|
{ key: 'name', label: t('recurring.fieldName'), placeholder: t('recurring.fieldNamePlaceholder'), defaultValue: editing?.name },
|
||||||
|
{ key: 'fromAccount', label: t('recurring.fieldFromAccount'), placeholder: '例如: Assets:支付宝余额', defaultValue: editing?.draft.postings[0]?.account?.replace(/^-/, '') || 'Assets:支付宝余额' },
|
||||||
|
{ key: 'toAccount', label: t('recurring.fieldToAccount'), placeholder: '例如: Expenses:Shopping', defaultValue: editing?.draft.postings[1]?.account || 'Expenses:Shopping' },
|
||||||
|
{ key: 'amount', label: t('recurring.fieldAmount'), placeholder: '例如: 6.00', defaultValue: editing?.draft.postings[1]?.amount },
|
||||||
|
{ key: 'frequency', label: t('recurring.fieldFrequency'), placeholder: 'monthly', defaultValue: editing?.frequency || 'monthly' },
|
||||||
|
{ key: 'nextDueDate', label: t('recurring.fieldNextDue'), placeholder: 'YYYY-MM-DD', defaultValue: editing?.nextDueDate || today },
|
||||||
|
]}
|
||||||
|
onSubmit={(values, editing) => {
|
||||||
|
const nameVal = values.name?.trim();
|
||||||
|
const fromAccount = values.fromAccount?.trim();
|
||||||
|
const toAccount = values.toAccount?.trim();
|
||||||
|
const amountVal = values.amount?.trim();
|
||||||
|
const freq = (values.frequency?.trim() || 'monthly') as RecurringTransaction['frequency'];
|
||||||
|
const dateVal = values.nextDueDate?.trim() || today;
|
||||||
|
|
||||||
<View style={styles.cardActions}>
|
if (!nameVal || !fromAccount || !toAccount || !amountVal) {
|
||||||
<Pressable
|
Alert.alert(t('recurring.inputError'), t('recurring.inputErrorDesc'));
|
||||||
onPress={() => setModal({ type: 'edit', item })}
|
return false;
|
||||||
style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]}
|
}
|
||||||
>
|
|
||||||
<Ionicons name="create-outline" size={14} color={theme.colors.accent} />
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
|
|
||||||
{t('recurring.edit')}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => handleDelete(item)}
|
|
||||||
style={[styles.closeBtn, { borderColor: theme.colors.border }]}
|
|
||||||
>
|
|
||||||
<Ionicons name="trash-outline" size={14} color={theme.colors.error} />
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
|
|
||||||
{t('recurring.delete')}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{recurringTransactions.length === 0 && (
|
const draft = {
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}>
|
date: dateVal,
|
||||||
{t('recurring.empty')}
|
narration: nameVal,
|
||||||
</Text>
|
postings: [
|
||||||
)}
|
{ account: fromAccount, amount: `-${amountVal}`, currency: 'CNY' },
|
||||||
</ScrollView>
|
{ account: toAccount, amount: amountVal, currency: 'CNY' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
<FormModal
|
if (editing) {
|
||||||
visible={modal !== null}
|
updateRecurring(editing.id, {
|
||||||
title={modal?.type === 'edit' ? t('recurring.editTitle') : t('recurring.addTitle')}
|
name: nameVal,
|
||||||
fields={fieldsFor(modal?.type === 'edit' ? modal.item : undefined)}
|
draft,
|
||||||
onConfirm={handleConfirm}
|
frequency: freq,
|
||||||
onCancel={() => setModal(null)}
|
nextDueDate: dateVal,
|
||||||
/>
|
});
|
||||||
</SafeAreaView>
|
Alert.alert(t('recurring.editSuccess'), t('recurring.editSuccessDesc', { name: nameVal }));
|
||||||
|
} else {
|
||||||
|
addRecurring({
|
||||||
|
id: 'rec_' + generateId(),
|
||||||
|
name: nameVal,
|
||||||
|
draft,
|
||||||
|
frequency: freq,
|
||||||
|
interval: 1,
|
||||||
|
nextDueDate: dateVal,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
Alert.alert(t('recurring.addSuccess'), t('recurring.addSuccessDesc', { name: nameVal }));
|
||||||
|
}
|
||||||
|
// 弹窗关闭由模板接管(返回 true)
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
onDelete={item => removeRecurring(item.id)}
|
||||||
|
deleteConfirmText={item => t('recurring.deleteConfirm', { name: item.name })}
|
||||||
|
deleteConfirmTitle={t('recurring.deleteTitle')}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
|
||||||
content: { padding: 16, gap: 12 },
|
|
||||||
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
|
|
||||||
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' },
|
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' },
|
||||||
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
|
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
|
||||||
actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
|
actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
|
||||||
closeBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,106 +1,70 @@
|
|||||||
/**
|
/**
|
||||||
* 备注模板管理页面。
|
* 备注模板管理页面。
|
||||||
* 模板用 ${placeholder} 语法,导入账单时自动填充。
|
* 模板用 ${placeholder} 语法,导入账单时自动填充。
|
||||||
|
* P5:套 ManagementScreen 模板,消除手写头部/新增按钮/弹窗样板。
|
||||||
*/
|
*/
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { Pressable, Text } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { FormModal, type FormField } from '../../components/FormModal';
|
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||||
|
|
||||||
type ModalMode = { type: 'add' } | { type: 'edit'; tpl: { id: string; name: string; template: string } } | null;
|
interface RemarkTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
template: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RemarkTemplateScreen() {
|
export default function RemarkTemplateScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const templates = useMetadataStore(s => s.remarkTemplates);
|
const templates = useMetadataStore(s => s.remarkTemplates);
|
||||||
const addTemplate = useMetadataStore(s => s.addRemarkTemplate);
|
const addTemplate = useMetadataStore(s => s.addRemarkTemplate);
|
||||||
const updateTemplate = useMetadataStore(s => s.updateRemarkTemplate);
|
const updateTemplate = useMetadataStore(s => s.updateRemarkTemplate);
|
||||||
const removeTemplate = useMetadataStore(s => s.removeRemarkTemplate);
|
const removeTemplate = useMetadataStore(s => s.removeRemarkTemplate);
|
||||||
|
|
||||||
const [modal, setModal] = useState<ModalMode>(null);
|
|
||||||
|
|
||||||
const handleDelete = (tpl: { id: string; name: string }) => {
|
|
||||||
Alert.alert(t('remark.deleteTitle'), t('remark.deleteConfirm', { name: tpl.name }), [
|
|
||||||
{ text: t('common.cancel'), style: 'cancel' },
|
|
||||||
{ text: t('common.delete'), style: 'destructive', onPress: () => removeTemplate(tpl.id) },
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = (values: Record<string, string>) => {
|
|
||||||
const name = values.name?.trim() || t('common.untitled');
|
|
||||||
const template = values.template?.trim() || '';
|
|
||||||
if (modal?.type === 'add') {
|
|
||||||
addTemplate({ id: generateId('tpl'), name, template });
|
|
||||||
} else if (modal?.type === 'edit') {
|
|
||||||
updateTemplate(modal.tpl.id, { name, template });
|
|
||||||
}
|
|
||||||
setModal(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const editFields: FormField[] = modal?.type === 'edit' ? [
|
|
||||||
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮', defaultValue: modal.tpl.name },
|
|
||||||
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}', defaultValue: modal.tpl.template },
|
|
||||||
] : [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<ManagementScreen<RemarkTemplate>
|
||||||
<View style={styles.header}>
|
title={t('remark.title')}
|
||||||
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
|
items={templates}
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('remark.title')}</Text>
|
keyExtractor={tpl => tpl.id}
|
||||||
</View>
|
addLabel={t('remark.add')}
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
emptyText={t('remark.empty')}
|
||||||
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
|
renderItem={(tpl, { openEdit, confirmDelete }) => (
|
||||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
<Pressable onPress={openEdit} onLongPress={confirmDelete}>
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('remark.add')}</Text>
|
<Card title={tpl.name}>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
|
||||||
|
{tpl.template}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{templates.map(tpl => (
|
)}
|
||||||
<Pressable
|
formTitle={editing => (editing ? t('remark.editTitle') : t('remark.add'))}
|
||||||
key={tpl.id}
|
formFields={editing => [
|
||||||
onPress={() => setModal({ type: 'edit', tpl })}
|
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮', defaultValue: editing?.name },
|
||||||
onLongPress={() => handleDelete(tpl)}
|
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}', defaultValue: editing?.template },
|
||||||
>
|
]}
|
||||||
<Card title={tpl.name}>
|
onSubmit={(values, editing) => {
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]}>
|
const name = values.name?.trim() || t('common.untitled');
|
||||||
{tpl.template}
|
const template = values.template?.trim() || '';
|
||||||
</Text>
|
if (editing) {
|
||||||
</Card>
|
updateTemplate(editing.id, { name, template });
|
||||||
</Pressable>
|
} else {
|
||||||
))}
|
addTemplate({ id: generateId('tpl'), name, template });
|
||||||
{templates.length === 0 && (
|
}
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
|
return true;
|
||||||
{t('remark.empty')}
|
}}
|
||||||
</Text>
|
onDelete={tpl => removeTemplate(tpl.id)}
|
||||||
)}
|
deleteConfirmText={tpl => t('remark.deleteConfirm', { name: tpl.name })}
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
deleteConfirmTitle={t('remark.deleteTitle')}
|
||||||
|
footer={
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
||||||
{t('common.clickEditLongDelete')}
|
{t('common.clickEditLongDelete')}
|
||||||
</Text>
|
</Text>
|
||||||
</ScrollView>
|
}
|
||||||
|
/>
|
||||||
<FormModal
|
|
||||||
visible={modal !== null}
|
|
||||||
title={modal?.type === 'edit' ? t('remark.editTitle') : t('remark.add')}
|
|
||||||
fields={modal?.type === 'edit' ? editFields : [
|
|
||||||
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮' },
|
|
||||||
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}' },
|
|
||||||
]}
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={() => setModal(null)}
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
page: { flex: 1 },
|
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
|
||||||
content: { padding: 16, gap: 12 },
|
|
||||||
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 4 },
|
|
||||||
});
|
|
||||||
|
|||||||
+67
-105
@@ -3,71 +3,27 @@
|
|||||||
*
|
*
|
||||||
* 功能:规则列表(匹配条件 → 分类账户) + 添加/编辑/删除。
|
* 功能:规则列表(匹配条件 → 分类账户) + 添加/编辑/删除。
|
||||||
* 规则用于 BillPipeline 的自动分类(参考 AutoAccounting RuleGenerator)。
|
* 规则用于 BillPipeline 的自动分类(参考 AutoAccounting RuleGenerator)。
|
||||||
|
* P5:套 ManagementScreen 模板,消除手写头部/新增按钮/弹窗样板。
|
||||||
*/
|
*/
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { FormModal, type FormField } from '../../components/FormModal';
|
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||||
import type { Rule } from '../../domain/types';
|
import type { Rule } from '../../domain/core/types';
|
||||||
|
|
||||||
type ModalMode = { type: 'add' } | { type: 'edit'; rule: Rule } | null;
|
|
||||||
|
|
||||||
export default function RulesScreen() {
|
export default function RulesScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const rules = useMetadataStore(s => s.rules);
|
const rules = useMetadataStore(s => s.rules);
|
||||||
const addRule = useMetadataStore(s => s.addRule);
|
const addRule = useMetadataStore(s => s.addRule);
|
||||||
const updateRule = useMetadataStore(s => s.updateRule);
|
const updateRule = useMetadataStore(s => s.updateRule);
|
||||||
const removeRule = useMetadataStore(s => s.removeRule);
|
const removeRule = useMetadataStore(s => s.removeRule);
|
||||||
|
|
||||||
const [modal, setModal] = useState<ModalMode>(null);
|
|
||||||
|
|
||||||
// 按 priority 降序排列
|
|
||||||
const sorted = [...rules].sort((a, b) => b.priority - a.priority);
|
|
||||||
|
|
||||||
const handleDelete = (rule: Rule) => {
|
|
||||||
Alert.alert(t('rules.deleteTitle'), t('rules.deleteConfirm', { name: rule.id }), [
|
|
||||||
{ text: t('common.cancel'), style: 'cancel' },
|
|
||||||
{ text: t('common.delete'), style: 'destructive', onPress: () => removeRule(rule.id) },
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = (values: Record<string, string>) => {
|
|
||||||
const rule: Omit<Rule, 'id' | 'hits'> = {
|
|
||||||
priority: parseInt(values.priority, 10) || 0,
|
|
||||||
counterpartyContains: values.counterpartyContains?.trim() || undefined,
|
|
||||||
memoContains: values.memoContains?.trim() || undefined,
|
|
||||||
sourceAccount: values.sourceAccount?.trim() || 'Assets:Unknown',
|
|
||||||
categoryAccount: values.categoryAccount?.trim() || 'Expenses:Uncategorized',
|
|
||||||
narration: values.narration?.trim() || undefined,
|
|
||||||
tags: values.tags ? values.tags.split(/[,,\s]+/).filter(Boolean) : [],
|
|
||||||
};
|
|
||||||
if (modal?.type === 'add') {
|
|
||||||
addRule({ ...rule, id: generateId('rule'), hits: 0 });
|
|
||||||
} else if (modal?.type === 'edit') {
|
|
||||||
updateRule(modal.rule.id, rule);
|
|
||||||
}
|
|
||||||
setModal(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldsFor = (rule?: Rule): FormField[] => [
|
|
||||||
{ key: 'priority', label: t('rules.fieldPriority'), placeholder: '100', defaultValue: rule ? String(rule.priority) : '', keyboardType: 'numeric' },
|
|
||||||
{ key: 'counterpartyContains', label: t('rules.fieldCounterparty'), placeholder: '咖啡', defaultValue: rule?.counterpartyContains ?? '' },
|
|
||||||
{ key: 'memoContains', label: t('rules.fieldMemo'), placeholder: '', defaultValue: rule?.memoContains ?? '' },
|
|
||||||
{ key: 'sourceAccount', label: t('rules.fieldSourceAccount'), placeholder: 'Assets:支付宝余额', defaultValue: rule?.sourceAccount ?? '' },
|
|
||||||
{ key: 'categoryAccount', label: t('rules.fieldCategoryAccount'), placeholder: 'Expenses:餐饮', defaultValue: rule?.categoryAccount ?? '' },
|
|
||||||
{ key: 'narration', label: t('rules.fieldNarration'), placeholder: '咖啡', defaultValue: rule?.narration ?? '' },
|
|
||||||
{ key: 'tags', label: t('rules.fieldTags'), placeholder: 'food', defaultValue: rule?.tags?.join(', ') ?? '' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const formatCondition = (rule: Rule): string => {
|
const formatCondition = (rule: Rule): string => {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (rule.counterpartyContains) parts.push(t('rules.condCounterparty', { val: rule.counterpartyContains }));
|
if (rule.counterpartyContains) parts.push(t('rules.condCounterparty', { val: rule.counterpartyContains }));
|
||||||
@@ -76,68 +32,74 @@ export default function RulesScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<ManagementScreen<Rule>
|
||||||
<View style={styles.header}>
|
title={t('tab.rules')}
|
||||||
<Pressable onPress={() => router.back()}>
|
items={[...rules].sort((a, b) => b.priority - a.priority)}
|
||||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
keyExtractor={rule => rule.id}
|
||||||
</Pressable>
|
addLabel={t('rules.add')}
|
||||||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, marginLeft: 8 }]}>{t('tab.rules')}</Text>
|
emptyText={t('rules.empty')}
|
||||||
</View>
|
renderItem={(rule, { openEdit, confirmDelete }) => (
|
||||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
<Card>
|
||||||
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
|
<Pressable
|
||||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
onPress={openEdit}
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('rules.add')}</Text>
|
onLongPress={confirmDelete}
|
||||||
</Pressable>
|
style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}
|
||||||
<Card title={t('rules.title')}>
|
>
|
||||||
{sorted.length === 0 && (
|
<View style={styles.ruleHeader}>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('rules.empty')}</Text>
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}>{rule.narration || rule.id}</Text>
|
||||||
)}
|
<Text style={[theme.typography.caption, { color: theme.colors.accent }]}>P{rule.priority}</Text>
|
||||||
{sorted.map(rule => (
|
</View>
|
||||||
<Pressable
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
key={rule.id}
|
{formatCondition(rule)}
|
||||||
onPress={() => setModal({ type: 'edit', rule })}
|
</Text>
|
||||||
onLongPress={() => handleDelete(rule)}
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}>
|
||||||
style={({ pressed }) => [styles.row, { borderTopColor: theme.colors.divider, opacity: pressed ? 0.6 : 1 }]}
|
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
|
||||||
>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
<View style={{ flex: 1 }}>
|
{rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
|
||||||
<View style={styles.ruleHeader}>
|
</Text>
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}>{rule.narration || rule.id}</Text>
|
</View>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.accent }]}>P{rule.priority}</Text>
|
</Pressable>
|
||||||
</View>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
|
||||||
{formatCondition(rule)}
|
|
||||||
</Text>
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}>
|
|
||||||
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
formTitle={editing => (editing ? t('rules.editTitle') : t('rules.add'))}
|
||||||
|
formFields={editing => [
|
||||||
|
{ key: 'priority', label: t('rules.fieldPriority'), placeholder: '100', defaultValue: editing ? String(editing.priority) : '', keyboardType: 'numeric' },
|
||||||
|
{ key: 'counterpartyContains', label: t('rules.fieldCounterparty'), placeholder: '咖啡', defaultValue: editing?.counterpartyContains ?? '' },
|
||||||
|
{ key: 'memoContains', label: t('rules.fieldMemo'), placeholder: '', defaultValue: editing?.memoContains ?? '' },
|
||||||
|
{ key: 'sourceAccount', label: t('rules.fieldSourceAccount'), placeholder: 'Assets:支付宝余额', defaultValue: editing?.sourceAccount ?? '' },
|
||||||
|
{ key: 'categoryAccount', label: t('rules.fieldCategoryAccount'), placeholder: 'Expenses:餐饮', defaultValue: editing?.categoryAccount ?? '' },
|
||||||
|
{ key: 'narration', label: t('rules.fieldNarration'), placeholder: '咖啡', defaultValue: editing?.narration ?? '' },
|
||||||
|
{ key: 'tags', label: t('rules.fieldTags'), placeholder: 'food', defaultValue: editing?.tags?.join(', ') ?? '' },
|
||||||
|
]}
|
||||||
|
onSubmit={(values, editing) => {
|
||||||
|
const rule: Omit<Rule, 'id' | 'hits'> = {
|
||||||
|
priority: parseInt(values.priority, 10) || 0,
|
||||||
|
counterpartyContains: values.counterpartyContains?.trim() || undefined,
|
||||||
|
memoContains: values.memoContains?.trim() || undefined,
|
||||||
|
sourceAccount: values.sourceAccount?.trim() || 'Assets:Unknown',
|
||||||
|
categoryAccount: values.categoryAccount?.trim() || 'Expenses:未分类',
|
||||||
|
narration: values.narration?.trim() || undefined,
|
||||||
|
tags: values.tags ? values.tags.split(/[,,\s]+/).filter(Boolean) : [],
|
||||||
|
};
|
||||||
|
if (editing) {
|
||||||
|
updateRule(editing.id, rule);
|
||||||
|
} else {
|
||||||
|
addRule({ ...rule, id: generateId('rule'), hits: 0 });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
onDelete={rule => removeRule(rule.id)}
|
||||||
|
deleteConfirmText={rule => t('rules.deleteConfirm', { name: rule.id })}
|
||||||
|
deleteConfirmTitle={t('rules.deleteTitle')}
|
||||||
|
footer={
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
||||||
{t('common.clickEditLongDelete')}
|
{t('common.clickEditLongDelete')}
|
||||||
</Text>
|
</Text>
|
||||||
</ScrollView>
|
}
|
||||||
|
/>
|
||||||
<FormModal
|
|
||||||
visible={modal !== null}
|
|
||||||
title={modal?.type === 'edit' ? t('rules.editTitle') : t('rules.add')}
|
|
||||||
fields={fieldsFor(modal?.type === 'edit' ? modal.rule : undefined)}
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={() => setModal(null)}
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
|
||||||
content: { padding: 16 },
|
|
||||||
addBtn: { flexDirection: 'row', alignItems: 'center' },
|
|
||||||
row: { borderTopWidth: 1, paddingTop: 10, gap: 3 },
|
|
||||||
ruleHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
ruleHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||||
});
|
});
|
||||||
|
|||||||
+122
-120
@@ -1,156 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* LLM / AI 视觉配置页(P8)。
|
||||||
|
*
|
||||||
|
* 配置项:
|
||||||
|
* - AI 服务商(openai / gemini / deepseek)
|
||||||
|
* - API Key
|
||||||
|
* - Base URL
|
||||||
|
* - 模型名称
|
||||||
|
* - AI 总开关
|
||||||
|
*/
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View, Switch, Alert } from 'react-native';
|
import { KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, Switch } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
|
import { createCommonStyles } from '../../theme/commonStyles';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { useSettingsStore } from '../../store/settingsStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { Button } from '../../components/Button';
|
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||||
import { FormModal, type FormField } from '../../components/FormModal';
|
|
||||||
|
|
||||||
export default function AISettingsScreen() {
|
const PROVIDERS: { key: string; label: string }[] = [
|
||||||
|
{ key: 'openai', label: 'OpenAI' },
|
||||||
|
{ key: 'gemini', label: 'Gemini' },
|
||||||
|
{ key: 'deepseek', label: 'DeepSeek' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROVIDER_DEFAULT_URLS: Record<string, string> = {
|
||||||
|
openai: 'https://api.openai.com/v1',
|
||||||
|
gemini: 'https://generativelanguage.googleapis.com/v1beta',
|
||||||
|
deepseek: 'https://api.deepseek.com/v1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER_DEFAULT_MODELS: Record<string, string> = {
|
||||||
|
openai: 'gpt-4o-mini',
|
||||||
|
gemini: 'gemini-2.0-flash',
|
||||||
|
deepseek: 'deepseek-chat',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AiSettingsScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const commonStyles = createCommonStyles(theme);
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Settings State
|
|
||||||
const dedupEnabled = useSettingsStore(s => s.dedupEnabled);
|
|
||||||
const setDedupEnabled = useSettingsStore(s => s.setDedupEnabled);
|
|
||||||
const transferRecognitionEnabled = useSettingsStore(s => s.transferRecognitionEnabled);
|
|
||||||
const setTransferRecognitionEnabled = useSettingsStore(s => s.setTransferRecognitionEnabled);
|
|
||||||
|
|
||||||
const aiEnabled = useSettingsStore(s => s.aiEnabled);
|
const aiEnabled = useSettingsStore(s => s.aiEnabled);
|
||||||
const aiProviderId = useSettingsStore(s => s.aiProviderId);
|
const aiProviderId = useSettingsStore(s => s.aiProviderId);
|
||||||
const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
|
const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
|
||||||
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || 'https://api.openai.com/v1';
|
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || '';
|
||||||
const aiModel = useSettingsStore(s => s.aiModel) || 'gpt-4o-mini';
|
const aiModel = useSettingsStore(s => s.aiModel) || '';
|
||||||
const updateAiConfig = useSettingsStore(s => s.updateAiConfig);
|
const updateAiConfig = useSettingsStore(s => s.updateAiConfig);
|
||||||
|
|
||||||
const [aiModalVisible, setAiModalVisible] = useState(false);
|
const [localKey, setLocalKey] = useState(aiApiKey);
|
||||||
|
const [localUrl, setLocalUrl] = useState(aiBaseUrl);
|
||||||
|
const [localModel, setLocalModel] = useState(aiModel);
|
||||||
|
|
||||||
const aiFields: FormField[] = [
|
const inputStyle = {
|
||||||
{ key: 'apiKey', label: t('settings.aiFieldApiKey'), placeholder: 'sk-xxxxxx', defaultValue: aiApiKey },
|
backgroundColor: theme.colors.bgTertiary,
|
||||||
{ key: 'baseUrl', label: t('settings.aiFieldBaseUrl'), placeholder: 'https://api.openai.com/v1', defaultValue: aiBaseUrl },
|
color: theme.colors.fgPrimary,
|
||||||
{ key: 'model', label: t('settings.aiFieldModel'), placeholder: 'gpt-4o-mini', defaultValue: aiModel },
|
borderRadius: theme.radii.sm,
|
||||||
];
|
padding: 12,
|
||||||
|
fontSize: 14,
|
||||||
const saveAI = (values: Record<string, string>) => {
|
marginTop: 6,
|
||||||
updateAiConfig({
|
|
||||||
aiApiKey: values.apiKey,
|
|
||||||
aiBaseUrl: values.baseUrl,
|
|
||||||
aiModel: values.model,
|
|
||||||
});
|
|
||||||
setAiModalVisible(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<ScreenHeader title={t('settings.llmTitle')} />
|
||||||
<Pressable onPress={() => router.back()}>
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
|
||||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]} keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive">
|
||||||
</Pressable>
|
{/* AI 总开关 */}
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
<Card>
|
||||||
{t('settings.aiSettingsTitle')}
|
<View style={styles.row}>
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
|
||||||
{/* 自动记账与算法开关 */}
|
|
||||||
<Card title={t('settings.algorithmConfig')}>
|
|
||||||
<View style={styles.switchRow}>
|
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
|
||||||
{t('settings.dedupLabel')}
|
|
||||||
</Text>
|
|
||||||
<Switch
|
|
||||||
value={dedupEnabled}
|
|
||||||
onValueChange={setDedupEnabled}
|
|
||||||
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
|
||||||
thumbColor={theme.colors.fgInverse}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={[styles.switchRow, { marginTop: 12 }]}>
|
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
|
||||||
{t('settings.transferLabel')}
|
|
||||||
</Text>
|
|
||||||
<Switch
|
|
||||||
value={transferRecognitionEnabled}
|
|
||||||
onValueChange={setTransferRecognitionEnabled}
|
|
||||||
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
|
||||||
thumbColor={theme.colors.fgInverse}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* AI 智能助理配置 */}
|
|
||||||
<Card title={t('settings.aiAssistantTitle')}>
|
|
||||||
<View style={styles.switchRow}>
|
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
||||||
{t('settings.enableAiHint')}
|
{t('settings.enableAiHint')}
|
||||||
</Text>
|
</Text>
|
||||||
<Switch
|
<Switch
|
||||||
value={aiEnabled}
|
value={aiEnabled}
|
||||||
onValueChange={(val) => updateAiConfig({ aiEnabled: val })}
|
onValueChange={val => updateAiConfig({ aiEnabled: val })}
|
||||||
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
||||||
thumbColor={theme.colors.fgInverse}
|
thumbColor={theme.colors.fgInverse}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{aiEnabled && (
|
</Card>
|
||||||
<View style={{ marginTop: 12, gap: 8 }}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
{/* 服务商选择 */}
|
||||||
{t('settings.aiProvider')}
|
<Card title={t('automation.aiProviderLabel')}>
|
||||||
</Text>
|
<View style={styles.chipRow}>
|
||||||
<View style={styles.optionRow}>
|
{PROVIDERS.map(p => (
|
||||||
{['openai', 'gemini', 'deepseek'].map((provider) => {
|
<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>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
{/* AI 配置模态框 */}
|
|
||||||
<FormModal
|
|
||||||
visible={aiModalVisible}
|
|
||||||
title={t('settings.aiConfigTitle')}
|
|
||||||
fields={aiFields}
|
|
||||||
onConfirm={saveAI}
|
|
||||||
onCancel={() => setAiModalVisible(false)}
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
content: { padding: 16, paddingBottom: 64 },
|
||||||
content: { padding: 16, gap: 16 },
|
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
chipRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' },
|
||||||
optionRow: { flexDirection: 'row', gap: 8 },
|
|
||||||
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/** 解析诊断(自交易页迁入,spec §7.2/§7.4 数据组)。 */
|
||||||
|
import React from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
|
import { useTheme } from '../../theme';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
import { Card } from '../../components/ui/Card';
|
||||||
|
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||||
|
|
||||||
|
export default function DiagnosticsScreen() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const t = useT();
|
||||||
|
const ledger = useLedgerStore(s => s.ledger);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
|
||||||
|
<ScreenHeader title={t('settings.diagnostics')} />
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
<Card title={t('diagnostics.title')}>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
||||||
|
{t('diagnostics.syntaxErrors', { count: ledger?.diagnostics.length ?? 0 })}
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
||||||
|
{t('diagnostics.unsupported', { count: ledger?.unsupported.length ?? 0 })}
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
|
||||||
|
{t('diagnostics.balanceAssertions', { count: ledger?.balances.length ?? 0 })}
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
|
||||||
|
{t('diagnostics.hint')}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: { flex: 1 },
|
||||||
|
content: { padding: 16 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
/**
|
||||||
|
* 应用日志中心(我的 -> 数据 -> 应用日志)。
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 实时内存日志 + 磁盘历史日志文件查看
|
||||||
|
* 2. 4 级日志(DEBUG/INFO/WARN/ERROR)与关键词/Tag 搜索过滤
|
||||||
|
* 3. 展开查看结构化 JSON data 载荷与 Stack Trace
|
||||||
|
* 4. 一键导出日志(调用原生分享/文件导出)与清空日志
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AppState,
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Share,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
|
import * as Sharing from 'expo-sharing';
|
||||||
|
|
||||||
|
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
import { useTheme } from '../../theme';
|
||||||
|
import { LogEntry, LogFileInfo, LogLevel, LEVEL_LABELS, logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
export default function LogsScreen() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
const [selectedFileDate, setSelectedFileDate] = useState<string | null>(null); // null 表示“今天(实时)”
|
||||||
|
const [logFiles, setLogFiles] = useState<LogFileInfo[]>([]);
|
||||||
|
const [fileLogs, setFileLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [liveLogs, setLiveLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [levelFilter, setLevelFilter] = useState<LogLevel | 'ALL'>('ALL');
|
||||||
|
const [dateModalOpen, setDateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedFile = useMemo(() => {
|
||||||
|
return logFiles.find(f => f.date === selectedFileDate);
|
||||||
|
}, [logFiles, selectedFileDate]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 刷新日志文件列表
|
||||||
|
const refreshFileList = useCallback(async () => {
|
||||||
|
const files = await logger.getLogFiles();
|
||||||
|
setLogFiles(files);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 刷新实时内存日志与历史日志
|
||||||
|
const refreshLogs = useCallback(async () => {
|
||||||
|
// 强制刷新日志写入队列
|
||||||
|
await logger.flushQueue();
|
||||||
|
await refreshFileList();
|
||||||
|
|
||||||
|
if (selectedFileDate === null) {
|
||||||
|
const todayLogs = await logger.getTodayLogs();
|
||||||
|
setLiveLogs(todayLogs);
|
||||||
|
} else {
|
||||||
|
const loaded = await logger.loadLogsFromFile(selectedFileDate);
|
||||||
|
setFileLogs(loaded);
|
||||||
|
}
|
||||||
|
}, [selectedFileDate, refreshFileList]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshLogs();
|
||||||
|
}, [refreshLogs]);
|
||||||
|
|
||||||
|
// 自动刷新:「今天(实时)」视图每 3 秒轮询;切回前台时立即刷新
|
||||||
|
const refreshRef = useRef(refreshLogs);
|
||||||
|
refreshRef.current = refreshLogs;
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFileDate !== null) return; // 历史文件不轮询
|
||||||
|
const timer = setInterval(() => { void refreshRef.current(); }, 3000);
|
||||||
|
const sub = AppState.addEventListener('change', (state) => {
|
||||||
|
if (state === 'active') void refreshRef.current();
|
||||||
|
});
|
||||||
|
return () => { clearInterval(timer); sub.remove(); };
|
||||||
|
}, [selectedFileDate]);
|
||||||
|
|
||||||
|
// 当前激活显示的原始日志列表
|
||||||
|
const rawLogs = selectedFileDate === null ? liveLogs : fileLogs;
|
||||||
|
|
||||||
|
// 过滤后的日志列表(按时间倒序排列:最新的日志在前面)
|
||||||
|
const filteredLogs = useMemo(() => {
|
||||||
|
const list = rawLogs.filter(e => {
|
||||||
|
// 级别过滤
|
||||||
|
if (levelFilter !== 'ALL' && e.level !== levelFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 搜索过滤
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
const msgMatch = e.message.toLowerCase().includes(q);
|
||||||
|
const tagMatch = e.tag.toLowerCase().includes(q);
|
||||||
|
const dataMatch = e.data ? JSON.stringify(e.data).toLowerCase().includes(q) : false;
|
||||||
|
if (!msgMatch && !tagMatch && !dataMatch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
// 倒序排列(最新在前)
|
||||||
|
return list.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||||
|
}, [rawLogs, levelFilter, searchQuery]);
|
||||||
|
|
||||||
|
// 统计各级别数量
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const res = { ALL: rawLogs.length, [LogLevel.DEBUG]: 0, [LogLevel.INFO]: 0, [LogLevel.WARN]: 0, [LogLevel.ERROR]: 0 };
|
||||||
|
for (const item of rawLogs) {
|
||||||
|
if (res[item.level] !== undefined) {
|
||||||
|
res[item.level]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}, [rawLogs]);
|
||||||
|
|
||||||
|
// 导出日志文件/文本
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const exportText = logger.exportAsFormattedText(filteredLogs);
|
||||||
|
if (!exportText || filteredLogs.length === 0) {
|
||||||
|
Alert.alert(t('common.error'), t('logs.empty'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await Sharing.isAvailableAsync()) {
|
||||||
|
const fileDateStr = selectedFileDate ?? new Date().toISOString().slice(0, 10);
|
||||||
|
const tempPath = `${FileSystem.cacheDirectory}app-log-${fileDateStr}-${Date.now()}.txt`;
|
||||||
|
await FileSystem.writeAsStringAsync(tempPath, exportText);
|
||||||
|
await Sharing.shareAsync(tempPath, {
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
dialogTitle: t('logs.export'),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await Share.share({
|
||||||
|
message: exportText,
|
||||||
|
title: t('logs.title'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert(t('error.exportFail'), e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清空所有日志
|
||||||
|
const handleClear = () => {
|
||||||
|
Alert.alert(
|
||||||
|
t('logs.clearConfirmTitle'),
|
||||||
|
t('logs.clearConfirmDesc'),
|
||||||
|
[
|
||||||
|
{ text: t('common.cancel'), style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: t('logs.clear'),
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await logger.clearAllLogs();
|
||||||
|
setSelectedFileDate(null);
|
||||||
|
await refreshLogs();
|
||||||
|
Alert.alert(t('common.confirm'), t('logs.clearSuccess'));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取级别对应的色彩
|
||||||
|
const getLevelStyle = (level: LogLevel) => {
|
||||||
|
switch (level) {
|
||||||
|
case LogLevel.DEBUG:
|
||||||
|
return { bg: theme.colors.bgTertiary, fg: theme.colors.fgSecondary };
|
||||||
|
case LogLevel.INFO:
|
||||||
|
return { bg: theme.colors.accent + '22', fg: theme.colors.accent };
|
||||||
|
case LogLevel.WARN:
|
||||||
|
return { bg: '#E6A23C22', fg: '#E6A23C' };
|
||||||
|
case LogLevel.ERROR:
|
||||||
|
return { bg: theme.colors.error + '22', fg: theme.colors.error };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时间戳 (HH:mm:ss.SSS)
|
||||||
|
const formatTime = (isoString: string) => {
|
||||||
|
try {
|
||||||
|
const d = new Date(isoString);
|
||||||
|
const h = String(d.getHours()).padStart(2, '0');
|
||||||
|
const m = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const s = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
||||||
|
return `${h}:${m}:${s}.${ms}`;
|
||||||
|
} catch {
|
||||||
|
return isoString.slice(11, 23);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHeaderRight = (
|
||||||
|
<View style={styles.headerRight}>
|
||||||
|
<Pressable onPress={handleExport} hitSlop={8} style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}>
|
||||||
|
<Ionicons name="share-outline" size={22} color={theme.colors.accent} />
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={handleClear} hitSlop={8} style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}>
|
||||||
|
<Ionicons name="trash-outline" size={22} color={theme.colors.error} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
|
||||||
|
<ScreenHeader title={t('logs.title')} right={renderHeaderRight} />
|
||||||
|
|
||||||
|
{/* 日志日期 Dropdown 触发按钮 */}
|
||||||
|
<View style={[styles.fileSelectorBar, { borderBottomColor: theme.colors.divider }]}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setDateModalOpen(true)}
|
||||||
|
style={[
|
||||||
|
styles.dropdownTriggerBtn,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.colors.bgSecondary,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name="calendar-outline" size={16} color={theme.colors.accent} />
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '600', flex: 1, marginLeft: 8 }]}>
|
||||||
|
{selectedFileDate === null
|
||||||
|
? t('logs.todayLive')
|
||||||
|
: `${selectedFileDate} (${Math.round((selectedFile?.size ?? 0) / 1024)}KB)`}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-down" size={16} color={theme.colors.fgSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 日志日期 Dropdown 弹出菜单 Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={dateModalOpen}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setDateModalOpen(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.modalOverlay, { backgroundColor: theme.colors.overlay }]}
|
||||||
|
onPress={() => setDateModalOpen(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={[
|
||||||
|
styles.dropdownMenu,
|
||||||
|
theme.shadows.lg,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.colors.bgSecondary,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, fontWeight: '600' }]}>
|
||||||
|
选择日志日期
|
||||||
|
</Text>
|
||||||
|
<ScrollView style={{ maxHeight: 300 }}>
|
||||||
|
{/* 今天(实时) */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedFileDate(null);
|
||||||
|
setDateModalOpen(false);
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
styles.menuItem,
|
||||||
|
{
|
||||||
|
backgroundColor: selectedFileDate === null ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
|
||||||
|
borderColor: selectedFileDate === null ? theme.colors.accent : theme.colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: selectedFileDate === null ? theme.colors.accent : theme.colors.fgPrimary, fontWeight: '600' }]}>
|
||||||
|
{t('logs.todayLive')}
|
||||||
|
</Text>
|
||||||
|
{selectedFileDate === null && (
|
||||||
|
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* 历史日志文件 */}
|
||||||
|
{logFiles.map(file => {
|
||||||
|
const isSelected = selectedFileDate === file.date;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={file.name}
|
||||||
|
onPress={() => {
|
||||||
|
setSelectedFileDate(file.date);
|
||||||
|
setDateModalOpen(false);
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
styles.menuItem,
|
||||||
|
{
|
||||||
|
backgroundColor: isSelected ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
|
||||||
|
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: isSelected ? theme.colors.accent : theme.colors.fgPrimary, fontWeight: '600' }]}>
|
||||||
|
{file.date} ({Math.round(file.size / 1024)}KB)
|
||||||
|
</Text>
|
||||||
|
{isSelected && (
|
||||||
|
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<View style={styles.searchContainer}>
|
||||||
|
<View style={[styles.searchBox, { backgroundColor: theme.colors.bgSecondary, borderColor: theme.colors.border }]}>
|
||||||
|
<Ionicons name="search-outline" size={16} color={theme.colors.fgSecondary} />
|
||||||
|
<TextInput
|
||||||
|
style={[theme.typography.bodySmall, styles.searchInput, { color: theme.colors.fgPrimary }]}
|
||||||
|
placeholder={t('logs.searchPlaceholder')}
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
value={searchQuery}
|
||||||
|
onChangeText={setSearchQuery}
|
||||||
|
/>
|
||||||
|
{searchQuery ? (
|
||||||
|
<Pressable onPress={() => setSearchQuery('')} hitSlop={6}>
|
||||||
|
<Ionicons name="close-circle" size={18} color={theme.colors.fgSecondary} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 级别 Chip 筛选 */}
|
||||||
|
<View style={styles.levelFilterRow}>
|
||||||
|
{(['ALL', LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR] as const).map(lvl => {
|
||||||
|
const isSelected = levelFilter === lvl;
|
||||||
|
const label = lvl === 'ALL' ? t('logs.filterLevelAll', { count: counts.ALL }) : `${LEVEL_LABELS[lvl]} (${counts[lvl]})`;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={String(lvl)}
|
||||||
|
onPress={() => setLevelFilter(lvl)}
|
||||||
|
style={[
|
||||||
|
styles.levelChip,
|
||||||
|
{
|
||||||
|
backgroundColor: isSelected ? theme.colors.accent : theme.colors.bgTertiary,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
theme.typography.caption,
|
||||||
|
{ color: isSelected ? theme.colors.fgInverse : theme.colors.fgSecondary, fontWeight: isSelected ? '700' : '400' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 日志列表 */}
|
||||||
|
<FlatList
|
||||||
|
data={filteredLogs}
|
||||||
|
keyExtractor={(_, index) => String(index)}
|
||||||
|
contentContainerStyle={[styles.listContent, { gap: theme.spacing.md }]}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Ionicons name="document-text-outline" size={48} color={theme.colors.fgSecondary} />
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary, marginTop: 12 }]}>
|
||||||
|
{t('logs.empty')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item, index }) => {
|
||||||
|
const style = getLevelStyle(item.level);
|
||||||
|
const isExpanded = expandedIndex === index;
|
||||||
|
const hasData = item.data !== undefined && item.data !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setExpandedIndex(isExpanded ? null : index)}
|
||||||
|
style={[
|
||||||
|
styles.logCard,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.colors.bgSecondary,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<View style={[styles.levelBadge, { backgroundColor: style.bg }]}>
|
||||||
|
<Text style={[theme.typography.caption, { color: style.fg, fontWeight: '700', fontSize: 10 }]}>
|
||||||
|
{LEVEL_LABELS[item.level]}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[theme.typography.caption, styles.tagText, { color: theme.colors.accent }]}>
|
||||||
|
[{item.tag}]
|
||||||
|
</Text>
|
||||||
|
<Text style={[theme.typography.caption, styles.timeText, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{formatTime(item.timestamp)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, marginTop: 6, lineHeight: 18 }]}>
|
||||||
|
{item.message}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{hasData && (
|
||||||
|
<View style={styles.dataFooter}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{isExpanded ? t('logs.collapseData') : t('logs.expandData')}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name={isExpanded ? 'chevron-up' : 'chevron-down'} size={14} color={theme.colors.fgSecondary} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isExpanded && hasData && (
|
||||||
|
<View style={[styles.dataBox, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, fontFamily: 'monospace' }]}>
|
||||||
|
{typeof item.data === 'object' ? JSON.stringify(item.data, null, 2) : String(item.data)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: { flex: 1 },
|
||||||
|
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 16, marginRight: 8 },
|
||||||
|
fileSelectorBar: { paddingHorizontal: 16, paddingVertical: 8, borderBottomWidth: 1 },
|
||||||
|
dropdownTriggerBtn: { flexDirection: 'row', alignItems: 'center', borderRadius: 8, borderWidth: 1, paddingHorizontal: 12, minHeight: 38 },
|
||||||
|
modalOverlay: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
|
||||||
|
dropdownMenu: { width: '100%', maxWidth: 360, borderRadius: 16, borderWidth: 1, padding: 16 },
|
||||||
|
menuItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 12, paddingVertical: 10, borderRadius: 8, borderWidth: 1, marginBottom: 6 },
|
||||||
|
searchContainer: { paddingHorizontal: 16, paddingTop: 10 },
|
||||||
|
searchBox: { flexDirection: 'row', alignItems: 'center', borderRadius: 8, borderWidth: 1, paddingHorizontal: 10, minHeight: 36, gap: 8 },
|
||||||
|
searchInput: { flex: 1, paddingVertical: 0 },
|
||||||
|
levelFilterRow: { flexDirection: 'row', paddingHorizontal: 16, paddingVertical: 10, gap: 6, flexWrap: 'wrap' },
|
||||||
|
levelChip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 12 },
|
||||||
|
listContent: { padding: 16, paddingBottom: 40 },
|
||||||
|
logCard: { borderRadius: 8, borderWidth: 1, padding: 12 },
|
||||||
|
cardHeader: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
|
levelBadge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
|
||||||
|
tagText: { fontWeight: '600' },
|
||||||
|
timeText: { marginLeft: 'auto' },
|
||||||
|
dataFooter: { flexDirection: 'row', alignItems: 'center', marginTop: 8, gap: 4 },
|
||||||
|
dataBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
|
||||||
|
emptyContainer: { alignItems: 'center', justifyContent: 'center', paddingTop: 60 },
|
||||||
|
});
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View, Switch } from 'react-native';
|
import { Alert, Pressable, ScrollView, StyleSheet, Text, View, Switch } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { TimePicker } from '../../components/ui/TimePicker';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { useSettingsStore, type Locale, type ThemeMode } from '../../store/settingsStore';
|
import { useSettingsStore, type Locale, type ThemeMode } from '../../store/settingsStore';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
|
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||||
|
|
||||||
export default function PreferencesScreen() {
|
export default function PreferencesScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Settings State
|
// Settings State
|
||||||
const themeMode = useSettingsStore(s => s.themeMode);
|
const themeMode = useSettingsStore(s => s.themeMode);
|
||||||
@@ -24,6 +24,9 @@ export default function PreferencesScreen() {
|
|||||||
const reminderHour = useSettingsStore(s => s.reminderHour);
|
const reminderHour = useSettingsStore(s => s.reminderHour);
|
||||||
const reminderMinute = useSettingsStore(s => s.reminderMinute);
|
const reminderMinute = useSettingsStore(s => s.reminderMinute);
|
||||||
const updateReminderConfig = useSettingsStore(s => s.updateReminderConfig);
|
const updateReminderConfig = useSettingsStore(s => s.updateReminderConfig);
|
||||||
|
const [timePickerOpen, setTimePickerOpen] = useState(false);
|
||||||
|
const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
|
||||||
|
const setNumpadGlobalEntry = useSettingsStore(s => s.setNumpadGlobalEntry);
|
||||||
|
|
||||||
const THEME_OPTIONS: { mode: ThemeMode; labelKey: string }[] = [
|
const THEME_OPTIONS: { mode: ThemeMode; labelKey: string }[] = [
|
||||||
{ mode: 'light', labelKey: 'settings.themeLight' },
|
{ mode: 'light', labelKey: 'settings.themeLight' },
|
||||||
@@ -37,17 +40,10 @@ export default function PreferencesScreen() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<ScreenHeader title={t('settings.preferencesTitle')} />
|
||||||
<Pressable onPress={() => router.back()}>
|
|
||||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
|
||||||
{t('settings.preferencesTitle')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||||
{/* 外观设置 */}
|
{/* 外观设置 */}
|
||||||
<Card title={t('settings.appearance')}>
|
<Card title={t('settings.appearance')}>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
|
||||||
@@ -65,6 +61,7 @@ export default function PreferencesScreen() {
|
|||||||
{
|
{
|
||||||
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
|
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
|
||||||
borderColor: active ? theme.colors.accent : theme.colors.border,
|
borderColor: active ? theme.colors.accent : theme.colors.border,
|
||||||
|
borderRadius: theme.radii.sm,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -94,6 +91,7 @@ export default function PreferencesScreen() {
|
|||||||
{
|
{
|
||||||
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
|
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
|
||||||
borderColor: active ? theme.colors.accent : theme.colors.border,
|
borderColor: active ? theme.colors.accent : theme.colors.border,
|
||||||
|
borderRadius: theme.radii.sm,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -106,6 +104,24 @@ export default function PreferencesScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 记账 */}
|
||||||
|
<Card title={t('settings.entryTitle')}>
|
||||||
|
<View style={styles.switchRow}>
|
||||||
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
||||||
|
{t('settings.numpadEntry')}
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
value={numpadGlobalEntry}
|
||||||
|
onValueChange={setNumpadGlobalEntry}
|
||||||
|
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
||||||
|
thumbColor={theme.colors.fgInverse}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 6 }]}>
|
||||||
|
{t('settings.numpadEntryDesc')}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 安全设置 */}
|
{/* 安全设置 */}
|
||||||
<Card title={t('settings.security')}>
|
<Card title={t('settings.security')}>
|
||||||
<View style={styles.switchRow}>
|
<View style={styles.switchRow}>
|
||||||
@@ -117,7 +133,7 @@ export default function PreferencesScreen() {
|
|||||||
onValueChange={async (val) => {
|
onValueChange={async (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
// 开启:需要设置 PIN
|
// 开启:需要设置 PIN
|
||||||
const { hasPinSet, setPin } = await import('../../components/LockScreen');
|
const { hasPinSet } = await import('../../components/layout/LockScreen');
|
||||||
const hasPin = await hasPinSet();
|
const hasPin = await hasPinSet();
|
||||||
if (!hasPin) {
|
if (!hasPin) {
|
||||||
// Alert.prompt 仅 iOS 可用;Android 直接开启(用户首次锁屏时设置)
|
// Alert.prompt 仅 iOS 可用;Android 直接开启(用户首次锁屏时设置)
|
||||||
@@ -143,7 +159,7 @@ export default function PreferencesScreen() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 关闭:清除 PIN
|
// 关闭:清除 PIN
|
||||||
const { clearPin } = await import('../../components/LockScreen');
|
const { clearPin } = await import('../../components/layout/LockScreen');
|
||||||
await clearPin();
|
await clearPin();
|
||||||
setAppLockEnabled(false);
|
setAppLockEnabled(false);
|
||||||
}
|
}
|
||||||
@@ -185,34 +201,42 @@ export default function PreferencesScreen() {
|
|||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
||||||
{t('settings.reminderTime')}
|
{t('settings.reminderTime')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700' }]}>
|
|
||||||
{String(reminderHour).padStart(2, '0')}:{String(reminderMinute).padStart(2, '0')}
|
|
||||||
</Text>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={async () => {
|
onPress={() => setTimePickerOpen(true)}
|
||||||
// 简单的时间调整:小时 +1 循环
|
accessibilityRole="button"
|
||||||
const newHour = (reminderHour + 1) % 24;
|
accessibilityLabel={t('settings.reminderTime')}
|
||||||
updateReminderConfig({ reminderHour: newHour });
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}
|
||||||
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
|
|
||||||
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
|
|
||||||
}}
|
|
||||||
style={{ marginLeft: 12, padding: 4 }}
|
|
||||||
>
|
>
|
||||||
<Ionicons name="time-outline" size={20} color={theme.colors.accent} />
|
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||||||
|
{String(reminderHour).padStart(2, '0')}:{String(reminderMinute).padStart(2, '0')}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color={theme.colors.accent} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
<TimePicker
|
||||||
|
visible={timePickerOpen}
|
||||||
|
hour={reminderHour}
|
||||||
|
minute={reminderMinute}
|
||||||
|
title={t('settings.reminderTime')}
|
||||||
|
onCancel={() => setTimePickerOpen(false)}
|
||||||
|
onConfirm={async (h, m) => {
|
||||||
|
setTimePickerOpen(false);
|
||||||
|
updateReminderConfig({ reminderHour: h, reminderMinute: m });
|
||||||
|
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
|
||||||
|
await setupDailyReminder(new RealNotificationScheduler(), h, m);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
content: { padding: 16 },
|
||||||
content: { padding: 16, gap: 16 },
|
|
||||||
optionRow: { flexDirection: 'row', gap: 8 },
|
optionRow: { flexDirection: 'row', gap: 8 },
|
||||||
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 },
|
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1 },
|
||||||
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
});
|
});
|
||||||
|
|||||||
+23
-32
@@ -1,30 +1,29 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Platform } from 'react-native';
|
import { ScrollView, StyleSheet, Text, View, Alert, Platform } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import * as Sharing from 'expo-sharing';
|
import * as Sharing from 'expo-sharing';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { useSettingsStore } from '../../store/settingsStore';
|
import { useSettingsStore, type PersistableSettings } from '../../store/settingsStore';
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
import { useImportStore } from '../../store/importStore';
|
import { useImportStore } from '../../store/importStore';
|
||||||
import { useMetadataStore } from '../../store/metadataStore';
|
import { useMetadataStore, type PersistableMetadata } from '../../store/metadataStore';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { Button } from '../../components/Button';
|
import { Button } from '../../components/ui/Button';
|
||||||
import { FormModal, type FormField } from '../../components/FormModal';
|
import { FormModal, type FormField } from '../../components/form/FormModal';
|
||||||
|
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||||
import { syncWithWebDAV, WebDAVClient, type FetchImpl } from '../../services/sync/webdavSync';
|
import { syncWithWebDAV, WebDAVClient, type FetchImpl } from '../../services/sync/webdavSync';
|
||||||
import { syncWithGit, ExpoGitBackend } from '../../services/sync/gitSync';
|
import { syncWithGit, ExpoGitBackend } from '../../services/sync/gitSync';
|
||||||
import { syncWithICloud, MockICloudFileSystem } from '../../services/sync/icloudSync';
|
import { syncWithICloud, MockICloudFileSystem } from '../../services/sync/icloudSync';
|
||||||
import { createBackupBundle, serializeBundle, deserializeBundle, restoreFiles } from '../../services/backup';
|
import { createBackupBundle, serializeBundle, deserializeBundle, restoreFiles } from '../../services/data/backup';
|
||||||
import { runMaintenance, ExpoMaintenanceFs, ExpoMaintenanceDb } from '../../services/maintenance';
|
import type { ExcelWorkbook } from '../../services/data/exportToExcel';
|
||||||
|
import { runMaintenance, ExpoMaintenanceFs, ExpoMaintenanceDb } from '../../services/data/maintenance';
|
||||||
|
|
||||||
export default function SyncSettingsScreen() {
|
export default function SyncSettingsScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Sync Configuration
|
// Sync Configuration
|
||||||
const webdavUrl = useSettingsStore(s => s.webdavUrl) || '';
|
const webdavUrl = useSettingsStore(s => s.webdavUrl) || '';
|
||||||
@@ -235,13 +234,13 @@ export default function SyncSettingsScreen() {
|
|||||||
const content = await FileSystem.readAsStringAsync(file.uri);
|
const content = await FileSystem.readAsStringAsync(file.uri);
|
||||||
const bundle = deserializeBundle(content);
|
const bundle = deserializeBundle(content);
|
||||||
const restoredFiles = restoreFiles(bundle);
|
const restoredFiles = restoreFiles(bundle);
|
||||||
// 用恢复的 content 替换当前内容,支持新版 main.bean 并且兼容旧版 mobile.bean
|
// 用恢复的 content 替换当前内容
|
||||||
const restoredMobileBean = restoredFiles.find(f => f.path === 'main.bean')?.content || restoredFiles.find(f => f.path === 'mobile.bean')?.content || '';
|
const restoredMainBean = restoredFiles.find(f => f.path === 'main.bean')?.content || '';
|
||||||
await replaceMobileBean(restoredMobileBean);
|
await replaceMobileBean(restoredMainBean);
|
||||||
|
|
||||||
// 恢复设置(v2)
|
// 恢复设置(v2)
|
||||||
if (bundle.settings) {
|
if (bundle.settings) {
|
||||||
useSettingsStore.getState().hydrate(bundle.settings as any);
|
useSettingsStore.getState().hydrate(bundle.settings as Partial<PersistableSettings>);
|
||||||
// 同步持久化到文件
|
// 同步持久化到文件
|
||||||
const settingsPath = FileSystem.documentDirectory + 'settings.json';
|
const settingsPath = FileSystem.documentDirectory + 'settings.json';
|
||||||
await FileSystem.writeAsStringAsync(settingsPath, JSON.stringify(bundle.settings, null, 2));
|
await FileSystem.writeAsStringAsync(settingsPath, JSON.stringify(bundle.settings, null, 2));
|
||||||
@@ -249,7 +248,7 @@ export default function SyncSettingsScreen() {
|
|||||||
|
|
||||||
// 恢复元数据(v2)
|
// 恢复元数据(v2)
|
||||||
if (bundle.metadata) {
|
if (bundle.metadata) {
|
||||||
useMetadataStore.getState().hydrate(bundle.metadata as any);
|
useMetadataStore.getState().hydrate(bundle.metadata as Partial<PersistableMetadata>);
|
||||||
// 同步持久化到文件
|
// 同步持久化到文件
|
||||||
const metadataPath = FileSystem.documentDirectory + 'metadata.json';
|
const metadataPath = FileSystem.documentDirectory + 'metadata.json';
|
||||||
await FileSystem.writeAsStringAsync(metadataPath, JSON.stringify(bundle.metadata, null, 2));
|
await FileSystem.writeAsStringAsync(metadataPath, JSON.stringify(bundle.metadata, null, 2));
|
||||||
@@ -286,7 +285,7 @@ export default function SyncSettingsScreen() {
|
|||||||
const categories = useMetadataStore.getState().categories;
|
const categories = useMetadataStore.getState().categories;
|
||||||
const loadLedger = useLedgerStore.getState().loadLedger;
|
const loadLedger = useLedgerStore.getState().loadLedger;
|
||||||
const setContext = useImportStore.getState().setContext;
|
const setContext = useImportStore.getState().setContext;
|
||||||
const { FileSystemBackend } = await import('../../services/fileSystemBackend');
|
const { FileSystemBackend } = await import('../../services/data/fileSystemBackend');
|
||||||
|
|
||||||
await loadLedger([{ path: 'main.bean', content }], new FileSystemBackend());
|
await loadLedger([{ path: 'main.bean', content }], new FileSystemBackend());
|
||||||
setContext({ rules, categories });
|
setContext({ rules, categories });
|
||||||
@@ -340,7 +339,7 @@ export default function SyncSettingsScreen() {
|
|||||||
const handleExportExcel = async () => {
|
const handleExportExcel = async () => {
|
||||||
try {
|
try {
|
||||||
const XLSX = (await import('xlsx')).default;
|
const XLSX = (await import('xlsx')).default;
|
||||||
const { exportToExcel } = await import('../../services/exportToExcel');
|
const { exportToExcel } = await import('../../services/data/exportToExcel');
|
||||||
const { transactions } = ledger!;
|
const { transactions } = ledger!;
|
||||||
const workbook = XLSX.utils.book_new();
|
const workbook = XLSX.utils.book_new();
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
@@ -348,7 +347,7 @@ export default function SyncSettingsScreen() {
|
|||||||
let base64Content = '';
|
let base64Content = '';
|
||||||
// 用 xlsx 库创建 workbook wrapper
|
// 用 xlsx 库创建 workbook wrapper
|
||||||
const wbWrapper = {
|
const wbWrapper = {
|
||||||
addSheet: (name: string, rows: any[]) => {
|
addSheet: (name: string, rows: Record<string, unknown>[]) => {
|
||||||
const ws = XLSX.utils.json_to_sheet(rows);
|
const ws = XLSX.utils.json_to_sheet(rows);
|
||||||
XLSX.utils.book_append_sheet(workbook, ws, name);
|
XLSX.utils.book_append_sheet(workbook, ws, name);
|
||||||
},
|
},
|
||||||
@@ -357,7 +356,7 @@ export default function SyncSettingsScreen() {
|
|||||||
return FileSystem.writeAsStringAsync(path, base64Content, { encoding: FileSystem.EncodingType.Base64 });
|
return FileSystem.writeAsStringAsync(path, base64Content, { encoding: FileSystem.EncodingType.Base64 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await exportToExcel(transactions, wbWrapper as any, exportPath);
|
await exportToExcel(transactions, wbWrapper as unknown as ExcelWorkbook, exportPath);
|
||||||
|
|
||||||
// Android 特殊处理:使用 SAF 直接保存到本地公开目录
|
// Android 特殊处理:使用 SAF 直接保存到本地公开目录
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
@@ -455,17 +454,10 @@ export default function SyncSettingsScreen() {
|
|||||||
const isAndroid = Platform.OS === 'android';
|
const isAndroid = Platform.OS === 'android';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<ScreenHeader title={t('sync.title')} />
|
||||||
<Pressable onPress={() => router.back()}>
|
|
||||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
|
||||||
</Pressable>
|
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
|
||||||
{t('sync.title')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||||
{/* 云同步配置与云端操作 */}
|
{/* 云同步配置与云端操作 */}
|
||||||
<Card title={t('sync.cloudTitle')}>
|
<Card title={t('sync.cloudTitle')}>
|
||||||
<View style={styles.syncBtnRow}>
|
<View style={styles.syncBtnRow}>
|
||||||
@@ -539,8 +531,7 @@ export default function SyncSettingsScreen() {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
page: { flex: 1 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
content: { padding: 16 },
|
||||||
content: { padding: 16, gap: 16 },
|
|
||||||
syncBtnRow: { flexDirection: 'row', gap: 8 },
|
syncBtnRow: { flexDirection: 'row', gap: 8 },
|
||||||
buttonRow: { flexDirection: 'row', gap: 8 },
|
buttonRow: { flexDirection: 'row', gap: 8 },
|
||||||
});
|
});
|
||||||
|
|||||||
+61
-110
@@ -1,141 +1,92 @@
|
|||||||
/**
|
/**
|
||||||
* 标签管理页面(plan.md「1.1 tag/index」+ 决策 1 双轨制)。
|
* 标签管理页面 —— ManagementScreen 模板试点(P2)。
|
||||||
*
|
*
|
||||||
* 功能:标签列表(彩色芯片) + 添加/编辑/删除。
|
* 功能:标签列表(彩色芯片) + 添加/编辑/删除。
|
||||||
* 标签名写入 .bean 的 #tag 语法,需符合 [\w-]。
|
* 标签名写入 .bean 的 #tag 语法,需符合 [\w-]。
|
||||||
*/
|
*/
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
|
import { TAG_COLORS } from '../../theme/palette';
|
||||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { Card } from '../../components/Card';
|
import { ManagementScreen } from '../../components/layout/ManagementScreen';
|
||||||
import { FormModal, type FormField } from '../../components/FormModal';
|
import { isValidTagName } from '../../domain/taxonomy/tags';
|
||||||
import { isValidTagName } from '../../domain/tags';
|
import type { Tag } from '../../domain/taxonomy/tags';
|
||||||
import type { Tag } from '../../domain/tags';
|
|
||||||
|
|
||||||
type ModalMode = { type: 'add' } | { type: 'edit'; tag: Tag } | null;
|
|
||||||
|
|
||||||
const COLORS = ['#F44336', '#E91E63', '#9C27B0', '#2196F3', '#00BCD4', '#4CAF50', '#FF9800', '#795548'];
|
|
||||||
|
|
||||||
export default function TagScreen() {
|
export default function TagScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const tags = useMetadataStore(s => s.tags);
|
const tags = useMetadataStore(s => s.tags);
|
||||||
const addTag = useMetadataStore(s => s.addTag);
|
const addTag = useMetadataStore(s => s.addTag);
|
||||||
const updateTag = useMetadataStore(s => s.updateTag);
|
const updateTag = useMetadataStore(s => s.updateTag);
|
||||||
const removeTag = useMetadataStore(s => s.removeTag);
|
const removeTag = useMetadataStore(s => s.removeTag);
|
||||||
|
|
||||||
const [modal, setModal] = useState<ModalMode>(null);
|
const [selectedColor, setSelectedColor] = useState<string>(TAG_COLORS[0]);
|
||||||
const [selectedColor, setSelectedColor] = useState(COLORS[0]);
|
|
||||||
|
|
||||||
const handleDelete = (tag: Tag) => {
|
|
||||||
Alert.alert(t('tag.deleteTitle'), t('tag.deleteConfirm', { name: tag.name }), [
|
|
||||||
{ text: t('common.cancel'), style: 'cancel' },
|
|
||||||
{ text: t('common.delete'), style: 'destructive', onPress: () => removeTag(tag.id) },
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = (values: Record<string, string>) => {
|
|
||||||
const name = values.name?.trim() ?? '';
|
|
||||||
if (!isValidTagName(name)) {
|
|
||||||
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (modal?.type === 'add') {
|
|
||||||
addTag({ id: generateId('tag'), name, color: selectedColor });
|
|
||||||
} else if (modal?.type === 'edit') {
|
|
||||||
updateTag(modal.tag.id, { name, color: selectedColor });
|
|
||||||
}
|
|
||||||
setModal(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openModal = (mode: ModalMode) => {
|
|
||||||
if (mode?.type === 'edit') setSelectedColor(mode.tag.color);
|
|
||||||
else setSelectedColor(COLORS[0]);
|
|
||||||
setModal(mode);
|
|
||||||
};
|
|
||||||
|
|
||||||
const editFields: FormField[] = modal?.type === 'edit'
|
|
||||||
? [{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: modal.tag.name }]
|
|
||||||
: [{ key: 'name', label: t('tag.fieldName'), placeholder: 'food' }];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<ManagementScreen<Tag>
|
||||||
<View style={styles.header}>
|
title={t('tag.title')}
|
||||||
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
|
items={tags}
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('tag.title')}</Text>
|
keyExtractor={tag => tag.id}
|
||||||
</View>
|
addLabel={t('tag.add')}
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
emptyText={t('tag.empty')}
|
||||||
<Card title={t('tag.count', { count: tags.length })}>
|
listStyle={styles.chipGrid}
|
||||||
<Pressable onPress={() => openModal({ type: 'add' })} style={styles.addBtn}>
|
onOpenForm={editing => setSelectedColor(editing?.color ?? TAG_COLORS[0])}
|
||||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
renderItem={(tag, { openEdit, confirmDelete }) => (
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('tag.add')}</Text>
|
<Pressable
|
||||||
</Pressable>
|
onPress={openEdit}
|
||||||
<View style={[styles.grid, { gap: 8, paddingTop: 4 }]}>
|
onLongPress={confirmDelete}
|
||||||
{tags.map(tag => (
|
style={[styles.chip, { backgroundColor: tag.color }]}
|
||||||
<Pressable
|
>
|
||||||
key={tag.id}
|
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
|
||||||
onPress={() => openModal({ type: 'edit', tag })}
|
</Pressable>
|
||||||
onLongPress={() => handleDelete(tag)}
|
)}
|
||||||
style={[styles.chip, { backgroundColor: tag.color }]}
|
formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))}
|
||||||
>
|
formFields={editing => [
|
||||||
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
|
{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name },
|
||||||
</Pressable>
|
]}
|
||||||
))}
|
formExtra={() => (
|
||||||
{tags.length === 0 && (
|
<View style={styles.colorRow}>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('tag.empty')}</Text>
|
{TAG_COLORS.map(c => (
|
||||||
)}
|
<Pressable
|
||||||
</View>
|
key={c}
|
||||||
</Card>
|
onPress={() => setSelectedColor(c)}
|
||||||
|
style={[styles.colorDot, {
|
||||||
{/* 颜色选择器(模态打开时显示) */}
|
backgroundColor: c,
|
||||||
{modal && (
|
borderWidth: selectedColor === c ? 3 : 0,
|
||||||
<Card title={t('tag.selectColor')}>
|
borderColor: theme.colors.fgPrimary,
|
||||||
<View style={styles.colorRow}>
|
}]}
|
||||||
{COLORS.map(c => (
|
/>
|
||||||
<Pressable
|
))}
|
||||||
key={c}
|
</View>
|
||||||
onPress={() => setSelectedColor(c)}
|
)}
|
||||||
style={[styles.colorDot, {
|
onSubmit={(values, editing) => {
|
||||||
backgroundColor: c,
|
const name = values.name?.trim() ?? '';
|
||||||
borderWidth: selectedColor === c ? 3 : 0,
|
if (!isValidTagName(name)) {
|
||||||
borderColor: theme.colors.fgPrimary,
|
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
|
||||||
}]}
|
return false;
|
||||||
/>
|
}
|
||||||
))}
|
if (editing) updateTag(editing.id, { name, color: selectedColor });
|
||||||
</View>
|
else addTag({ id: generateId('tag'), name, color: selectedColor });
|
||||||
</Card>
|
return true;
|
||||||
)}
|
}}
|
||||||
|
onDelete={tag => removeTag(tag.id)}
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
deleteConfirmText={tag => t('tag.deleteConfirm', { name: tag.name })}
|
||||||
|
deleteConfirmTitle={t('tag.deleteTitle')}
|
||||||
|
footer={
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
||||||
{t('common.clickEditLongDelete')}
|
{t('common.clickEditLongDelete')}
|
||||||
</Text>
|
</Text>
|
||||||
</ScrollView>
|
}
|
||||||
|
/>
|
||||||
<FormModal
|
|
||||||
visible={modal !== null}
|
|
||||||
title={modal?.type === 'edit' ? t('tag.editTitle') : t('tag.add')}
|
|
||||||
fields={editFields}
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={() => setModal(null)}
|
|
||||||
/>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
page: { flex: 1 },
|
chipGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
|
||||||
content: { padding: 16, gap: 12 },
|
|
||||||
grid: { flexDirection: 'row', flexWrap: 'wrap' },
|
|
||||||
chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
|
chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
|
||||||
addBtn: { flexDirection: 'row', alignItems: 'center', paddingBottom: 8 },
|
|
||||||
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
|
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
|
||||||
colorDot: { width: 36, height: 36, borderRadius: 18 },
|
colorDot: { width: 36, height: 36, borderRadius: 18 },
|
||||||
});
|
});
|
||||||
|
|||||||
+189
-63
@@ -8,20 +8,23 @@
|
|||||||
* - 原始 .bean 文本
|
* - 原始 .bean 文本
|
||||||
*/
|
*/
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Modal, TextInput, FlatList } from 'react-native';
|
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Modal, TextInput, FlatList, KeyboardAvoidingView, Platform } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useTheme, createCommonStyles } from '../../theme';
|
import { useTheme, createCommonStyles } from '../../theme';
|
||||||
import { Card } from '../../components/Card';
|
import { Card } from '../../components/ui/Card';
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
import { useLedgerStore } from '../../store/ledgerStore';
|
||||||
import { hash } from '../../domain/ledger';
|
import { useNumpadUiStore } from '../../store/numpadUiStore';
|
||||||
|
import { hash } from '../../domain/core/ledger';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { TransactionCard } from '../../components/TransactionCard';
|
import { TransactionCard } from '../../components/transaction/TransactionCard';
|
||||||
|
import { ScreenHeader } from '../../components/layout/ScreenHeader';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
export default function TransactionDetailScreen() {
|
export default function TransactionDetailScreen() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const commonStyles = createCommonStyles(theme);
|
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
@@ -43,12 +46,12 @@ export default function TransactionDetailScreen() {
|
|||||||
return ledger.transactions.filter(
|
return ledger.transactions.filter(
|
||||||
t => t.id !== id && t.links.some(l => tx.links.includes(l))
|
t => t.id !== id && t.links.some(l => tx.links.includes(l))
|
||||||
);
|
);
|
||||||
}, [ledger, id, tx?.links]);
|
}, [ledger, id, tx]);
|
||||||
|
|
||||||
// 筛选可以用来关联的其他账单
|
// 筛选可以用来关联的其他账单
|
||||||
const linkableTransactions = useMemo(() => {
|
const linkableTransactions = useMemo(() => {
|
||||||
if (!ledger) return [];
|
if (!ledger) return [];
|
||||||
return ledger.transactions
|
const list = ledger.transactions
|
||||||
.filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l)))
|
.filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l)))
|
||||||
.filter(t => {
|
.filter(t => {
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
@@ -59,17 +62,19 @@ export default function TransactionDetailScreen() {
|
|||||||
t.postings.some(p => p.account.toLowerCase().includes(q))
|
t.postings.some(p => p.account.toLowerCase().includes(q))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.reverse() // 解决截断 Bug:将顺序反转,优先展示最新的交易
|
.sort((a, b) => b.date.localeCompare(a.date)); // 显式按交易日期【由新到旧 (最新 ➔ 最旧)】严格倒序排列
|
||||||
.slice(0, 30);
|
|
||||||
|
// 有搜索条件时展示最多 100 条匹配结果,无搜索词时默认展示最新 50 条
|
||||||
|
return searchQuery ? list.slice(0, 100) : list.slice(0, 50);
|
||||||
}, [ledger, id, tx?.links, searchQuery]);
|
}, [ledger, id, tx?.links, searchQuery]);
|
||||||
|
|
||||||
const handleLinkTransaction = async (targetTx: typeof tx) => {
|
const handleLinkTransaction = async (targetTx: typeof tx) => {
|
||||||
if (!tx || !targetTx) return;
|
if (!tx || !targetTx) return;
|
||||||
|
let linkTag = tx.links[0] || targetTx.links[0] || '';
|
||||||
try {
|
try {
|
||||||
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
|
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
|
||||||
|
|
||||||
// 取出已有链接标签,若没有则生成一个随机哈希标签
|
// 取出已有链接标签,若没有则生成一个随机哈希标签
|
||||||
let linkTag = tx.links[0] || targetTx.links[0];
|
|
||||||
if (!linkTag) {
|
if (!linkTag) {
|
||||||
linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8);
|
linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8);
|
||||||
}
|
}
|
||||||
@@ -97,10 +102,33 @@ export default function TransactionDetailScreen() {
|
|||||||
const newTargetRaw = addLinkToRaw(targetTx.raw, linkTag);
|
const newTargetRaw = addLinkToRaw(targetTx.raw, linkTag);
|
||||||
|
|
||||||
let newContent = mobileBean;
|
let newContent = mobileBean;
|
||||||
newContent = newContent.replace(tx.raw, newCurrentRaw);
|
newContent = newContent.replaceAll(tx.raw, newCurrentRaw);
|
||||||
newContent = newContent.replace(targetTx.raw, newTargetRaw);
|
newContent = newContent.replaceAll(targetTx.raw, newTargetRaw);
|
||||||
|
|
||||||
|
const formatTxSummary = (t: typeof tx) => {
|
||||||
|
if (!t) return '';
|
||||||
|
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
|
||||||
|
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
|
||||||
|
};
|
||||||
|
|
||||||
await replaceMobileBean(newContent);
|
await replaceMobileBean(newContent);
|
||||||
|
logger.info('ledgerStore', `关联交易成功: 标签 ^${linkTag}\n【交易 1】: ${formatTxSummary(tx)}\n【交易 2】: ${formatTxSummary(targetTx)}`, {
|
||||||
|
linkTag,
|
||||||
|
transaction1: {
|
||||||
|
id: tx.id,
|
||||||
|
date: tx.date,
|
||||||
|
payee: tx.payee,
|
||||||
|
narration: tx.narration,
|
||||||
|
postings: tx.postings,
|
||||||
|
},
|
||||||
|
transaction2: {
|
||||||
|
id: targetTx.id,
|
||||||
|
date: targetTx.date,
|
||||||
|
payee: targetTx.payee,
|
||||||
|
narration: targetTx.narration,
|
||||||
|
postings: targetTx.postings,
|
||||||
|
},
|
||||||
|
});
|
||||||
setLinkModalVisible(false);
|
setLinkModalVisible(false);
|
||||||
|
|
||||||
// 解决 race condition:直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏
|
// 解决 race condition:直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏
|
||||||
@@ -110,6 +138,7 @@ export default function TransactionDetailScreen() {
|
|||||||
}
|
}
|
||||||
Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag }));
|
Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error('ledgerStore', `关联交易失败 (Tag: ^${linkTag})`, e);
|
||||||
Alert.alert(t('transaction.linkFail'), String(e));
|
Alert.alert(t('transaction.linkFail'), String(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -132,13 +161,31 @@ export default function TransactionDetailScreen() {
|
|||||||
lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), '');
|
lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), '');
|
||||||
const newRaw = lines.join('\n');
|
const newRaw = lines.join('\n');
|
||||||
|
|
||||||
newContent = newContent.replace(companion.raw, newRaw);
|
newContent = newContent.replaceAll(companion.raw, newRaw);
|
||||||
if (companion.id === tx.id) {
|
if (companion.id === tx.id) {
|
||||||
newCurrentRaw = newRaw;
|
newCurrentRaw = newRaw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatTxSummary = (t: typeof tx) => {
|
||||||
|
if (!t) return '';
|
||||||
|
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
|
||||||
|
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
|
||||||
|
};
|
||||||
|
|
||||||
await replaceMobileBean(newContent);
|
await replaceMobileBean(newContent);
|
||||||
|
const unlinkedListStr = companionTxs.map(c => formatTxSummary(c)).join('\n ');
|
||||||
|
logger.info('ledgerStore', `解除交易关联成功: 标签 ^${linkTag} (同步解绑 ${companionTxs.length} 笔相关交易)\n 解绑交易列表:\n ${unlinkedListStr}`, {
|
||||||
|
linkTag,
|
||||||
|
unlinkedCount: companionTxs.length,
|
||||||
|
unlinkedTransactions: companionTxs.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
date: c.date,
|
||||||
|
payee: c.payee,
|
||||||
|
narration: c.narration,
|
||||||
|
postings: c.postings,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
// 解决 race condition:直接使用 hash() 算法计算新 ID
|
// 解决 race condition:直接使用 hash() 算法计算新 ID
|
||||||
const newId = hash(`main.bean:${newCurrentRaw}`);
|
const newId = hash(`main.bean:${newCurrentRaw}`);
|
||||||
@@ -147,6 +194,7 @@ export default function TransactionDetailScreen() {
|
|||||||
}
|
}
|
||||||
Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag }));
|
Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error('ledgerStore', `解除交易关联失败 (Tag: ^${linkTag})`, e);
|
||||||
Alert.alert(t('transaction.unlinkFail'), String(e));
|
Alert.alert(t('transaction.unlinkFail'), String(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -154,10 +202,7 @@ export default function TransactionDetailScreen() {
|
|||||||
if (!tx) {
|
if (!tx) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
|
||||||
<View style={styles.header}>
|
<ScreenHeader title={t('common.notFound')} />
|
||||||
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
|
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('common.notFound')}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<Card title={t('transaction.notFoundTitle')}>
|
<Card title={t('transaction.notFoundTitle')}>
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
||||||
@@ -178,11 +223,8 @@ export default function TransactionDetailScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
|
||||||
<View style={styles.header}>
|
<ScreenHeader title={t('transaction.detailTitle')} />
|
||||||
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
|
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('transaction.detailTitle')}</Text>
|
|
||||||
</View>
|
|
||||||
<ScrollView contentContainerStyle={[styles.content, { gap: 12 }]}>
|
|
||||||
{/* 摘要卡片 */}
|
{/* 摘要卡片 */}
|
||||||
<Card title={t('transaction.summary')}>
|
<Card title={t('transaction.summary')}>
|
||||||
<View style={styles.summaryRow}>
|
<View style={styles.summaryRow}>
|
||||||
@@ -200,39 +242,111 @@ export default function TransactionDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Postings */}
|
{/* 资金流向 */}
|
||||||
<Card title={`${t('transaction.postings')} (${tx.postings.length})`}>
|
<Card title={`${t('transaction.postings')} (${tx.postings.length})`}>
|
||||||
{tx.postings.map((p, i) => (
|
<View style={{ gap: 8 }}>
|
||||||
<View key={i} style={[styles.postingRow, { borderTopColor: theme.colors.divider, borderTopWidth: i > 0 ? 1 : 0 }]}>
|
{tx.postings.map((p, i) => {
|
||||||
<View style={{ flex: 1 }}>
|
const parts = p.account.split(':');
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
const rootType = parts[0];
|
||||||
{p.account}
|
const accountShortName = parts.slice(1).join(':') || p.account;
|
||||||
</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>
|
||||||
|
|
||||||
{/* 标签 */}
|
{/* 标签 */}
|
||||||
@@ -297,9 +411,13 @@ export default function TransactionDetailScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 元数据 */}
|
{/* 元数据 / 附加信息 */}
|
||||||
{tx.metadata && Object.keys(tx.metadata).length > 0 && (
|
{tx.metadata && Object.keys(tx.metadata).length > 0 && (
|
||||||
<Card title={t('transaction.metadata')}>
|
<Card
|
||||||
|
title={`${t('transaction.metadata')} (${Object.keys(tx.metadata).length})`}
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed
|
||||||
|
>
|
||||||
{Object.entries(tx.metadata).map(([k, v]) => (
|
{Object.entries(tx.metadata).map(([k, v]) => (
|
||||||
<View key={k} style={styles.metaRow}>
|
<View key={k} style={styles.metaRow}>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{k}</Text>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{k}</Text>
|
||||||
@@ -310,8 +428,12 @@ export default function TransactionDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 原始 .bean */}
|
{/* 原始 .bean */}
|
||||||
<Card title={t('transaction.rawBean')}>
|
<Card
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontFamily: 'monospace', lineHeight: 20 }]}>
|
title={t('transaction.rawBean')}
|
||||||
|
collapsible
|
||||||
|
defaultCollapsed
|
||||||
|
>
|
||||||
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'], lineHeight: 20 }]}>
|
||||||
{tx.raw}
|
{tx.raw}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
|
||||||
@@ -323,7 +445,7 @@ export default function TransactionDetailScreen() {
|
|||||||
{isEditable && (
|
{isEditable && (
|
||||||
<View style={styles.editActions}>
|
<View style={styles.editActions}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.push({ pathname: '/transaction/new', params: { editId: id } })}
|
onPress={() => useNumpadUiStore.getState().open({ editId: id })}
|
||||||
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
|
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
|
||||||
>
|
>
|
||||||
<Ionicons name="create-outline" size={16} color={theme.colors.accent} />
|
<Ionicons name="create-outline" size={16} color={theme.colors.accent} />
|
||||||
@@ -367,7 +489,7 @@ export default function TransactionDetailScreen() {
|
|||||||
transparent={false}
|
transparent={false}
|
||||||
onRequestClose={() => setLinkModalVisible(false)}
|
onRequestClose={() => setLinkModalVisible(false)}
|
||||||
>
|
>
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable onPress={() => setLinkModalVisible(false)}>
|
<Pressable onPress={() => setLinkModalVisible(false)}>
|
||||||
<Ionicons name="close" size={24} color={theme.colors.fgPrimary} />
|
<Ionicons name="close" size={24} color={theme.colors.fgPrimary} />
|
||||||
@@ -377,9 +499,10 @@ export default function TransactionDetailScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={{ flex: 1 }}>
|
||||||
<View style={styles.searchBarContainer}>
|
<View style={styles.searchBarContainer}>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[commonStyles.input, { height: 40 }]}
|
style={[commonStyles.input, { minHeight: 40 }]}
|
||||||
placeholder={t('transaction.searchTxPlaceholder')}
|
placeholder={t('transaction.searchTxPlaceholder')}
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -390,6 +513,7 @@ export default function TransactionDetailScreen() {
|
|||||||
<FlatList
|
<FlatList
|
||||||
data={linkableTransactions}
|
data={linkableTransactions}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={{ paddingHorizontal: 16, paddingVertical: 4 }}>
|
<View style={{ paddingHorizontal: 16, paddingVertical: 4 }}>
|
||||||
<TransactionCard
|
<TransactionCard
|
||||||
@@ -404,6 +528,7 @@ export default function TransactionDetailScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Modal>
|
</Modal>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
@@ -416,14 +541,15 @@ const styles = StyleSheet.create({
|
|||||||
content: { padding: 16 },
|
content: { padding: 16 },
|
||||||
summaryRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
summaryRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
directionBadge: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: 12 },
|
directionBadge: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: 12 },
|
||||||
postingRow: { flexDirection: 'row', paddingTop: 10, gap: 8 },
|
|
||||||
tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||||
tag: { paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
|
tag: { paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
|
||||||
tagContainer: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
|
tagContainer: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
|
||||||
linkBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 8 },
|
linkBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 8 },
|
||||||
searchBarContainer: { paddingHorizontal: 16, paddingBottom: 8 },
|
searchBarContainer: { paddingHorizontal: 16, paddingBottom: 8 },
|
||||||
searchInput: { height: 40, borderWidth: StyleSheet.hairlineWidth, borderRadius: 8, paddingHorizontal: 12, fontSize: 14 },
|
|
||||||
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
|
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
|
||||||
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
|
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
|
||||||
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 },
|
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 },
|
||||||
|
flowCardItem: { flexDirection: 'row', alignItems: 'center', padding: 10 },
|
||||||
|
flowIconBox: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
miniTypeTag: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,792 +0,0 @@
|
|||||||
import React, { useMemo, useState, useEffect, useRef } from 'react';
|
|
||||||
import { ScrollView, StyleSheet, Text, TextInput, View, Switch, Alert, Pressable, LayoutAnimation, Platform } from 'react-native';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
|
||||||
import * as FileSystem from 'expo-file-system/legacy';
|
|
||||||
import { useLedgerStore } from '../../store/ledgerStore';
|
|
||||||
import { useMetadataStore } from '../../store/metadataStore';
|
|
||||||
import { useTheme, createCommonStyles } from '../../theme';
|
|
||||||
import { useT } from '../../i18n';
|
|
||||||
import { Card } from '../../components/Card';
|
|
||||||
import { Button } from '../../components/Button';
|
|
||||||
import { CategoryPicker } from '../../components/CategoryPicker';
|
|
||||||
import { TagPicker } from '../../components/TagPicker';
|
|
||||||
import { PostingEditor } from '../../components/PostingEditor';
|
|
||||||
import { tagsToBeanSyntax } from '../../domain/tags';
|
|
||||||
import { serializeTransaction } from '../../domain/ledger';
|
|
||||||
import { buildAndSaveTransaction } from '../../domain/transactionBuilder';
|
|
||||||
import { matchCategory } from '../../domain/categories';
|
|
||||||
import { getNativeOcrModule, getNativeOcrBridge } from '../../services/ocrBridge';
|
|
||||||
import { OcrProcessor } from '../../domain/ocrProcessor';
|
|
||||||
import type { Category } from '../../domain/categories';
|
|
||||||
import type { Tag } from '../../domain/tags';
|
|
||||||
import type { Posting } from '../../domain/types';
|
|
||||||
|
|
||||||
|
|
||||||
type Direction = 'expense' | 'income' | 'transfer';
|
|
||||||
|
|
||||||
export default function NewTransactionScreen() {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const commonStyles = createCommonStyles(theme);
|
|
||||||
const t = useT();
|
|
||||||
const router = useRouter();
|
|
||||||
const { mode, editId, draftJson } = useLocalSearchParams<{ mode?: string; editId?: string; draftJson?: string }>();
|
|
||||||
const addTransaction = useLedgerStore(s => s.addTransaction);
|
|
||||||
const editTransaction = useLedgerStore(s => s.editTransaction);
|
|
||||||
const ledger = useLedgerStore(s => s.ledger);
|
|
||||||
const categories = useMetadataStore(s => s.categories);
|
|
||||||
const tags = useMetadataStore(s => s.tags);
|
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
// 基础表单状态
|
|
||||||
const [date, setDate] = useState(today);
|
|
||||||
const [narration, setNarration] = useState('');
|
|
||||||
const [payee, setPayee] = useState('');
|
|
||||||
const [currency, setCurrency] = useState('CNY');
|
|
||||||
const [amount, setAmount] = useState('');
|
|
||||||
const [direction, setDirection] = useState<Direction>('expense');
|
|
||||||
const [sourceAccount, setSourceAccount] = useState('Assets:支付宝余额');
|
|
||||||
const [targetAccount, setTargetAccount] = useState('Assets:微信零钱');
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
|
|
||||||
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
|
|
||||||
// 高级模式状态
|
|
||||||
const [isAdvanced, setIsAdvanced] = useState(false);
|
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
|
||||||
|
|
||||||
const [postings, setPostings] = useState<Posting[]>([
|
|
||||||
{ account: 'Assets:支付宝余额', amount: '', currency: 'CNY' },
|
|
||||||
{ account: 'Expenses:餐饮', amount: '', currency: 'CNY' }
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
|
|
||||||
const hasChanges =
|
|
||||||
amount.trim() !== '' ||
|
|
||||||
payee.trim() !== '' ||
|
|
||||||
narration.trim() !== '' ||
|
|
||||||
selectedTags.length > 0 ||
|
|
||||||
(isAdvanced && postings.some(p => p.account.trim() !== '' || (p.amount || '').trim() !== ''));
|
|
||||||
|
|
||||||
if (!hasChanges || isSubmitted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
Alert.alert(
|
|
||||||
t('transaction.unsavedTitle'),
|
|
||||||
t('transaction.unsavedMessage'),
|
|
||||||
[
|
|
||||||
{ text: t('common.cancel'), style: 'cancel', onPress: () => {} },
|
|
||||||
{
|
|
||||||
text: t('common.discard'),
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: () => navigation.dispatch(e.data.action),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return unsubscribe;
|
|
||||||
}, [navigation, amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted]);
|
|
||||||
|
|
||||||
// === 草稿预填模式 ===
|
|
||||||
useEffect(() => {
|
|
||||||
if (draftJson) {
|
|
||||||
try {
|
|
||||||
const draft = JSON.parse(draftJson);
|
|
||||||
setDate(draft.date ? draft.date.slice(0, 10) : today);
|
|
||||||
setNarration(draft.narration || '');
|
|
||||||
setPayee(draft.payee || '');
|
|
||||||
|
|
||||||
const expensePosting = draft.postings.find((p: any) => p.account.startsWith('Expenses'));
|
|
||||||
const incomePosting = draft.postings.find((p: any) => p.account.startsWith('Income'));
|
|
||||||
const assetsPostings = draft.postings.filter((p: any) => p.account.startsWith('Assets') || p.account.startsWith('Liabilities'));
|
|
||||||
|
|
||||||
if (expensePosting) {
|
|
||||||
setDirection('expense');
|
|
||||||
setCurrency(expensePosting.currency || 'CNY');
|
|
||||||
setAmount((expensePosting.amount || '').replace(/^-/, ''));
|
|
||||||
const matchedCat = categories.find(c => c.linkedAccount === expensePosting.account);
|
|
||||||
if (matchedCat) setSelectedCategory(matchedCat);
|
|
||||||
if (assetsPostings.length > 0) {
|
|
||||||
setSourceAccount(assetsPostings[0].account);
|
|
||||||
}
|
|
||||||
} else if (incomePosting) {
|
|
||||||
setDirection('income');
|
|
||||||
setCurrency(incomePosting.currency || 'CNY');
|
|
||||||
setAmount((incomePosting.amount || '').replace(/^-/, ''));
|
|
||||||
const matchedCat = categories.find(c => c.linkedAccount === incomePosting.account);
|
|
||||||
if (matchedCat) setSelectedCategory(matchedCat);
|
|
||||||
if (assetsPostings.length > 0) {
|
|
||||||
setSourceAccount(assetsPostings[0].account);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setDirection('transfer');
|
|
||||||
if (draft.postings.length >= 2) {
|
|
||||||
setCurrency(draft.postings[0].currency || 'CNY');
|
|
||||||
setAmount((draft.postings[0].amount || '').replace(/^-/, ''));
|
|
||||||
setSourceAccount(draft.postings[0].account);
|
|
||||||
setTargetAccount(draft.postings[1].account);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (draft.postings && draft.postings.length > 0) {
|
|
||||||
setPostings(draft.postings.map((p: any) => ({
|
|
||||||
account: p.account,
|
|
||||||
amount: p.amount,
|
|
||||||
currency: p.currency,
|
|
||||||
cost: p.cost,
|
|
||||||
price: p.price,
|
|
||||||
metadata: p.metadata,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse draftJson', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [draftJson, categories]);
|
|
||||||
|
|
||||||
// === 编辑模式:预填表单 ===
|
|
||||||
const editingTx = useMemo(
|
|
||||||
() => editId ? ledger?.transactions.find(txn => txn.id === editId) : undefined,
|
|
||||||
[editId, ledger],
|
|
||||||
);
|
|
||||||
const [oldRaw, setOldRaw] = useState('');
|
|
||||||
const [editFlag, setEditFlag] = useState<string>('*');
|
|
||||||
const [editLinks, setEditLinks] = useState<string[]>([]);
|
|
||||||
const [editMetadata, setEditMetadata] = useState<Record<string, string> | undefined>(undefined);
|
|
||||||
useEffect(() => {
|
|
||||||
if (editingTx) {
|
|
||||||
setOldRaw(editingTx.raw);
|
|
||||||
setDate(editingTx.date.slice(0, 10));
|
|
||||||
setNarration(editingTx.narration || '');
|
|
||||||
setPayee(editingTx.payee || '');
|
|
||||||
setCurrency(editingTx.postings[0]?.currency || 'CNY');
|
|
||||||
// 保留编辑前的 flag/links/metadata
|
|
||||||
setEditFlag(editingTx.flag || '*');
|
|
||||||
setEditLinks(editingTx.links || []);
|
|
||||||
setEditMetadata(editingTx.metadata);
|
|
||||||
// 从 postings 推断方向和金额
|
|
||||||
const expensePosting = editingTx.postings.find(p => p.account.startsWith('Expenses'));
|
|
||||||
const incomePosting = editingTx.postings.find(p => p.account.startsWith('Income'));
|
|
||||||
if (expensePosting) {
|
|
||||||
setDirection('expense');
|
|
||||||
setAmount(expensePosting.amount?.replace(/^-/, '') || '');
|
|
||||||
setSourceAccount(editingTx.postings.find(p => p.account.startsWith('Assets'))?.account || 'Assets:Alipay');
|
|
||||||
// 预填分类:匹配 Expenses 账户对应的 category
|
|
||||||
const matchedCat = categories.find(c => c.linkedAccount === expensePosting.account);
|
|
||||||
if (matchedCat) setSelectedCategory(matchedCat);
|
|
||||||
} else if (incomePosting) {
|
|
||||||
setDirection('income');
|
|
||||||
setAmount(incomePosting.amount?.replace(/^-/, '') || '');
|
|
||||||
setSourceAccount(editingTx.postings.find(p => p.account.startsWith('Assets'))?.account || 'Assets:Alipay');
|
|
||||||
// 预填分类
|
|
||||||
const matchedCat = categories.find(c => c.linkedAccount === incomePosting.account);
|
|
||||||
if (matchedCat) setSelectedCategory(matchedCat);
|
|
||||||
} else {
|
|
||||||
setDirection('transfer');
|
|
||||||
setAmount(editingTx.postings[0]?.amount?.replace(/^-/, '') || '');
|
|
||||||
setSourceAccount(editingTx.postings[0]?.account || '');
|
|
||||||
setTargetAccount(editingTx.postings[1]?.account || '');
|
|
||||||
}
|
|
||||||
// 预填 postings(高级模式,含 cost/price/metadata)
|
|
||||||
setPostings(editingTx.postings.map(p => ({
|
|
||||||
account: p.account,
|
|
||||||
amount: p.amount,
|
|
||||||
currency: p.currency,
|
|
||||||
cost: p.cost,
|
|
||||||
price: p.price,
|
|
||||||
metadata: p.metadata,
|
|
||||||
})));
|
|
||||||
// 预填标签
|
|
||||||
if (editingTx.tags.length > 0) {
|
|
||||||
setSelectedTags(editingTx.tags.map(name => ({ id: name, name, color: '#2196F3' })));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [editingTx, categories]);
|
|
||||||
|
|
||||||
// 按方向过滤分类
|
|
||||||
const availableCategories = useMemo(
|
|
||||||
() => categories.filter(c => c.type === (direction === 'income' ? 'income' : 'expense')),
|
|
||||||
[categories, direction],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 来源账户列表(从 ledger 提取 Assets 账户)
|
|
||||||
const assetAccounts = useMemo(() => {
|
|
||||||
if (!ledger) return ['Assets:支付宝余额', 'Assets:微信零钱', 'Assets:兴业银行储蓄卡-2586'];
|
|
||||||
const accts = Array.from(ledger.accounts.keys()).filter(a => a.startsWith('Assets'));
|
|
||||||
return accts.length > 0 ? accts : ['Assets:支付宝余额'];
|
|
||||||
}, [ledger]);
|
|
||||||
|
|
||||||
const toggleTag = (tag: Tag) => {
|
|
||||||
setSelectedTags(prev =>
|
|
||||||
prev.some(t => t.name === tag.name)
|
|
||||||
? prev.filter(t => t.name !== tag.name)
|
|
||||||
: [...prev, tag],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// === OCR 拍照识账 ===
|
|
||||||
const handleOcrScan = async () => {
|
|
||||||
try {
|
|
||||||
// 1. 选图(相册或拍照)
|
|
||||||
const result = await ImagePicker.launchImageLibraryAsync({
|
|
||||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
||||||
allowsEditing: false,
|
|
||||||
quality: 0.8,
|
|
||||||
base64: true,
|
|
||||||
});
|
|
||||||
if (result.canceled || !result.assets?.[0]?.base64) return;
|
|
||||||
|
|
||||||
const base64 = result.assets[0].base64;
|
|
||||||
|
|
||||||
// 2. 检查 OCR 引擎是否就绪
|
|
||||||
const nativeModule = getNativeOcrModule();
|
|
||||||
if (nativeModule) {
|
|
||||||
const ready = await nativeModule.isReady();
|
|
||||||
if (!ready) {
|
|
||||||
Alert.alert(t('ocr.notReady'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(t('ocr.scanning'));
|
|
||||||
|
|
||||||
// 3. 走 OcrProcessor 管线 (Layer1 regex → Layer2 → Layer3 AI)
|
|
||||||
const ocrBridge = getNativeOcrBridge();
|
|
||||||
const processor = new OcrProcessor(ocrBridge);
|
|
||||||
const ocrResult = await processor.process(base64);
|
|
||||||
|
|
||||||
if (ocrResult.event) {
|
|
||||||
// 4. 预填表单
|
|
||||||
const event = ocrResult.event;
|
|
||||||
if (event.amount) {
|
|
||||||
const absAmount = event.amount.replace(/^-/, '');
|
|
||||||
setAmount(absAmount);
|
|
||||||
}
|
|
||||||
if (event.counterparty || event.memo) {
|
|
||||||
setNarration([event.counterparty, event.memo].filter(Boolean).join(' - '));
|
|
||||||
}
|
|
||||||
if (event.direction === 'income') {
|
|
||||||
setDirection('income');
|
|
||||||
} else if (event.direction === 'transfer') {
|
|
||||||
setDirection('transfer');
|
|
||||||
} else {
|
|
||||||
setDirection('expense');
|
|
||||||
}
|
|
||||||
// 5. 自动分类:根据商户名/备注匹配分类(与 BillPipeline 分类逻辑一致)
|
|
||||||
const matchText = [event.counterparty, event.memo].filter(Boolean).join(' ');
|
|
||||||
if (matchText) {
|
|
||||||
const type = event.direction === 'income' ? 'income' : 'expense';
|
|
||||||
const candidates = categories.filter(c => c.type === type);
|
|
||||||
const match = matchCategory(matchText, candidates);
|
|
||||||
if (match.category) {
|
|
||||||
setSelectedCategory(match.category);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setStatus(t('ocr.success'));
|
|
||||||
} else {
|
|
||||||
setStatus(t('ocr.failed'));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setStatus(t('ocr.failed') + ': ' + (e instanceof Error ? e.message : String(e)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 从 SpeedDial OCR 入口进入时自动触发
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode === 'ocr') {
|
|
||||||
handleOcrScan();
|
|
||||||
}
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
const tagNames = selectedTags.map(tg => tg.name);
|
|
||||||
|
|
||||||
if (isAdvanced) {
|
|
||||||
// 高级模式:校验多 postings
|
|
||||||
const validPostings = postings.filter(p => p.account.trim());
|
|
||||||
if (validPostings.length < 2) {
|
|
||||||
setStatus(t('transaction.atLeast2Postings'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实时借贷平衡验证
|
|
||||||
const currencySum: Record<string, number> = {};
|
|
||||||
for (const p of validPostings) {
|
|
||||||
if (p.amount && p.currency) {
|
|
||||||
const val = parseFloat(p.amount);
|
|
||||||
if (!isNaN(val)) {
|
|
||||||
currencySum[p.currency] = (currencySum[p.currency] || 0) + val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isBalanced = Object.values(currencySum).every(sum => Math.abs(sum) < 0.001);
|
|
||||||
if (!isBalanced) {
|
|
||||||
setStatus(t('transaction.unbalanced'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const draft = {
|
|
||||||
date,
|
|
||||||
flag: editId ? editFlag : undefined,
|
|
||||||
narration: narration.trim() || t('transaction.defaultAdvancedNarration'),
|
|
||||||
postings: validPostings,
|
|
||||||
tags: tagNames,
|
|
||||||
links: editId && editLinks.length > 0 ? editLinks : undefined,
|
|
||||||
metadata: editId ? editMetadata : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editId && oldRaw) {
|
|
||||||
// 编辑模式:序列化新内容并替换旧块
|
|
||||||
const newRaw = serializeTransaction(draft);
|
|
||||||
editTransaction(oldRaw, newRaw).then(() => {
|
|
||||||
setIsSubmitted(true);
|
|
||||||
setStatus(t('transaction.editSuccess'));
|
|
||||||
setTimeout(() => router.back(), 600);
|
|
||||||
}).catch(e => setStatus(String(e)));
|
|
||||||
} else {
|
|
||||||
addTransaction(draft).then(() => {
|
|
||||||
setIsSubmitted(true);
|
|
||||||
setStatus(t('transaction.appended'));
|
|
||||||
setNarration('');
|
|
||||||
setSelectedTags([]);
|
|
||||||
setTimeout(() => router.back(), 600);
|
|
||||||
}).catch(e => setStatus(String(e)));
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// 简单模式:调用统一的 buildAndSaveTransaction 构建复式 postings 并保存
|
|
||||||
const amt = amount.trim();
|
|
||||||
if (!amt) {
|
|
||||||
setStatus(t('transaction.amountRequired'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (direction === 'transfer' && sourceAccount === targetAccount) {
|
|
||||||
setStatus(t('transaction.sameAccountError'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
|
|
||||||
|
|
||||||
if (editId && oldRaw) {
|
|
||||||
// 编辑模式:由于编辑模式需要保留原始元数据并替换原始代码块,我们仍然就地拼装 draft 并执行 editTransaction
|
|
||||||
let finalPostings: Posting[];
|
|
||||||
if (direction === 'expense') {
|
|
||||||
finalPostings = [
|
|
||||||
{ account: sourceAccount, amount: `-${amt}`, currency },
|
|
||||||
{ account: categoryAccount, amount: amt, currency },
|
|
||||||
];
|
|
||||||
} else if (direction === 'income') {
|
|
||||||
finalPostings = [
|
|
||||||
{ account: sourceAccount, amount: amt, currency },
|
|
||||||
{ account: categoryAccount, amount: `-${amt}`, currency },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
finalPostings = [
|
|
||||||
{ account: sourceAccount, amount: `-${amt}`, currency },
|
|
||||||
{ account: targetAccount, amount: amt, currency },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const draft = {
|
|
||||||
date,
|
|
||||||
payee: payee.trim() || undefined,
|
|
||||||
flag: editFlag,
|
|
||||||
narration: narration.trim() || (
|
|
||||||
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
|
|
||||||
direction === 'income' ? t('transaction.narrationDefaultIncome') :
|
|
||||||
t('transaction.narrationDefaultTransfer')
|
|
||||||
),
|
|
||||||
postings: finalPostings,
|
|
||||||
tags: tagNames,
|
|
||||||
links: editLinks.length > 0 ? editLinks : undefined,
|
|
||||||
metadata: editMetadata,
|
|
||||||
};
|
|
||||||
|
|
||||||
const newRaw = serializeTransaction(draft);
|
|
||||||
editTransaction(oldRaw, newRaw).then(() => {
|
|
||||||
setIsSubmitted(true);
|
|
||||||
setStatus(t('transaction.editSuccess'));
|
|
||||||
setTimeout(() => router.back(), 600);
|
|
||||||
}).catch(e => setStatus(String(e)));
|
|
||||||
} else {
|
|
||||||
// 新建交易:直接调用统一的共享逻辑函数
|
|
||||||
buildAndSaveTransaction({
|
|
||||||
date,
|
|
||||||
amount: amt,
|
|
||||||
payee: payee.trim() || undefined,
|
|
||||||
narration: narration.trim() || (
|
|
||||||
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
|
|
||||||
direction === 'income' ? t('transaction.narrationDefaultIncome') :
|
|
||||||
t('transaction.narrationDefaultTransfer')
|
|
||||||
),
|
|
||||||
categoryAccount: direction === 'transfer' ? targetAccount : categoryAccount,
|
|
||||||
sourceAccount,
|
|
||||||
direction,
|
|
||||||
currency,
|
|
||||||
tags: tagNames,
|
|
||||||
links: editLinks.length > 0 ? editLinks : undefined,
|
|
||||||
metadata: editMetadata,
|
|
||||||
}).then(() => {
|
|
||||||
setIsSubmitted(true);
|
|
||||||
setStatus(t('transaction.appended'));
|
|
||||||
setAmount('');
|
|
||||||
setNarration('');
|
|
||||||
setSelectedCategory(null);
|
|
||||||
setSelectedTags([]);
|
|
||||||
setTimeout(() => router.back(), 600);
|
|
||||||
}).catch(e => setStatus(String(e)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const directions: { key: Direction; label: string; color: string }[] = [
|
|
||||||
{ key: 'expense', label: t('transaction.directionExpense'), color: theme.colors.financial.expense },
|
|
||||||
{ key: 'income', label: t('transaction.directionIncome'), color: theme.colors.financial.income },
|
|
||||||
{ key: 'transfer', label: t('transaction.directionTransfer'), color: theme.colors.financial.transfer },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['bottom', 'left', 'right']}>
|
|
||||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
|
||||||
{/* 高级模式模式切换开关 */}
|
|
||||||
<View style={commonStyles.switchRow}>
|
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700', fontFamily: theme.typography.body.fontFamily }]}>
|
|
||||||
{t('transaction.advancedMode')}
|
|
||||||
</Text>
|
|
||||||
<Switch
|
|
||||||
value={isAdvanced}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
setIsAdvanced(val);
|
|
||||||
// 同步初始化当前分录
|
|
||||||
if (val) {
|
|
||||||
const amt = amount.trim();
|
|
||||||
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
|
|
||||||
if (direction === 'expense') {
|
|
||||||
setPostings([
|
|
||||||
{ account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
|
|
||||||
{ account: categoryAccount, amount: amt || '', currency: 'CNY' },
|
|
||||||
]);
|
|
||||||
} else if (direction === 'income') {
|
|
||||||
setPostings([
|
|
||||||
{ account: sourceAccount, amount: amt || '', currency: 'CNY' },
|
|
||||||
{ account: categoryAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
setPostings([
|
|
||||||
{ account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
|
|
||||||
{ account: targetAccount, amount: amt || '', currency: 'CNY' },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
trackColor={{ false: theme.colors.bgPrimary, true: theme.colors.accent }}
|
|
||||||
thumbColor={theme.colors.fgInverse}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 简单模式下的方向选择 */}
|
|
||||||
{!isAdvanced && (
|
|
||||||
<View style={styles.directionRow}>
|
|
||||||
{directions.map(d => (
|
|
||||||
<Pressable
|
|
||||||
key={d.key}
|
|
||||||
onPress={() => { setDirection(d.key); setSelectedCategory(null); }}
|
|
||||||
style={[
|
|
||||||
commonStyles.chip,
|
|
||||||
direction === d.key && commonStyles.chipActive,
|
|
||||||
{ flex: 1, alignItems: 'center' }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
commonStyles.chipText,
|
|
||||||
direction === d.key && commonStyles.chipTextActive,
|
|
||||||
{ fontFamily: theme.typography.caption.fontFamily }
|
|
||||||
]}>
|
|
||||||
{d.label}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
<Pressable
|
|
||||||
onPress={handleOcrScan}
|
|
||||||
style={[
|
|
||||||
commonStyles.chip,
|
|
||||||
{ alignItems: 'center', minWidth: 60, borderColor: theme.colors.accent }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={[commonStyles.chipText, { color: theme.colors.accent, fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{t('home.ocrScan')}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 主输入区域:金额与账户 (简单模式) 或 分录编辑器 (高级模式) */}
|
|
||||||
{!isAdvanced ? (
|
|
||||||
<Card title={t('transaction.newTitle')}>
|
|
||||||
{/* 金额 */}
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{t('transaction.amountPlaceholder')}
|
|
||||||
</Text>
|
|
||||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
||||||
<TextInput
|
|
||||||
value={amount}
|
|
||||||
onChangeText={setAmount}
|
|
||||||
autoFocus={editId ? false : true}
|
|
||||||
keyboardType="decimal-pad"
|
|
||||||
placeholder={t('transaction.amountPlaceholder')}
|
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
|
||||||
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace', fontSize: 16 }]}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
value={currency}
|
|
||||||
onChangeText={setCurrency}
|
|
||||||
placeholder="CNY"
|
|
||||||
maxLength={3}
|
|
||||||
autoCapitalize="characters"
|
|
||||||
style={[commonStyles.input, { width: 70, textAlign: 'center' }]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 来源账户 */}
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{t('transaction.formSourceAccount')}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={sourceAccount}
|
|
||||||
onChangeText={setSourceAccount}
|
|
||||||
placeholder="Assets:支付宝余额"
|
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
|
||||||
style={commonStyles.input}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 快捷来源账户选择 */}
|
|
||||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
|
||||||
{assetAccounts.map(acct => {
|
|
||||||
const active = sourceAccount === acct;
|
|
||||||
const shortName = acct.split(':').pop() || acct;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={`src-${acct}`}
|
|
||||||
onPress={() => setSourceAccount(acct)}
|
|
||||||
style={[
|
|
||||||
commonStyles.chip,
|
|
||||||
active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent },
|
|
||||||
{ paddingVertical: 4, paddingHorizontal: 8 }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
commonStyles.chipText,
|
|
||||||
{ fontSize: 11 },
|
|
||||||
active && { color: theme.colors.accent, fontWeight: '700' }
|
|
||||||
]}>
|
|
||||||
{shortName}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 目标账户 (仅在转账时显示) */}
|
|
||||||
{direction === 'transfer' && (
|
|
||||||
<View style={{ marginTop: 10 }}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{t('transaction.targetAccount')}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={targetAccount}
|
|
||||||
onChangeText={setTargetAccount}
|
|
||||||
placeholder="Assets:WeChat"
|
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
|
||||||
style={commonStyles.input}
|
|
||||||
/>
|
|
||||||
{/* 快捷目标账户选择 */}
|
|
||||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
|
||||||
{assetAccounts.map(acct => {
|
|
||||||
const active = targetAccount === acct;
|
|
||||||
const shortName = acct.split(':').pop() || acct;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={`tgt-${acct}`}
|
|
||||||
onPress={() => setTargetAccount(acct)}
|
|
||||||
style={[
|
|
||||||
commonStyles.chip,
|
|
||||||
active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent },
|
|
||||||
{ paddingVertical: 4, paddingHorizontal: 8 }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
commonStyles.chipText,
|
|
||||||
{ fontSize: 11 },
|
|
||||||
active && { color: theme.colors.accent, fontWeight: '700' }
|
|
||||||
]}>
|
|
||||||
{shortName}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card title={t('transaction.newTitle')}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{t('transaction.postingsList')}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<PostingEditor
|
|
||||||
postings={postings}
|
|
||||||
onChange={(p) => setPostings(p)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View style={styles.postingBtnRow}>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Button
|
|
||||||
label={t('transaction.addPosting')}
|
|
||||||
onPress={() => setPostings([...postings, { account: '', amount: '', currency: 'CNY' }])}
|
|
||||||
variant="secondary"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Button
|
|
||||||
label={t('transaction.deleteLastPosting')}
|
|
||||||
onPress={() => postings.length > 2 && setPostings(postings.slice(0, -1))}
|
|
||||||
variant="secondary"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 简单模式下的分类选择 */}
|
|
||||||
{!isAdvanced && direction !== 'transfer' && (
|
|
||||||
<Card title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
|
|
||||||
<CategoryPicker
|
|
||||||
categories={availableCategories}
|
|
||||||
selectedId={selectedCategory?.id}
|
|
||||||
onSelect={(cat) => setSelectedCategory(cat)}
|
|
||||||
/>
|
|
||||||
{selectedCategory && (
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 8 }}>
|
|
||||||
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{selectedCategory.linkedAccount}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 详情与高级字段收纳区 */}
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
LayoutAnimation.easeInEaseOut();
|
|
||||||
setShowDetails(!showDetails);
|
|
||||||
}}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.detailsToggle,
|
|
||||||
{
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
backgroundColor: theme.colors.bgSecondary,
|
|
||||||
opacity: pressed ? 0.8 : 1,
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 6
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontFamily: theme.typography.bodySmall.fontFamily }]}>
|
|
||||||
{showDetails ? t('transaction.hideDetails') : t('transaction.addDetails')}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name={showDetails ? 'chevron-up-outline' : 'chevron-down-outline'}
|
|
||||||
size={16}
|
|
||||||
color={theme.colors.accent}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{showDetails && (
|
|
||||||
<View style={{ gap: theme.spacing.md }}>
|
|
||||||
<Card title={t('transaction.extraDetails')}>
|
|
||||||
{/* 日期 */}
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{t('transaction.formDate')}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={date}
|
|
||||||
onChangeText={setDate}
|
|
||||||
placeholder="YYYY-MM-DD"
|
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
|
||||||
style={commonStyles.input}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 摘要 */}
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{t('transaction.formNarration')}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={narration}
|
|
||||||
onChangeText={setNarration}
|
|
||||||
placeholder={t('transaction.formNarration')}
|
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
|
||||||
style={commonStyles.input}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 收款方(简单模式才显示) */}
|
|
||||||
{!isAdvanced && (
|
|
||||||
<>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{t('transaction.formPayee')}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={payee}
|
|
||||||
onChangeText={setPayee}
|
|
||||||
placeholder={t('transaction.formPayeePlaceholder')}
|
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
|
||||||
style={commonStyles.input}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 标签选择 */}
|
|
||||||
<Card title={t('transaction.formTags')}>
|
|
||||||
<TagPicker
|
|
||||||
tags={tags}
|
|
||||||
selectedNames={selectedTags.map(tg => tg.name)}
|
|
||||||
onToggle={toggleTag}
|
|
||||||
/>
|
|
||||||
{selectedTags.length > 0 && (
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, fontFamily: theme.typography.caption.fontFamily }]}>
|
|
||||||
{t('transaction.formTagsWillWrite')}: {tagsToBeanSyntax(selectedTags)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button label={t('transaction.submit')} onPress={submit} />
|
|
||||||
{status ? <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8, fontFamily: theme.typography.bodySmall.fontFamily }]}>{status}</Text> : null}
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
page: { flex: 1 },
|
|
||||||
content: { padding: 16, paddingBottom: 40 },
|
|
||||||
directionRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
|
|
||||||
postingBtnRow: { flexDirection: 'row', gap: 8, marginTop: 8 },
|
|
||||||
detailsToggle: { paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center', marginTop: 4 },
|
|
||||||
});
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
||||||
import { useTheme } from '../theme';
|
|
||||||
import type { Category } from '../domain/categories';
|
|
||||||
|
|
||||||
interface CategoryPickerProps {
|
|
||||||
categories: Category[];
|
|
||||||
selectedId?: string;
|
|
||||||
onSelect: (cat: Category) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CATEGORY_ICONS: Record<string, string> = {
|
|
||||||
food: '🍔',
|
|
||||||
transport: '🚗',
|
|
||||||
shopping: '🛍️',
|
|
||||||
housing_utility: '💧',
|
|
||||||
housing_rent: '🏠',
|
|
||||||
housing_communication: '📞',
|
|
||||||
entertainment: '🎮',
|
|
||||||
services: '⚙️',
|
|
||||||
personal_care: '💅',
|
|
||||||
clothing: '👕',
|
|
||||||
health: '🏥',
|
|
||||||
learning: '📚',
|
|
||||||
salary: '💰',
|
|
||||||
income_activity: '🧧',
|
|
||||||
income_investment: '📈',
|
|
||||||
fallback: '🏷️',
|
|
||||||
};
|
|
||||||
|
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
|
||||||
food: '#F59E0B', // Amber
|
|
||||||
transport: '#3B82F6', // Blue
|
|
||||||
shopping: '#EC4899', // Pink
|
|
||||||
housing_utility: '#06B6D4',// Cyan
|
|
||||||
housing_rent: '#6366F1', // Indigo
|
|
||||||
housing_communication: '#8B5CF6', // Purple
|
|
||||||
entertainment: '#10B981', // Emerald
|
|
||||||
services: '#64748B', // Slate
|
|
||||||
personal_care: '#F43F5E', // Rose
|
|
||||||
clothing: '#14B8A6', // Teal
|
|
||||||
health: '#EF4444', // Red
|
|
||||||
learning: '#84CC16', // Lime
|
|
||||||
salary: '#22C55E', // Green
|
|
||||||
income_activity: '#EF4444',// Red/Orange
|
|
||||||
income_investment: '#F59E0B', // Amber
|
|
||||||
fallback: '#64748B',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 分类选择器(网格卡片化布局)。 */
|
|
||||||
export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPickerProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.grid}>
|
|
||||||
{categories.map(cat => {
|
|
||||||
const active = cat.id === selectedId;
|
|
||||||
const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback;
|
|
||||||
const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={cat.id}
|
|
||||||
onPress={() => onSelect(cat)}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.item,
|
|
||||||
{
|
|
||||||
backgroundColor: active ? color + '15' : theme.colors.bgTertiary,
|
|
||||||
borderColor: active ? color : theme.colors.border,
|
|
||||||
opacity: pressed ? 0.75 : 1,
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={[styles.icon, { color }]}>{icon}</Text>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.label,
|
|
||||||
{
|
|
||||||
color: active ? theme.colors.fgPrimary : theme.colors.fgSecondary,
|
|
||||||
fontFamily: theme.typography.caption.fontFamily,
|
|
||||||
fontWeight: active ? '700' : '500',
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{cat.name}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
grid: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 8,
|
|
||||||
paddingVertical: 4,
|
|
||||||
},
|
|
||||||
item: {
|
|
||||||
width: '23%', // 约 4 列排布
|
|
||||||
aspectRatio: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
gap: 6,
|
|
||||||
padding: 4,
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
fontSize: 22,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
/**
|
|
||||||
* 通用模态表单(plan.md 管理页 CRUD)。
|
|
||||||
*
|
|
||||||
* 底部弹出的模态对话框,包含多个文本输入字段 + 确认/取消按钮。
|
|
||||||
* 供分类/标签/预算/信用卡/规则管理页的「添加/编辑」复用。
|
|
||||||
*
|
|
||||||
* 用法:
|
|
||||||
* <FormModal
|
|
||||||
* visible={true}
|
|
||||||
* title="添加分类"
|
|
||||||
* fields={[
|
|
||||||
* { key: 'name', label: '名称', placeholder: '餐饮' },
|
|
||||||
* { key: 'linkedAccount', label: '关联账户', placeholder: 'Expenses:餐饮' },
|
|
||||||
* ]}
|
|
||||||
* initialValues={{ name: '', linkedAccount: '' }}
|
|
||||||
* onConfirm={(values) => { /* 保存 *\/ }}
|
|
||||||
* onCancel={() => { /* 关闭 *\/ }}
|
|
||||||
* />
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
||||||
import { useTheme, createCommonStyles } from '../theme';
|
|
||||||
import { useT } from '../i18n';
|
|
||||||
import { Button } from './Button';
|
|
||||||
|
|
||||||
export interface FormField {
|
|
||||||
/** 字段 key,对应 values 对象的属性名。 */
|
|
||||||
key: string;
|
|
||||||
/** 显示标签。 */
|
|
||||||
label: string;
|
|
||||||
/** 占位提示。 */
|
|
||||||
placeholder?: string;
|
|
||||||
/** 默认值(新增时为空,编辑时预填)。 */
|
|
||||||
defaultValue?: string;
|
|
||||||
/** 键盘类型。 */
|
|
||||||
keyboardType?: 'default' | 'numeric' | 'decimal-pad' | 'phone-pad';
|
|
||||||
/** 多行输入。 */
|
|
||||||
multiline?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormModalProps {
|
|
||||||
visible: boolean;
|
|
||||||
title: string;
|
|
||||||
fields: FormField[];
|
|
||||||
onConfirm: (values: Record<string, string>) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
/** 确认按钮文本(默认「确定」)。 */
|
|
||||||
confirmLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel }: FormModalProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const t = useT();
|
|
||||||
const commonStyles = createCommonStyles(theme);
|
|
||||||
const [values, setValues] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
// 每次 visible 变为 true 时,用 fields 的 defaultValue 初始化
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
const init: Record<string, string> = {};
|
|
||||||
for (const f of fields) {
|
|
||||||
init[f.key] = f.defaultValue ?? '';
|
|
||||||
}
|
|
||||||
setValues(init);
|
|
||||||
}
|
|
||||||
}, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
|
|
||||||
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onCancel}>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.sheet, {
|
|
||||||
backgroundColor: theme.colors.bgSecondary,
|
|
||||||
borderRadius: theme.radii.lg,
|
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
}]}
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<View style={[styles.header, { borderBottomColor: theme.colors.divider }]}>
|
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{title}</Text>
|
|
||||||
<Pressable onPress={onCancel} hitSlop={8}>
|
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>✕</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView style={styles.body}>
|
|
||||||
{fields.map(f => (
|
|
||||||
<View key={f.key} style={styles.field}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4 }]}>
|
|
||||||
{f.label}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={values[f.key] ?? ''}
|
|
||||||
onChangeText={(text) => setValues(prev => ({ ...prev, [f.key]: text }))}
|
|
||||||
placeholder={f.placeholder}
|
|
||||||
placeholderTextColor={theme.colors.fgSecondary}
|
|
||||||
keyboardType={f.keyboardType ?? 'default'}
|
|
||||||
multiline={f.multiline}
|
|
||||||
style={commonStyles.input}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<View style={styles.actions}>
|
|
||||||
<Button label={t('common.cancel')} onPress={onCancel} variant="secondary" />
|
|
||||||
<View style={{ flex: 1 }} />
|
|
||||||
<Button label={confirmLabel ?? t('common.confirm')} onPress={() => onConfirm(values)} />
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
overlay: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
},
|
|
||||||
sheet: {
|
|
||||||
maxHeight: '80%',
|
|
||||||
padding: 20,
|
|
||||||
paddingBottom: 36,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingBottom: 12,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
maxHeight: 400,
|
|
||||||
},
|
|
||||||
field: {
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { useTheme } from '../theme';
|
|
||||||
import { useT } from '../i18n';
|
|
||||||
|
|
||||||
/** 快捷操作项。 */
|
|
||||||
export interface SpeedDialAction {
|
|
||||||
key: string;
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
label: string;
|
|
||||||
onPress: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeedDialProps {
|
|
||||||
/** 展开式快捷操作列表。不传时保持单按钮行为(记一笔)。 */
|
|
||||||
actions?: SpeedDialAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 浮动操作按钮。
|
|
||||||
* - 无 actions:单个 "+" 按钮,点击跳转记账页
|
|
||||||
* - 有 actions:点击展开多个 mini-FAB 快捷操作(记一笔/拍照识账/导入等)
|
|
||||||
*/
|
|
||||||
export function SpeedDial({ actions }: SpeedDialProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const t = useT();
|
|
||||||
const router = useRouter();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [spin] = useState(new Animated.Value(0));
|
|
||||||
|
|
||||||
const hasActions = actions && actions.length > 0;
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
const next = !open;
|
|
||||||
setOpen(next);
|
|
||||||
Animated.spring(spin, {
|
|
||||||
toValue: next ? 1 : 0,
|
|
||||||
useNativeDriver: true,
|
|
||||||
tension: 80,
|
|
||||||
friction: 8,
|
|
||||||
}).start();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAction = (action: SpeedDialAction) => {
|
|
||||||
setOpen(false);
|
|
||||||
Animated.spring(spin, {
|
|
||||||
toValue: 0,
|
|
||||||
useNativeDriver: true,
|
|
||||||
tension: 80,
|
|
||||||
friction: 8,
|
|
||||||
}).start();
|
|
||||||
action.onPress();
|
|
||||||
};
|
|
||||||
|
|
||||||
const mainRotation = spin.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: ['0deg', '45deg'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const mainIcon = hasActions ? (
|
|
||||||
<Animated.View style={{ transform: [{ rotate: mainRotation }] }}>
|
|
||||||
<Ionicons name="add" size={28} color={theme.colors.fgInverse} />
|
|
||||||
</Animated.View>
|
|
||||||
) : (
|
|
||||||
<Ionicons name="add" size={28} color={theme.colors.fgInverse} />
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 遮罩层:展开时点击空白收起 */}
|
|
||||||
{hasActions && open && (
|
|
||||||
<Pressable
|
|
||||||
onPress={toggle}
|
|
||||||
style={[styles.overlay, { backgroundColor: theme.colors.overlay }]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 展开的快捷操作列表 */}
|
|
||||||
{hasActions && (
|
|
||||||
<View style={styles.actionsContainer} pointerEvents={open ? 'auto' : 'none'}>
|
|
||||||
{actions!.map((action, i) => {
|
|
||||||
const offset = 72 + 56 * (actions!.length - 1 - i);
|
|
||||||
const translateY = spin.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [0, -offset],
|
|
||||||
});
|
|
||||||
const opacity = spin.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [0, 1],
|
|
||||||
});
|
|
||||||
const scale = spin.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [0.5, 1],
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<Animated.View
|
|
||||||
key={action.key}
|
|
||||||
style={[
|
|
||||||
styles.actionRow,
|
|
||||||
{ transform: [{ translateY }, { scale }], opacity },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={[styles.actionLabel, { backgroundColor: theme.colors.bgSecondary }]}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary }]}>
|
|
||||||
{action.label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => handleAction(action)}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={action.label}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.miniFab,
|
|
||||||
{
|
|
||||||
backgroundColor: theme.colors.bgSecondary,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
opacity: pressed ? 0.7 : 1,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Ionicons name={action.icon} size={20} color={theme.colors.accent} />
|
|
||||||
</Pressable>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 主按钮 */}
|
|
||||||
<Pressable
|
|
||||||
onPress={hasActions ? toggle : () => router.push('/transaction/new')}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.fab,
|
|
||||||
{
|
|
||||||
backgroundColor: theme.colors.accent,
|
|
||||||
opacity: pressed ? 0.85 : 1,
|
|
||||||
...theme.shadows.lg,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={t('home.newTransaction')}
|
|
||||||
accessibilityHint={hasActions ? t('speedDial.expandHint') : undefined}
|
|
||||||
>
|
|
||||||
{mainIcon}
|
|
||||||
</Pressable>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
overlay: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0, left: 0, right: 0, bottom: 0,
|
|
||||||
zIndex: 9998,
|
|
||||||
},
|
|
||||||
fab: {
|
|
||||||
position: 'absolute',
|
|
||||||
right: 20,
|
|
||||||
bottom: 20,
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 28,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
zIndex: 9999,
|
|
||||||
},
|
|
||||||
actionsContainer: {
|
|
||||||
position: 'absolute',
|
|
||||||
right: 28,
|
|
||||||
bottom: 28,
|
|
||||||
zIndex: 9999,
|
|
||||||
},
|
|
||||||
actionRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
actionLabel: {
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
paddingVertical: 4,
|
|
||||||
borderRadius: 6,
|
|
||||||
},
|
|
||||||
miniFab: {
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
||||||
import { useTheme } from '../theme';
|
|
||||||
import { useT } from '../i18n';
|
|
||||||
import type { Transaction } from '../domain/types';
|
|
||||||
|
|
||||||
interface TransactionCardProps {
|
|
||||||
transaction: Transaction;
|
|
||||||
onPress?: (t: Transaction) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 交易卡片(主题化,展示日期/摘要/金额)。 */
|
|
||||||
export function TransactionCard({ transaction, onPress }: TransactionCardProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const t = useT();
|
|
||||||
const isExpense = transaction.postings.some(p => p.account.startsWith('Expenses'));
|
|
||||||
const isIncome = transaction.postings.some(p => p.account.startsWith('Income'));
|
|
||||||
const color = isExpense ? theme.colors.financial.expense : isIncome ? theme.colors.financial.income : theme.colors.financial.transfer;
|
|
||||||
const primaryAmount = transaction.postings.find(p => p.amount)?.amount ?? '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`${transaction.narration || transaction.payee || ''} ${primaryAmount} ${transaction.date.slice(0, 10)}`}
|
|
||||||
onPress={() => onPress?.(transaction)}
|
|
||||||
style={({ pressed }) => [
|
|
||||||
styles.card,
|
|
||||||
{
|
|
||||||
backgroundColor: theme.colors.bgSecondary,
|
|
||||||
borderRadius: theme.radii.lg, // 升级为大圆角
|
|
||||||
padding: theme.spacing.md,
|
|
||||||
borderWidth: StyleSheet.hairlineWidth, // 添加极细透白边框
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
opacity: pressed ? 0.8 : 1,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.row}>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]} numberOfLines={1}>
|
|
||||||
{transaction.narration || transaction.payee || t('transaction.noSummary')}
|
|
||||||
</Text>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
|
|
||||||
{transaction.date.slice(0, 10)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={[theme.typography.body, { color, fontWeight: '700', fontFamily: 'monospace', fontSize: 15 }]}>
|
|
||||||
{primaryAmount}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{transaction.tags.length > 0 && (
|
|
||||||
<View style={styles.tags}>
|
|
||||||
{transaction.tags.map(tag => (
|
|
||||||
<Text key={tag} style={[styles.tag, { backgroundColor: theme.colors.accentLight, color: theme.colors.accent, fontWeight: '600' }]}>
|
|
||||||
#{tag}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
card: { marginBottom: 8 },
|
|
||||||
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
||||||
tags: { flexDirection: 'row', gap: 4, marginTop: 6, flexWrap: 'wrap' },
|
|
||||||
tag: { fontSize: 11, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, overflow: 'hidden' },
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
import { FormModal, type FormField } from '../form/FormModal';
|
||||||
|
|
||||||
|
export interface AccountCreateModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
defaultType?: string;
|
||||||
|
onConfirm: (values: Record<string, string>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一新建账户弹窗组件(全应用共享复用)。
|
||||||
|
* 包含一行 3 列同行等高布局:账户类型 Dropdown (1.2) + 账户名称 TextInput (2.0) + 币种 TextInput (0.9)。
|
||||||
|
*/
|
||||||
|
export function AccountCreateModal({ visible, defaultType = 'Assets', onConfirm, onCancel }: AccountCreateModalProps) {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
const fields: FormField[] = useMemo(() => [
|
||||||
|
// Row 1: 账户类型 Dropdown (flex: 1) + 币种 TextInput (flex: 1)
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
label: t('account.fieldType'),
|
||||||
|
type: 'dropdown',
|
||||||
|
options: [
|
||||||
|
{ label: t('account.rootTypes.Assets'), value: 'Assets' },
|
||||||
|
{ label: t('account.rootTypes.Liabilities'), value: 'Liabilities' },
|
||||||
|
{ label: t('account.rootTypes.Expenses'), value: 'Expenses' },
|
||||||
|
{ label: t('account.rootTypes.Income'), value: 'Income' },
|
||||||
|
{ label: t('account.rootTypes.Equity'), value: 'Equity' },
|
||||||
|
],
|
||||||
|
defaultValue: defaultType,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'currency',
|
||||||
|
label: t('account.fieldCurrency'),
|
||||||
|
placeholder: 'CNY',
|
||||||
|
defaultValue: 'CNY',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
// Row 2: 账户名称 TextInput (全宽大输入框)
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: t('account.fieldName'),
|
||||||
|
placeholder: t('account.fieldNamePlaceholder'),
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
], [t, defaultType]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
visible={visible}
|
||||||
|
title={t('account.addModalTitle')}
|
||||||
|
fields={fields}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { useTheme } from '../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { calculateAccountTotalBalance, type AccountNode } from '../domain/accountTree';
|
import { useT } from '../../i18n';
|
||||||
|
import { calculateAccountTotalBalance, type AccountNode } from '../../domain/taxonomy/accountTree';
|
||||||
|
|
||||||
interface AccountTreeProps {
|
interface AccountTreeProps {
|
||||||
nodes: AccountNode[];
|
nodes: AccountNode[];
|
||||||
@@ -11,7 +12,6 @@ interface AccountTreeProps {
|
|||||||
|
|
||||||
/** 账户树展示(主题化,缩进显示层级 + 余额)。 */
|
/** 账户树展示(主题化,缩进显示层级 + 余额)。 */
|
||||||
export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
|
export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
|
||||||
const { theme } = useTheme();
|
|
||||||
return (
|
return (
|
||||||
<View style={{ gap: 4 }}>
|
<View style={{ gap: 4 }}>
|
||||||
{nodes.map(node => (
|
{nodes.map(node => (
|
||||||
@@ -21,15 +21,25 @@ export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) {
|
const AccountTreeNode = React.memo(function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const total = calculateAccountTotalBalance(node);
|
const t = useT();
|
||||||
|
const total = useMemo(() => calculateAccountTotalBalance(node), [node]);
|
||||||
const isLeaf = node.children.length === 0;
|
const isLeaf = node.children.length === 0;
|
||||||
|
|
||||||
|
let displayName = node.name;
|
||||||
|
if (depth === 0) {
|
||||||
|
const localized = t(`account.rootTypes.${node.name as 'Assets' | 'Liabilities' | 'Income' | 'Expenses' | 'Equity'}`);
|
||||||
|
if (localized && !localized.startsWith('account.')) {
|
||||||
|
displayName = localized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<View style={[styles.row, { paddingLeft: depth * 16 }]}>
|
<View style={[styles.row, { paddingLeft: depth * 16 }]}>
|
||||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: depth === 0 ? '700' : '400' }]}>
|
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: depth === 0 ? '700' : '400' }]}>
|
||||||
{isLeaf ? '• ' : ''}{node.name}
|
{isLeaf ? '• ' : ''}{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
{total}
|
{total}
|
||||||
@@ -40,7 +50,7 @@ function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth:
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 4 },
|
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 4 },
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { getCategoryColor } from '../../theme/palette';
|
||||||
|
import { getCategoryIcon } from './categoryIcons';
|
||||||
|
|
||||||
|
interface CategoryIconProps {
|
||||||
|
categoryId: string;
|
||||||
|
/** 圆形底色直径,默认 36。 */
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分类图标:Ionicon + 分类色圆形浅底(设计系统 §图标:替换 emoji)。 */
|
||||||
|
export function CategoryIcon({ categoryId, size = 36 }: CategoryIconProps) {
|
||||||
|
const color = getCategoryColor(categoryId);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.circle, {
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size / 2,
|
||||||
|
backgroundColor: color + '1A',
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
<Ionicons name={getCategoryIcon(categoryId)} size={size * 0.55} color={color} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
circle: { alignItems: 'center', justifyContent: 'center' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useTheme } from '../../theme';
|
||||||
|
import { getCategoryColor } from '../../theme/palette';
|
||||||
|
import type { Category } from '../../domain/taxonomy/categories';
|
||||||
|
import { CategoryIcon } from './CategoryIcon';
|
||||||
|
import { Touchable } from '../ui/Touchable';
|
||||||
|
|
||||||
|
interface CategoryPickerProps {
|
||||||
|
categories: Category[];
|
||||||
|
selectedId?: string;
|
||||||
|
onSelect: (cat: Category) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分类选择器(网格卡片化布局)。 */
|
||||||
|
export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPickerProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{categories.map(cat => {
|
||||||
|
const active = cat.id === selectedId;
|
||||||
|
const color = getCategoryColor(cat.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Touchable
|
||||||
|
key={cat.id}
|
||||||
|
onPress={() => onSelect(cat)}
|
||||||
|
style={[
|
||||||
|
styles.item,
|
||||||
|
{
|
||||||
|
backgroundColor: active ? color + '15' : theme.colors.bgTertiary,
|
||||||
|
borderColor: active ? color : theme.colors.border,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<CategoryIcon categoryId={cat.id} size={32} />
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.label,
|
||||||
|
{
|
||||||
|
color: active ? theme.colors.fgPrimary : theme.colors.fgSecondary,
|
||||||
|
fontWeight: active ? '700' : '500',
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</Text>
|
||||||
|
</Touchable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
grid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
width: '23%', // 约 4 列排布
|
||||||
|
aspectRatio: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
gap: 6,
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { useTheme, createCommonStyles } from '../theme';
|
import { useTheme, createCommonStyles } from '../../theme';
|
||||||
import type { Tag } from '../domain/tags';
|
import type { Tag } from '../../domain/taxonomy/tags';
|
||||||
|
|
||||||
interface TagPickerProps {
|
interface TagPickerProps {
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
@@ -12,7 +12,7 @@ interface TagPickerProps {
|
|||||||
/** 标签选择器(主题化,多选 chip)。 */
|
/** 标签选择器(主题化,多选 chip)。 */
|
||||||
export function TagPicker({ tags, selectedNames, onToggle }: TagPickerProps) {
|
export function TagPicker({ tags, selectedNames, onToggle }: TagPickerProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const commonStyles = createCommonStyles(theme);
|
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 分类 id → Ionicons 图标名映射(替换原 CategoryPicker 的 emoji 表)。
|
||||||
|
* 纯数据模块:仅含类型导入(运行时零依赖),可在 Vitest(node) 中直接测试。
|
||||||
|
*/
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
import type { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
export type IoniconName = ComponentProps<typeof Ionicons>['name'];
|
||||||
|
|
||||||
|
export const CATEGORY_ICON_NAMES: Record<string, IoniconName> = {
|
||||||
|
food: 'fast-food-outline',
|
||||||
|
transport: 'bus-outline',
|
||||||
|
shopping: 'bag-handle-outline',
|
||||||
|
housing_utility: 'water-outline',
|
||||||
|
housing_rent: 'home-outline',
|
||||||
|
housing_communication: 'call-outline',
|
||||||
|
entertainment: 'game-controller-outline',
|
||||||
|
services: 'construct-outline',
|
||||||
|
personal_care: 'sparkles-outline',
|
||||||
|
clothing: 'shirt-outline',
|
||||||
|
health: 'medkit-outline',
|
||||||
|
learning: 'book-outline',
|
||||||
|
salary: 'wallet-outline',
|
||||||
|
income_activity: 'gift-outline',
|
||||||
|
income_investment: 'trending-up-outline',
|
||||||
|
// 方向兜底(无分类交易:TransactionCard 传 expense/income/transfer)
|
||||||
|
expense: 'cart-outline',
|
||||||
|
income: 'cash-outline',
|
||||||
|
transfer: 'swap-horizontal-outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_ICON: IoniconName = 'pricetag-outline';
|
||||||
|
|
||||||
|
/** 取分类图标:具名映射,否则 fallback(hasOwn 防原型链穿透)。 */
|
||||||
|
export function getCategoryIcon(categoryId: string): IoniconName {
|
||||||
|
return Object.hasOwn(CATEGORY_ICON_NAMES, categoryId)
|
||||||
|
? CATEGORY_ICON_NAMES[categoryId]
|
||||||
|
: FALLBACK_ICON;
|
||||||
|
}
|
||||||
@@ -1,23 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* 年度报告图表(plan.md「5.2 年度报告」)。
|
* 年度报告图表(plan.md「5.2 年度报告」;P4:去内嵌月报,加月度节奏迷你图)。
|
||||||
* 展示年度收支/分类/月度趋势。
|
* 展示年度收支/分类/月度节奏。
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { generateAnnualReport } from '../../domain/annualReport';
|
import { generateAnnualReport } from '../../domain/stats/annualReport';
|
||||||
import { MonthlyReport } from './MonthlyReport';
|
import type { Transaction } from '../../domain/core/types';
|
||||||
import type { Transaction } from '../../domain/types';
|
|
||||||
|
|
||||||
export function AnnualReport({ transactions, year }: { transactions: Transaction[]; year: number }) {
|
export function AnnualReport({ transactions, year }: { transactions: Transaction[]; year: number }) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const report = generateAnnualReport(transactions, year);
|
const report = useMemo(() => generateAnnualReport(transactions, year), [transactions, year]);
|
||||||
|
// 展示层比率计算,允许 parseFloat(非记账金额)
|
||||||
|
const maxExpense = useMemo(
|
||||||
|
() => Math.max(...report.monthlyTrend.map(m => parseFloat(m.expense) || 0)),
|
||||||
|
[report],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
|
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
|
||||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{t('report.annualTitle', { year })}</Text>
|
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{t('report.annualTitle', { year })}</Text>
|
||||||
<View style={styles.statsRow}>
|
<View style={styles.statsRow}>
|
||||||
<View style={styles.statItem}>
|
<View style={styles.statItem}>
|
||||||
@@ -39,7 +43,7 @@ export function AnnualReport({ transactions, year }: { transactions: Transaction
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{report.topCategories.length > 0 && (
|
{report.topCategories.length > 0 && (
|
||||||
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
|
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.topCategory')}</Text>
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.topCategory')}</Text>
|
||||||
{report.topCategories.slice(0, 5).map(c => (
|
{report.topCategories.slice(0, 5).map(c => (
|
||||||
<View key={c.category} style={[styles.catRow, { borderTopColor: theme.colors.progressBg }]}>
|
<View key={c.category} style={[styles.catRow, { borderTopColor: theme.colors.progressBg }]}>
|
||||||
@@ -50,7 +54,23 @@ export function AnnualReport({ transactions, year }: { transactions: Transaction
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MonthlyReport transactions={transactions.filter(t => t.date.startsWith(String(year)))} />
|
<View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.monthlyRhythm')}</Text>
|
||||||
|
<View style={styles.rhythmRow}>
|
||||||
|
{report.monthlyTrend.map(m => {
|
||||||
|
const ratio = maxExpense > 0 ? Math.min(parseFloat(m.expense) / maxExpense, 1) : 0;
|
||||||
|
return (
|
||||||
|
<View key={m.month} style={styles.rhythmCol}>
|
||||||
|
{/* 节奏条圆角使用 theme.radii.sm / 2,避免硬编码 */}
|
||||||
|
<View style={[styles.rhythmTrack, { backgroundColor: theme.colors.bgTertiary, borderRadius: theme.radii.sm / 2 }]}>
|
||||||
|
<View style={[styles.rhythmFill, { height: `${Math.max(ratio * 100, 2)}%`, backgroundColor: theme.colors.financial.expense, borderRadius: theme.radii.sm / 2 }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: theme.typography.caption.fontSize - 3 }]}>{Number(m.month.slice(5))}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,4 +81,8 @@ const styles = StyleSheet.create({
|
|||||||
statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
|
statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
|
||||||
statItem: { alignItems: 'center', gap: 4 },
|
statItem: { alignItems: 'center', gap: 4 },
|
||||||
catRow: { flexDirection: 'row', borderTopWidth: 1, paddingTop: 8 },
|
catRow: { flexDirection: 'row', borderTopWidth: 1, paddingTop: 8 },
|
||||||
|
rhythmRow: { flexDirection: 'row', gap: 4, height: 64, marginTop: 8 },
|
||||||
|
rhythmCol: { flex: 1, alignItems: 'center', gap: 4 },
|
||||||
|
rhythmTrack: { flex: 1, width: '100%', justifyContent: 'flex-end', overflow: 'hidden' },
|
||||||
|
rhythmFill: { width: '100%' },
|
||||||
});
|
});
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
/**
|
|
||||||
* 日历热力图(plan.md「5.2 日历热力图」)。
|
|
||||||
* 按日期展示消费强度(颜色深浅)。
|
|
||||||
*/
|
|
||||||
import React, { useMemo } from 'react';
|
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
|
||||||
import { useTheme } from '../../theme';
|
|
||||||
import { useT } from '../../i18n';
|
|
||||||
import { getMonthGrid } from '../../domain/calendarGrid';
|
|
||||||
import { dailyExpenseMap } from '../../domain/chartStats';
|
|
||||||
import type { Transaction } from '../../domain/types';
|
|
||||||
|
|
||||||
/** 根据强度返回颜色(0-1)。使用 opacity 而非字符串替换,兼容 hex 和 rgb。 */
|
|
||||||
function heatColor(intensity: number): { opacity: number } {
|
|
||||||
if (intensity <= 0) return { opacity: 0 };
|
|
||||||
return { opacity: Math.min(0.2 + intensity * 0.8, 1) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
|
|
||||||
|
|
||||||
export function CalendarHeatmap({ transactions, year, month }: { transactions: Transaction[]; year: number; month: number }) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const t = useT();
|
|
||||||
const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]);
|
|
||||||
const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]);
|
|
||||||
const maxDaily = Math.max(...dailyMap.values(), 1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.sm }]}>
|
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, marginBottom: 4 }]}>
|
|
||||||
{t('report.heatmap')}
|
|
||||||
</Text>
|
|
||||||
<View style={styles.weekdayRow}>
|
|
||||||
{WEEKDAYS.map(d => (
|
|
||||||
<Text key={d} style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', flex: 1, fontSize: 10 }]}>
|
|
||||||
{d}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
{weeks.map((week, wi) => (
|
|
||||||
<View key={wi} style={styles.weekRow}>
|
|
||||||
{week.map(cell => {
|
|
||||||
const expense = dailyMap.get(cell.date) ?? 0;
|
|
||||||
const intensity = expense / maxDaily;
|
|
||||||
return (
|
|
||||||
<View key={cell.date} style={[
|
|
||||||
styles.cell,
|
|
||||||
{
|
|
||||||
backgroundColor: cell.isCurrentMonth && expense > 0
|
|
||||||
? theme.colors.financial.expense
|
|
||||||
: 'transparent',
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
opacity: cell.isCurrentMonth ? (expense > 0 ? heatColor(intensity).opacity : 1) : 0.3,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<Text style={[theme.typography.caption, { fontSize: 10, color: theme.colors.fgSecondary }]}>
|
|
||||||
{cell.day}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { gap: 3 },
|
|
||||||
weekdayRow: { flexDirection: 'row', marginBottom: 2 },
|
|
||||||
weekRow: { flexDirection: 'row', gap: 2 },
|
|
||||||
cell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', borderRadius: 3, borderWidth: 0.5 },
|
|
||||||
});
|
|
||||||
@@ -2,37 +2,38 @@
|
|||||||
* 分类占比图(plan.md「5.2 图表与可视化」)。
|
* 分类占比图(plan.md「5.2 图表与可视化」)。
|
||||||
* 采用卡片化布局与磨砂感水平胶囊进度条。
|
* 采用卡片化布局与磨砂感水平胶囊进度条。
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { groupByCategory } from '../../domain/chartStats';
|
import { groupByCategory } from '../../domain/stats/chartStats';
|
||||||
import type { Transaction } from '../../domain/types';
|
import type { Transaction } from '../../domain/core/types';
|
||||||
|
|
||||||
export function CategoryPie({ transactions }: { transactions: Transaction[] }) {
|
export function CategoryPie({ transactions }: { transactions: Transaction[] }) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const data = groupByCategory(transactions);
|
const data = useMemo(() => groupByCategory(transactions), [transactions]);
|
||||||
const maxAmount = Math.max(...data.map(d => d.amount), 1);
|
const maxAmount = Math.max(...data.map(d => d.amount), 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
|
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h3.fontFamily }]}>
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
|
||||||
{t('report.categoryTitle')}
|
{t('report.categoryTitle')}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.list}>
|
<View style={styles.list}>
|
||||||
{data.map(d => (
|
{data.map(d => (
|
||||||
<View key={d.category} style={styles.row}>
|
<View key={d.category} style={styles.row}>
|
||||||
<Text
|
<Text
|
||||||
style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1, fontWeight: '600', fontFamily: theme.typography.bodySmall.fontFamily }]}
|
style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1, maxWidth: '38%', fontWeight: '600' }]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{d.category.replace('Expenses:', '').replace('Income:', '')}
|
{d.category.replace('Expenses:', '').replace('Income:', '')}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, borderWidth: StyleSheet.hairlineWidth }]}>
|
{/* 进度条圆角使用 theme.radii.sm / 2,避免硬编码 */}
|
||||||
<View style={[styles.bar, { width: `${(d.amount / maxAmount) * 100}%`, backgroundColor: theme.colors.accent }]} />
|
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, borderWidth: StyleSheet.hairlineWidth, borderRadius: theme.radii.sm / 2 }]}>
|
||||||
|
<View style={[styles.bar, { width: `${(d.amount / maxAmount) * 100}%`, backgroundColor: theme.colors.accent, borderRadius: theme.radii.sm / 2 }]} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 55, textAlign: 'right', fontFamily: 'monospace', fontSize: 13 }]}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 55, textAlign: 'right', fontVariant: ['tabular-nums'], fontSize: theme.typography.bodySmall.fontSize - 1 }]}>
|
||||||
{d.percentage}%
|
{d.percentage}%
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -51,6 +52,6 @@ const styles = StyleSheet.create({
|
|||||||
container: { gap: 10 },
|
container: { gap: 10 },
|
||||||
list: { gap: 8 },
|
list: { gap: 8 },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||||
barWrap: { width: 120, height: 8, borderRadius: 4, overflow: 'hidden' },
|
barWrap: { flex: 1, height: 8, overflow: 'hidden' },
|
||||||
bar: { height: '100%', borderRadius: 4 },
|
bar: { height: '100%' },
|
||||||
});
|
});
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* 月度报告图表(plan.md「5.2 图表与可视化」)。
|
|
||||||
* 数据计算为纯函数,图表渲染用简单 RN 组件(避免重图表库依赖)。
|
|
||||||
*/
|
|
||||||
import React from 'react';
|
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
|
||||||
import { useTheme } from '../../theme';
|
|
||||||
import { useT } from '../../i18n';
|
|
||||||
import { groupByMonth } from '../../domain/chartStats';
|
|
||||||
import type { Transaction } from '../../domain/types';
|
|
||||||
|
|
||||||
export function MonthlyReport({ transactions }: { transactions: Transaction[] }) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const t = useT();
|
|
||||||
const data = groupByMonth(transactions);
|
|
||||||
const maxExpense = Math.max(...data.map(d => d.expense), 1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
|
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.monthlyTitle')}</Text>
|
|
||||||
{data.map(d => (
|
|
||||||
<View key={d.month} style={styles.row}>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 60 }]}>{d.month.slice(5)}</Text>
|
|
||||||
<View style={[styles.barWrap, { backgroundColor: theme.colors.progressBg }]}>
|
|
||||||
<View style={[styles.bar, {
|
|
||||||
width: `${(d.expense / maxExpense) * 100}%`,
|
|
||||||
backgroundColor: theme.colors.financial.expense,
|
|
||||||
}]} />
|
|
||||||
</View>
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.financial.expense }]}>{d.expense.toFixed(0)}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
{data.length === 0 && (
|
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('common.noData')}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { gap: 6 },
|
|
||||||
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
||||||
barWrap: { flex: 1, height: 12, borderRadius: 6, overflow: 'hidden' },
|
|
||||||
bar: { height: '100%', borderRadius: 6 },
|
|
||||||
});
|
|
||||||
@@ -1,32 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* 净资产趋势图(plan.md「5.2 净资产趋势」)。
|
* 净资产趋势图(plan.md「5.2 净资产趋势」)。
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React, { useEffect, useMemo, useRef } from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { Animated, StyleSheet, Text, View } from 'react-native';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import { calculateNetWorthTrend } from '../../domain/netWorth';
|
import { calculateNetWorthTrend } from '../../domain/stats/netWorth';
|
||||||
import type { Transaction } from '../../domain/types';
|
import type { Transaction } from '../../domain/core/types';
|
||||||
|
|
||||||
export function NetWorthChart({ transactions, dates }: { transactions: Transaction[]; dates: string[] }) {
|
export function NetWorthChart({ transactions, dates }: { transactions: Transaction[]; dates: string[] }) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const points = calculateNetWorthTrend(transactions, dates);
|
const points = useMemo(() => calculateNetWorthTrend(transactions, dates), [transactions, dates]);
|
||||||
const maxAbs = Math.max(...points.map(p => Math.abs(parseFloat(p.netWorth))), 1);
|
const maxAbs = Math.max(...points.map(p => Math.abs(parseFloat(p.netWorth))), 1);
|
||||||
|
const progress = useRef(new Animated.Value(0)).current;
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.timing(progress, { toValue: 1, duration: 600, useNativeDriver: false }).start();
|
||||||
|
}, [progress]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
|
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.netWorthTitle')}</Text>
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.netWorthTitle')}</Text>
|
||||||
<View style={styles.chart}>
|
<View style={styles.chart}>
|
||||||
{points.map(p => (
|
{points.map(p => (
|
||||||
<View key={p.date} style={styles.col}>
|
<View key={p.date} style={styles.col}>
|
||||||
<View style={styles.barWrap}>
|
<View style={styles.barWrap}>
|
||||||
<View style={[styles.bar, {
|
{/* 柱状条圆角使用 theme.radii.sm / 2,避免硬编码 */}
|
||||||
height: `${(Math.abs(parseFloat(p.netWorth)) / maxAbs) * 100}%`,
|
<Animated.View style={[styles.bar, {
|
||||||
|
height: progress.interpolate({ inputRange: [0, 1], outputRange: [0, (Math.abs(parseFloat(p.netWorth)) / maxAbs) * 100] }),
|
||||||
backgroundColor: parseFloat(p.netWorth) >= 0 ? theme.colors.accent : theme.colors.error,
|
backgroundColor: parseFloat(p.netWorth) >= 0 ? theme.colors.accent : theme.colors.error,
|
||||||
|
borderRadius: theme.radii.sm / 2,
|
||||||
}]} />
|
}]} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: 9 }]}>{p.date.slice(5)}</Text>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: theme.typography.caption.fontSize - 3 }]}>{p.date.slice(5)}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -45,5 +51,5 @@ const styles = StyleSheet.create({
|
|||||||
chart: { flexDirection: 'row', alignItems: 'flex-end', height: 120, gap: 4 },
|
chart: { flexDirection: 'row', alignItems: 'flex-end', height: 120, gap: 4 },
|
||||||
col: { flex: 1, alignItems: 'center', gap: 4 },
|
col: { flex: 1, alignItems: 'center', gap: 4 },
|
||||||
barWrap: { height: 100, justifyContent: 'flex-end', width: '100%', alignItems: 'center' },
|
barWrap: { height: 100, justifyContent: 'flex-end', width: '100%', alignItems: 'center' },
|
||||||
bar: { width: 12, borderRadius: 3 },
|
bar: { width: 12 },
|
||||||
});
|
});
|
||||||
@@ -2,21 +2,25 @@
|
|||||||
* 趋势折线图(plan.md「5.2 图表与可视化」)。
|
* 趋势折线图(plan.md「5.2 图表与可视化」)。
|
||||||
* 采用 react-native-svg 绘制三次贝塞尔曲线及渐变填充区域。
|
* 采用 react-native-svg 绘制三次贝塞尔曲线及渐变填充区域。
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { Animated, Dimensions, StyleSheet, Text, View } from 'react-native';
|
||||||
import Svg, { Path, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
|
import Svg, { Path, Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
|
||||||
import { useTheme } from '../../theme';
|
import { useTheme } from '../../theme';
|
||||||
import { useT } from '../../i18n';
|
import { useT } from '../../i18n';
|
||||||
import type { Transaction } from '../../domain/types';
|
import type { Transaction } from '../../domain/core/types';
|
||||||
import { groupByMonth } from '../../domain/chartStats';
|
import { groupByMonth } from '../../domain/stats/chartStats';
|
||||||
|
|
||||||
export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const data = groupByMonth(transactions).slice(-6);
|
const fade = useRef(new Animated.Value(0)).current;
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.timing(fade, { toValue: 1, duration: 500, useNativeDriver: true }).start();
|
||||||
|
}, [fade]);
|
||||||
|
const data = useMemo(() => groupByMonth(transactions).slice(-6), [transactions]);
|
||||||
|
|
||||||
// 1. 数据配置
|
// 1. 数据配置 —— 自适应宽度:初始取屏幕宽减去页面 padding,onLayout 后更新为容器实际宽度
|
||||||
const width = 300;
|
const [chartWidth, setChartWidth] = useState(() => Dimensions.get('window').width - 64);
|
||||||
const height = 120;
|
const height = 120;
|
||||||
const paddingX = 20;
|
const paddingX = 20;
|
||||||
const paddingY = 15;
|
const paddingY = 15;
|
||||||
@@ -26,16 +30,16 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
|||||||
// 2. 坐标转换计算
|
// 2. 坐标转换计算
|
||||||
const pointsIncome = data.map((d, i) => {
|
const pointsIncome = data.map((d, i) => {
|
||||||
const x = data.length > 1
|
const x = data.length > 1
|
||||||
? paddingX + (i * (width - 2 * paddingX)) / (data.length - 1)
|
? paddingX + (i * (chartWidth - 2 * paddingX)) / (data.length - 1)
|
||||||
: width / 2;
|
: chartWidth / 2;
|
||||||
const y = height - paddingY - (d.income / maxVal) * (height - 2 * paddingY);
|
const y = height - paddingY - (d.income / maxVal) * (height - 2 * paddingY);
|
||||||
return { x, y };
|
return { x, y };
|
||||||
});
|
});
|
||||||
|
|
||||||
const pointsExpense = data.map((d, i) => {
|
const pointsExpense = data.map((d, i) => {
|
||||||
const x = data.length > 1
|
const x = data.length > 1
|
||||||
? paddingX + (i * (width - 2 * paddingX)) / (data.length - 1)
|
? paddingX + (i * (chartWidth - 2 * paddingX)) / (data.length - 1)
|
||||||
: width / 2;
|
: chartWidth / 2;
|
||||||
const y = height - paddingY - (d.expense / maxVal) * (height - 2 * paddingY);
|
const y = height - paddingY - (d.expense / maxVal) * (height - 2 * paddingY);
|
||||||
return { x, y };
|
return { x, y };
|
||||||
});
|
});
|
||||||
@@ -72,14 +76,14 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
|||||||
const expenseClosedPath = getClosedBezierPath(pointsExpense);
|
const expenseClosedPath = getClosedBezierPath(pointsExpense);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
|
<Animated.View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md, opacity: fade }]}>
|
||||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h3.fontFamily }]}>
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
|
||||||
{t('report.trendTitle')}
|
{t('report.trendTitle')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{data.length > 0 ? (
|
{data.length > 0 ? (
|
||||||
<View style={styles.chartWrapper}>
|
<View style={styles.chartWrapper} onLayout={(e) => setChartWidth(e.nativeEvent.layout.width)}>
|
||||||
<Svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`}>
|
<Svg width="100%" height={height} viewBox={`0 0 ${chartWidth} ${height}`}>
|
||||||
<Defs>
|
<Defs>
|
||||||
<LinearGradient id="incomeGrad" x1="0" y1="0" x2="0" y2="1">
|
<LinearGradient id="incomeGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
<Stop offset="0%" stopColor={theme.colors.financial.income} stopOpacity={0.25} />
|
<Stop offset="0%" stopColor={theme.colors.financial.income} stopOpacity={0.25} />
|
||||||
@@ -93,7 +97,7 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
|||||||
|
|
||||||
{/* 网格线(只绘制一条底线和中线) */}
|
{/* 网格线(只绘制一条底线和中线) */}
|
||||||
<Path
|
<Path
|
||||||
d={`M ${paddingX} ${height / 2} L ${width - paddingX} ${height / 2} M ${paddingX} ${height - paddingY} L ${width - paddingX} ${height - paddingY}`}
|
d={`M ${paddingX} ${height / 2} L ${chartWidth - paddingX} ${height / 2} M ${paddingX} ${height - paddingY} L ${chartWidth - paddingX} ${height - paddingY}`}
|
||||||
stroke={theme.colors.divider}
|
stroke={theme.colors.divider}
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
strokeDasharray="4 4"
|
strokeDasharray="4 4"
|
||||||
@@ -153,7 +157,7 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
|||||||
{data.map((d, i) => (
|
{data.map((d, i) => (
|
||||||
<Text
|
<Text
|
||||||
key={`x-label-${i}`}
|
key={`x-label-${i}`}
|
||||||
style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: 10, fontFamily: theme.typography.caption.fontFamily }]}
|
style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: theme.typography.caption.fontSize - 2 }]}
|
||||||
>
|
>
|
||||||
{d.month.slice(5)}
|
{d.month.slice(5)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -168,15 +172,15 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
|||||||
|
|
||||||
<View style={styles.legend}>
|
<View style={styles.legend}>
|
||||||
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.income }]} />
|
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.income }]} />
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
{t('home.income')}
|
{t('home.income')}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.expense }]} />
|
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.expense }]} />
|
||||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||||
{t('home.expense')}
|
{t('home.expense')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useTheme, createCommonStyles } from '../../theme';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
import { BottomSheet } from '../ui/BottomSheet';
|
||||||
|
import { buildMonthGrid } from '../stats/dateGrid';
|
||||||
|
|
||||||
|
interface DatePickerFieldProps {
|
||||||
|
/** YYYY-MM-DD,空串表示未选。 */
|
||||||
|
value: string;
|
||||||
|
onChange: (date: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日期选择字段:输入框外观 + 底部弹层月历(终结手输 YYYY-MM-DD)。 */
|
||||||
|
export function DatePickerField({ value, onChange, placeholder }: DatePickerFieldProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const t = useT();
|
||||||
|
// 星期头周一起(与 buildMonthGrid 网格一致),文案走 i18n
|
||||||
|
const weekdays = useMemo(() => t('datepicker.weekdays').split(','), [t]);
|
||||||
|
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [viewYear, setViewYear] = useState(new Date().getFullYear());
|
||||||
|
const [viewMonth, setViewMonth] = useState(new Date().getMonth() + 1);
|
||||||
|
|
||||||
|
const grid = useMemo(() => buildMonthGrid(viewYear, viewMonth), [viewYear, viewMonth]);
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
const y = Number(value.slice(0, 4));
|
||||||
|
const m = Number(value.slice(5, 7));
|
||||||
|
if (y) {
|
||||||
|
setViewYear(y);
|
||||||
|
setViewMonth(m || 1);
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shiftMonth = (delta: number) => {
|
||||||
|
const m = viewMonth - 1 + delta;
|
||||||
|
setViewYear(viewYear + Math.floor(m / 12));
|
||||||
|
setViewMonth(((m % 12) + 12) % 12 + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = (date: string) => {
|
||||||
|
onChange(date);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Pressable style={commonStyles.input} onPress={openPicker} accessibilityRole="button" accessibilityLabel={value || placeholder || '选择日期'}>
|
||||||
|
<Text style={{ color: value ? theme.colors.fgPrimary : theme.colors.fgSecondary }}>
|
||||||
|
{value || placeholder || 'YYYY-MM-DD'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<BottomSheet visible={open} onClose={() => setOpen(false)}>
|
||||||
|
{/* 月份导航 */}
|
||||||
|
<View style={styles.navRow}>
|
||||||
|
<Pressable onPress={() => shiftMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月">
|
||||||
|
<Ionicons name="chevron-back" size={22} color={theme.colors.fgPrimary} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'] }]}>
|
||||||
|
{viewYear}-{String(viewMonth).padStart(2, '0')}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={() => shiftMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月">
|
||||||
|
<Ionicons name="chevron-forward" size={22} color={theme.colors.fgPrimary} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
{/* 星期头 */}
|
||||||
|
<View style={styles.weekRow}>
|
||||||
|
{weekdays.map(w => (
|
||||||
|
<Text key={w} style={[styles.weekCell, theme.typography.caption, { color: theme.colors.fgSecondary }]}>{w}</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{/* 日期网格 */}
|
||||||
|
{grid.map((week, wi) => (
|
||||||
|
<View key={wi} style={styles.weekRow}>
|
||||||
|
{week.map(cell => {
|
||||||
|
const selected = cell.date === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={cell.date}
|
||||||
|
onPress={() => pick(cell.date)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={cell.date}
|
||||||
|
accessibilityState={{ selected }}
|
||||||
|
style={[styles.dayCell, {
|
||||||
|
backgroundColor: selected ? theme.colors.accent : 'transparent',
|
||||||
|
borderRadius: theme.radii.full,
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
<Text style={[theme.typography.bodySmall, {
|
||||||
|
color: selected
|
||||||
|
? theme.colors.fgInverse
|
||||||
|
: cell.inMonth ? theme.colors.fgPrimary : theme.colors.fgSecondary,
|
||||||
|
opacity: cell.inMonth ? 1 : 0.5,
|
||||||
|
fontVariant: ['tabular-nums'],
|
||||||
|
}]}>
|
||||||
|
{Number(cell.date.slice(8, 10))}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</BottomSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
navRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
|
||||||
|
weekRow: { flexDirection: 'row' },
|
||||||
|
weekCell: { flex: 1, textAlign: 'center', paddingVertical: 6 },
|
||||||
|
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', margin: 1 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 高级筛选底部弹层(spec §6):账户 / 日期范围 / 金额区间。
|
||||||
|
* 受控组件:值由父级持有,「完成」回调应用,「重置」清空。
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
|
import { useTheme, createCommonStyles } from '../../theme';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
import { BottomSheet } from '../ui/BottomSheet';
|
||||||
|
import { DatePickerField } from './DatePickerField';
|
||||||
|
import { Button } from '../ui/Button';
|
||||||
|
|
||||||
|
export interface AdvancedFilterValues {
|
||||||
|
account: string;
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
amountMin: string;
|
||||||
|
amountMax: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterSheetProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
values: AdvancedFilterValues;
|
||||||
|
onChange: (values: AdvancedFilterValues) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterSheet({ visible, onClose, values, onChange, onReset }: FilterSheetProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const commonStyles = createCommonStyles(theme);
|
||||||
|
const t = useT();
|
||||||
|
const set = (patch: Partial<AdvancedFilterValues>) => onChange({ ...values, ...patch });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheet visible={visible} onClose={onClose} title={t('filter.title')} scrollable>
|
||||||
|
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{t('transactions.accountFilterPlaceholder')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={values.account}
|
||||||
|
onChangeText={v => set({ account: v })}
|
||||||
|
placeholder={t('transactions.accountFilterPlaceholder')}
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
style={commonStyles.input}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{t('transactions.dateFrom')} ~ {t('transactions.dateTo')}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.flex1}><DatePickerField value={values.dateFrom} onChange={v => set({ dateFrom: v })} placeholder={t('transactions.dateFrom')} /></View>
|
||||||
|
<View style={styles.flex1}><DatePickerField value={values.dateTo} onChange={v => set({ dateTo: v })} placeholder={t('transactions.dateTo')} /></View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{t('transactions.amountMin')} ~ {t('transactions.amountMax')}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<TextInput
|
||||||
|
value={values.amountMin}
|
||||||
|
onChangeText={v => set({ amountMin: v })}
|
||||||
|
placeholder={t('transactions.amountMin')}
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
style={[commonStyles.input, styles.flex1]}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
value={values.amountMax}
|
||||||
|
onChangeText={v => set({ amountMax: v })}
|
||||||
|
placeholder={t('transactions.amountMax')}
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
style={[commonStyles.input, styles.flex1]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.row, styles.actions]}>
|
||||||
|
<View style={styles.flex1}>
|
||||||
|
<Button label={t('filter.reset')} variant="secondary" onPress={onReset} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.flex1}>
|
||||||
|
<Button label={t('filter.apply')} onPress={onClose} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
label: { marginTop: 10, marginBottom: 4 },
|
||||||
|
row: { flexDirection: 'row', gap: 8 },
|
||||||
|
flex1: { flex: 1 },
|
||||||
|
actions: { marginTop: 16 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* 表单字段子组件(从 FormModal 拆分)。
|
||||||
|
* - TextField: 文本输入
|
||||||
|
* - SelectField: 单选卡片组
|
||||||
|
* - DropdownField: 下拉选择触发器(菜单由 FormModal 同层 overlay 渲染)
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Touchable } from '../ui/Touchable';
|
||||||
|
import type { ThemeTokens } from '../../theme';
|
||||||
|
import { createCommonStyles } from '../../theme';
|
||||||
|
type CommonStyles = ReturnType<typeof createCommonStyles>;
|
||||||
|
import type { FormField } from './FormModal';
|
||||||
|
|
||||||
|
// ---------- TextField ----------
|
||||||
|
interface TextFieldProps {
|
||||||
|
field: FormField;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
isFocused: boolean;
|
||||||
|
onFocus: () => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
theme: ThemeTokens;
|
||||||
|
commonStyles: CommonStyles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextField({ field: f, value, onChangeText, isFocused, onFocus, onBlur, theme, commonStyles }: TextFieldProps) {
|
||||||
|
return (
|
||||||
|
<View style={[fieldStyles.field, f.flex ? { flex: f.flex } : undefined]}>
|
||||||
|
<Text style={[theme.typography.caption, fieldStyles.fieldLabel, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{f.label}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
placeholder={f.placeholder}
|
||||||
|
placeholderTextColor={theme.colors.fgSecondary}
|
||||||
|
keyboardType={f.keyboardType ?? 'default'}
|
||||||
|
multiline={f.multiline}
|
||||||
|
secureTextEntry={f.secureTextEntry}
|
||||||
|
style={[
|
||||||
|
commonStyles.input,
|
||||||
|
f.flex ? { minHeight: 44 } : undefined,
|
||||||
|
{
|
||||||
|
backgroundColor: isFocused ? theme.colors.bgPrimary : theme.colors.bgTertiary,
|
||||||
|
borderColor: isFocused ? theme.colors.accent : theme.colors.border,
|
||||||
|
borderWidth: isFocused ? 1.5 : StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- SelectField ----------
|
||||||
|
interface SelectFieldProps {
|
||||||
|
field: FormField;
|
||||||
|
value: string;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
theme: ThemeTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectField({ field: f, value, onSelect, theme }: SelectFieldProps) {
|
||||||
|
return (
|
||||||
|
<View style={[fieldStyles.field, f.flex ? { flex: f.flex } : undefined]}>
|
||||||
|
<Text style={[theme.typography.caption, fieldStyles.fieldLabel, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{f.label}
|
||||||
|
</Text>
|
||||||
|
<View style={fieldStyles.optionContainer}>
|
||||||
|
{f.options?.map(opt => {
|
||||||
|
const isSelected = value === opt.value;
|
||||||
|
return (
|
||||||
|
<Touchable
|
||||||
|
key={opt.value}
|
||||||
|
style={[
|
||||||
|
fieldStyles.optionChip,
|
||||||
|
{
|
||||||
|
backgroundColor: isSelected ? theme.colors.accent + '15' : theme.colors.bgPrimary,
|
||||||
|
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => onSelect(opt.value)}
|
||||||
|
>
|
||||||
|
<View style={fieldStyles.optionTextWrap}>
|
||||||
|
<Text style={[fieldStyles.optionValueText, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{opt.value}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
fieldStyles.optionLabelText,
|
||||||
|
{
|
||||||
|
color: isSelected ? theme.colors.accent : theme.colors.fgPrimary,
|
||||||
|
fontWeight: isSelected ? '700' : '600',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isSelected && (
|
||||||
|
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} style={{ marginLeft: 'auto' }} />
|
||||||
|
)}
|
||||||
|
</Touchable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- DropdownField ----------
|
||||||
|
interface DropdownFieldProps {
|
||||||
|
field: FormField;
|
||||||
|
value: string;
|
||||||
|
isFocused: boolean;
|
||||||
|
onOpen: () => void;
|
||||||
|
theme: ThemeTokens;
|
||||||
|
commonStyles: CommonStyles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownField({ field: f, value, isFocused, onOpen, theme, commonStyles }: DropdownFieldProps) {
|
||||||
|
const selectedOpt = f.options?.find(o => o.value === value);
|
||||||
|
const rawValue = selectedOpt ? selectedOpt.value : value;
|
||||||
|
const rawLabel = selectedOpt ? selectedOpt.label : '';
|
||||||
|
const hasColon = rawValue.includes(':');
|
||||||
|
const triggerPrefix = hasColon ? rawValue.slice(0, rawValue.lastIndexOf(':')) : '';
|
||||||
|
const triggerTitle = hasColon ? rawValue.slice(rawValue.lastIndexOf(':') + 1) : (rawLabel || f.placeholder || '');
|
||||||
|
const isPlaceholder = !rawValue;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[fieldStyles.field, f.flex ? { flex: f.flex } : undefined]}>
|
||||||
|
<Text style={[theme.typography.caption, fieldStyles.fieldLabel, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{f.label}
|
||||||
|
</Text>
|
||||||
|
<Touchable
|
||||||
|
style={[
|
||||||
|
commonStyles.input,
|
||||||
|
fieldStyles.dropdownTrigger,
|
||||||
|
{
|
||||||
|
borderColor: isFocused ? theme.colors.accent : theme.colors.border,
|
||||||
|
backgroundColor: isFocused ? theme.colors.bgPrimary : theme.colors.bgTertiary,
|
||||||
|
borderWidth: isFocused ? 1.5 : StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={onOpen}
|
||||||
|
>
|
||||||
|
<View style={fieldStyles.dropdownTriggerContent}>
|
||||||
|
{triggerPrefix ? (
|
||||||
|
<Text style={[fieldStyles.optionValueText, { color: theme.colors.fgSecondary }]} numberOfLines={1}>
|
||||||
|
{triggerPrefix}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
fieldStyles.optionLabelText,
|
||||||
|
{
|
||||||
|
color: isPlaceholder ? theme.colors.fgSecondary : theme.colors.fgPrimary,
|
||||||
|
fontWeight: isPlaceholder ? '400' : '600',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{triggerTitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-down" size={16} color={isFocused ? theme.colors.accent : theme.colors.fgSecondary} />
|
||||||
|
</Touchable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- DropdownMenu (overlay 内容) ----------
|
||||||
|
interface DropdownMenuProps {
|
||||||
|
field: FormField;
|
||||||
|
value: string;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
theme: ThemeTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenu({ field, value, onSelect, onClose, theme }: DropdownMenuProps) {
|
||||||
|
return (
|
||||||
|
<View style={{ padding: 16 }}>
|
||||||
|
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, letterSpacing: 0.5, fontWeight: '600' }]}>
|
||||||
|
{field.label}
|
||||||
|
</Text>
|
||||||
|
{field.options?.map(opt => {
|
||||||
|
const isSelected = value === opt.value;
|
||||||
|
const hasColon = opt.value.includes(':');
|
||||||
|
const pathPrefix = hasColon ? opt.value.slice(0, opt.value.lastIndexOf(':')) : '';
|
||||||
|
const shortName = hasColon ? opt.value.slice(opt.value.lastIndexOf(':') + 1) : opt.label;
|
||||||
|
return (
|
||||||
|
<Touchable
|
||||||
|
key={opt.value}
|
||||||
|
style={[
|
||||||
|
fieldStyles.dropdownMenuItem,
|
||||||
|
{
|
||||||
|
backgroundColor: isSelected ? theme.colors.accent + '15' : theme.colors.bgPrimary,
|
||||||
|
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => { onSelect(opt.value); onClose(); }}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{pathPrefix ? (
|
||||||
|
<Text style={[fieldStyles.optionValueText, { color: theme.colors.fgSecondary }]}>
|
||||||
|
{pathPrefix}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
fieldStyles.optionLabelText,
|
||||||
|
{
|
||||||
|
color: isSelected ? theme.colors.accent : theme.colors.fgPrimary,
|
||||||
|
fontWeight: isSelected ? '700' : '600',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{shortName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isSelected && (
|
||||||
|
<Ionicons name="checkmark-circle" size={18} color={theme.colors.accent} />
|
||||||
|
)}
|
||||||
|
</Touchable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 共享样式 ----------
|
||||||
|
const fieldStyles = StyleSheet.create({
|
||||||
|
field: { marginBottom: 14 },
|
||||||
|
fieldLabel: { fontWeight: '600', letterSpacing: 0.3, marginBottom: 6 },
|
||||||
|
optionContainer: { gap: 8, marginTop: 4 },
|
||||||
|
optionChip: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, borderWidth: 1, flexDirection: 'row', alignItems: 'center' },
|
||||||
|
optionTextWrap: { flexDirection: 'column' as const },
|
||||||
|
optionValueText: { fontSize: 10, lineHeight: 12 },
|
||||||
|
optionLabelText: { fontSize: 13, lineHeight: 16, fontWeight: '600' },
|
||||||
|
dropdownTrigger: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 2, paddingHorizontal: 12, minHeight: 44 },
|
||||||
|
dropdownTriggerContent: { justifyContent: 'center' },
|
||||||
|
dropdownMenuItem: { paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, borderWidth: 1, flexDirection: 'row', alignItems: 'center', marginBottom: 6 },
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user