Compare commits

...

3 Commits

Author SHA1 Message Date
fengmengqi
bf04400852 feat: OCR 模型按需下载 + 三层独立开关 + 日志持久化 + 原生浮层主题同步 + 文档重构
OCR 模型按需下载(P8 瘦身):
- 移除 plugins/ppocr/assets/ 内置模型(det 9.8MB + rec 21MB + dict),APK 减包 ~31MB
- 新增 modelDownloader.ts:优先从 HuggingFace/CDN 下载,兜底从 APK assets 拷贝
- OcrModule.kt 新增 setModelDir,支持从 filesystem 加载模型,回退 assets 兼容旧用户
- settingsStore 持久化 ocrModelVersion / ocrModelDir
- 自动化页集成模型状态检查与一键下载 UI

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

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

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

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

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

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

其他:
- i18n 新增权限/信用卡详情/日志中心/AI 设置等翻译键
- ppocr Config Plugin 修复 import 注入去重;size-optimization 增强
- 新增测试:logger.test.ts、floating-ui-config.test.ts
2026-07-23 19:03:38 +08:00
fengmengqi
2e73b5a2c6 feat: UI 全面重设计(Bento 明亮风)+ 记一笔 NumpadSheet + 管理页模板 + OCR v5→v6 升级
UI 重设计(P1-P5):
- 主题从靛蓝 accent 切换为近黑白单色(#111318),品牌感靠排版与财务语义色
- 暗色模式适配 OLED 纯黑,财务色整体提亮一档保证对比度
- 圆角体系新增 xl(24),卡片/弹窗统一大圆角
- 移除 Caveat/Quicksand 自定义字体,切换为系统字体
- 新增 display(34/800) 字阶用于净资产等大数字展示
- 分类/标签/渠道颜色统一到 palette.ts 色板(12 色循环 + FNV 哈希)
记一笔 NumpadSheet(P3 录入闭环):
- 全新计算器风格录入面板:方向 chip → 大金额 → 账户 chip → 分类快捷行 → 数字键盘
- 支持表达式输入(20+15-5.5)与字符串十进制求值,避免浮点精度问题
- 高级模式保留多行分录编辑器,简单模式自动推断双腿
- postingLegs.ts 实现 buildPostings 的逆操作(编辑/草稿预填)
- 未保存守卫:整页 beforeRemove + modal 脏状态上报
- 全局弹层(NumpadSheetHost)由+按钮唤起,可通过设置开关
Tab 栏重构:
- AppTabBar 替换默认 tab bar,中央+按钮唤起全局记账面板
- Tab 从 5 个精简为 4 个(首页/交易/报表/设置),+按钮独立
管理页模板 ManagementScreen:
- 统一「列表 + 新增 + 点按编辑 + 长按删除 + FormModal」模式
- 预算/分类/标签/信用卡等管理页消除手写样板
其他:
- OCR 模型从 PP-OCRv5 升级到 v6(det/rec/dict 全部替换)
- GBK 解码器 import 修复(text-encoding-gbk ESM 兼容)
- 批量确认失败数计算修正(用 failedPayees.length 替代 store 快照差值)
- 新增 diagnostics 诊断页、TodoStrip 首页待办条、SwipeableTransactionCard 左滑操作
- periodNav.ts 报表周期导航(anchor+period 统一状态管理,本地时区运算)
- 测试覆盖 numpadExpression/postingLegs/periodNav/palette/search/transactionGroups/categoryIcons/dateGrid
2026-07-22 13:33:29 +08:00
fengmengqi
7fa345b558 feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构
品牌与配置
- 重命名 Bean Mobile → 浮记(DriftLedger),slug 改为 drift-ledger
- 所有 Config Plugin 从 app.json 动态读取 appId,替换硬编码 com.beancount.mobile
- 预构建时自动从 constants.ts 同步支付包名列表到 Kotlin 端
- size-optimization 插件新增 NDK 版本强制统一(27.1.12297006)
- 添加 app icon(direction_a_feather.png)
Decimal 精度改造
- 全面消除 parseFloat 用于金额计算的场景,改用 string decimal 运算
- 新增 divideDecimals(除法)、toDateString(Date→YYYY-MM-DD)
- negateDecimal 零值特殊处理,避免产生 "-0"
- 年报/月报/预算/信用卡/汇率/图表统计等模块全部迁移
- 去重指纹用 parseDecimal 归一化金额格式
- 转账识别增加 normalizeAmount,支持不同精度格式配对(100 vs 100.00)
架构变更
- mobile.bean → main.bean:移除启动时合并迁移逻辑,直接读写 main.bean
- TransactionDraft.sourceEventId → sourceEventIds(数组,支持转账双方)
- 新增 buildPostings() 纯函数,从 transactionBuilder 提取复式分录构建逻辑
- 新增 csvUtils.ts(引号感知 CSV 行拆分),statements/adapters 共用
- 新增 accessibilityParser.ts:从 automationPipeline 提取微信/支付宝文本解析
- 新增 floatingBillManager.ts:悬浮窗草稿管理(LRU+超时)与排序数据构建
- 新增 txKeyStore.ts:交易级去重键持久化(原子写入+500条LRU)
- ledger.ts 新增 resolveIncludes 选项,支持 include 指令递归解析
- metadataStore CRUD 用 createCrudActions 工厂消除7组实体重复代码
安全与持久化
- PIN 哈希改用带盐 SHA-256,兼容旧版无 salt 数据迁移
- storePersistence 从 persistTo 回调改为 subscribe 自动触发
- 持久化增加深比较跳过无变化写入 + 滞后重试
- 新增 flushPersistence(),App 切后台时立即刷盘
- CrashReporter 新增文件持久化(CrashFs 抽象)
- settingsStore hydrate 期间跳过自动持久化(skipPersist 标志)
去重与管道
- batchDedup 改用日期索引 Map,候选查找从 O(n) 降至 O(1)
- dedupAgainstHistory 按日期分组,仅扫描时间窗口内候选
- OcrProcessor 从布尔标志改为 Promise 队列,不再丢弃并发请求
- 去重缓存改用 Map<hash,timestamp> LRU,阈值超20%时批量淘汰
- 悬浮球全局开关(floatingBallEnabled) + 原生 bridge API
其他改进
- recurring 计算修复月/年溢出(1月31日+1月→2月28日,闰年2月29日)
- 预算进度按 posting 分录金额求和(不再仅过滤负数金额)
- recurring.ts 用 Date.parse 本地时间替代 toISOString 的 UTC 偏移
- 信用卡账单日/还款日限制不超过28,防止月份溢出
- 类别模糊匹配增加最小长度2,避免单字符误匹配
- 还款检测排除 refund 方向,避免退款被误判为还款
- WebDAV 同步增加 HTTP 日期格式解析
- Git 合并策略改为以 remote 为基础追加 local 独有行
性能优化
- commonStyles 全局 useMemo 缓存
- Button/Card 的 pressIn/pressOut 改用 useCallback
- TransactionCard/AccountTreeNode 包裹 React.memo
- PostingEditor 用 refs + 函数式更新避免重建
- CreditCard 页预计算账户余额 Map,避免渲染循环重复遍历
- OCR 处理器单例复用,配置变更时才重建
- getLocalAuth/getAccessibilityBridge 结果缓存
测试
- 新增 decimal/constants/channelConfig/transactionBuilder 单测
- 修复金额格式断言(-24.5→-24.50),匹配字符串精度行为
- billPipeline 新增不同格式金额配对、指纹唯一性测试
2026-07-21 15:16:44 +08:00
211 changed files with 17932 additions and 23243 deletions

2
.gitignore vendored
View File

@ -48,3 +48,5 @@ CLAUDE.md
/.agents/ /.agents/
/reference_project/ /reference_project/
/.zcode/ /.zcode/
# superpowers brainstorm mockups
.superpowers/

62
AGENTS.md Normal file
View File

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

View File

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

View File

@ -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

View File

@ -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、渠道品牌色
|------|-----|--------------| 其余 tokendivider / 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
- 阴影:浅色 412px 弥散轻阴影;暗色阴影减重,辅以半透边框
| 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 - 禁止瞬间状态变化,交互反馈 150300ms
- 禁止绕过 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

116
docs/architecture.md Normal file
View File

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

View File

@ -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。
- 阴影减重:浅色 212px 弥散阴影;暗色以边框代替。
- **图标统一 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 默认 tabBar4 个内容 Tab首页/交易/报表/我的)+ 中央凸起 +。+ **不是路由**,在任何页面唤起全局 ModalNumpadSheet不丢失上下文。**SpeedDial 退役**。
## 6. 核心组件库P2 交付物)
新增:
- **NumpadSheet** — 见第 4 节
- **AppTabBar** — 见第 5 节
- **ScreenHeader** — 统一二级页头(返回 + 标题 + 右操作位替换十几份手写返回栏transaction/new 的原生 header 一并撤掉
- **StatCard** — "标题 + 大数字 + caption"统计卡(首页/报表/年报共用)
- **ManagementScreen** — 管理页模板ScreenHeader+ / 可选分组 Tab / FlatList / 删除确认 / FormModal8 个管理页共用,各页只声明字段配置 + 数据读写 hooks
- **DatePickerField** — 日历选择器,终结手输 `YYYY-MM-DD`
- **FilterSheet** — 底部弹层高级筛选(账户/日期/金额区间)
- **CategoryIcon** — Ionicon + 彩色圆底,替换 emoji
改造/退役Button/Card/Chip/SearchBar 按新 token 重刷CategoryPicker 改底部弹层网格并 Ionicon 化FormModal 的 "✕" 字符换 Ioniconstransaction/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/存储/同步层;平板/桌面布局适配。

View File

@ -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视觉语言、§10P1 验收)。
**验收标准:** `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: PASSpalette.test.ts 仍 FAILTask 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**
删除第 3148 行的 `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 的字体导入**
删除第 2739 行两个 `import { useFonts, ... } from '@expo-google-fonts/...'` 块。
- [ ] **Step 2: 删除 useFonts 调用**
删除第 7079 行的 `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` 的运行时引用返回 undefinedRN 回退系统字体,无需在 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
- 阴影:浅色 412px 弥散轻阴影;暗色以 1px 半透边框代替阴影
## 图标
统一 Ionicons`@expo/vector-icons`)。禁止用 emoji 充当图标;
分类图标 = Ionicon + 圆形彩色底CategoryIcon 组件P2 落地)。
## 反模式
- 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token
- 禁止低对比文字(< 4.5:1
- 禁止瞬间状态变化,交互反馈 150300ms
- 禁止绕过 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 管理页模板化 + 清零审计**
每阶段完成后基于实际代码编写下一阶段计划。

View File

@ -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';
/** 取分类图标:具名映射,否则 fallbackhasOwn 防原型链穿透)。 */
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` 常量(第 1330 行)。
- 顶部加 `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 条 PASSdateGrid 仍 FAILTask 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/newP3 改为唤起全局 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.tsxManagementScreen 试点)**
```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

View File

@ -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.tsfontFamily 残留 14 处emoji 5 处monthlySummary.ts 4 + ai/chat.tsx 1tag 页已是模板试点。
**与 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: 管理页模板化批次 1budget / rules / remark-template
**Files:**
- Modify: `src/app/budget/index.tsx`、`src/app/rules/index.tsx`、`src/app/remark-template/index.tsx`
- [ ] **Step 1: 三页套 ManagementScreen**(参照试点 `src/app/tag/index.tsx` 与模板 `src/components/ManagementScreen.tsx`
通用映射:页面 state 消失modal/editing 由模板持有);手写 header/addBtn/Alert/FormModal 全删;`footer` 传 `common.clickEditLongDelete` 提示;`deleteConfirmTitle` 传各页 `t('xxx.deleteTitle')`
**budget/index.tsx**
- `items={budgets}`、`keyExtractor={b => b.id}`、`addLabel={t('budget.add')}`、`emptyText={t('budget.empty')}`
- renderItem保留现有 Card + 进度条结构Pressable onPress={openEdit} onLongPress={confirmDelete} 包裹)。`today` 改用 `toDateString(new Date())`(修掉 `toISOString().slice(0,10)` 的 UTC 偏移)
- formFields5 字段name/amount decimal-pad/period/categoryAccount/startDatedefaultValue 从 editing 取
- onSubmit保留校验amount parseFloat ≤ 0 → Alert + return falseadd/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`**(审计项)
- formFields7 字段priority numeric/counterpartyContains/memoContains/sourceAccount/categoryAccount/narration/tags
- onSubmit保留现有 Rule 组装逻辑addRule `{...rule, id: generateId('rule'), hits: 0}` / updateRule
**remark-template/index.tsx**
- 2 字段name/templaterenderItem 的模板文本 `fontFamily: 'monospace'``fontVariant: ['tabular-nums']`(审计项)
- onSubmitname 空回退 `t('common.untitled')`
- [ ] **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: 管理页模板化批次 2category / recurring / credit-card
**Files:**
- Modify: `src/app/category/index.tsx`、`src/app/recurring/index.tsx`、`src/app/credit-card/index.tsx`
- [ ] **Step 1: category/index.tsx — headerContent 类型 chips**
- 保留一个本地 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
- formFields3 字段name/linkedAccount/keywords编辑时带 defaultValue
- onSubmitadd 时 `type: catType`linkedAccount 空回退按类型Income:/Expenses:Uncategorizedkeywords 拆分逻辑保留edit 保留原逻辑
- renderItem保留名称/linkedAccount/关键词行
- [ ] **Step 2: recurring/index.tsx — 显式按钮用 handlers**
- renderItem保留信息行 Card卡片底部两个按钮编辑/删除)分别调 `handlers.openEdit()` / `handlers.confirmDelete()`**不**用长按,交互更显式);外层 Pressable 去掉
- formFields6 字段;`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}
- formFields8 字段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**
手写 headerarrow-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 与不再使用的 importIonicons/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: 星期头 i18n3 处硬编码中文)**
- 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 importcommon.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、preferencesheader 统一有返回箭头
4. **+按钮默认开全局面板**(新装/清数据后):任意页面弹 modal输入金额后点遮罩/Android 返回 → 弹「放弃修改」确认;无输入直接关
5. 设置→偏好里开关关闭 → +回到跳整页
6. 交易页左滑复制带标签的交易 → 标签预填
7. 报表月 Tab 日历、年报节奏图、TrendLine/CategoryPie 显示正常
8. 英文语言:日期选择器/日历星期头为英文缩写
---
## 完成后的项目状态
P1P5 全部交付spec §8 验收标准全达成。剩余已知非阻塞项不修记录TransactionCard memo 被内联 onPress 击穿性能todayStr useMemo 跨午夜不刷新NumpadSheet page 模式无可见返回按钮spec 允许_error.tsx monospace 保留(堆栈对齐)。

View File

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

View File

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

View File

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

68
docs/development.md Normal file
View File

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

18
package-lock.json generated
View File

@ -1,15 +1,13 @@
{ {
"name": "beancount-mobile", "name": "drift-ledger",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "beancount-mobile", "name": "drift-ledger",
"version": "0.1.0", "version": "0.1.0",
"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",
@ -2017,18 +2015,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@expo-google-fonts/caveat": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/@expo-google-fonts/caveat/-/caveat-0.4.2.tgz",
"integrity": "sha512-kCG35tRUX8Wi+u0Jh5ck3K1u7vZkdLnwRTuKMrEnlVmikvxQFXN1KjF5cL2vqRG7Hwm7648Zl8xe1BWAoIsPCA==",
"license": "MIT AND OFL-1.1"
},
"node_modules/@expo-google-fonts/quicksand": {
"version": "0.4.1",
"resolved": "https://registry.npmmirror.com/@expo-google-fonts/quicksand/-/quicksand-0.4.1.tgz",
"integrity": "sha512-LpGdmY+CDm/GTv29qDPy8Nz1a3idWO1R6Bb+ty7flS6vfHXsdOQTRKNz93SFHdXQj0FSvVhvMLe3wTtMgf312A==",
"license": "MIT AND OFL-1.1"
},
"node_modules/@expo/code-signing-certificates": { "node_modules/@expo/code-signing-certificates": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmmirror.com/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", "resolved": "https://registry.npmmirror.com/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",

View File

@ -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",
@ -11,8 +11,6 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"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",

31
plugins/README.md Normal file
View File

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

View File

@ -1,5 +1,6 @@
package com.beancount.mobile.accessibility package com.beancount.mobile.accessibility
import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReactMethod
@ -7,6 +8,7 @@ import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.WritableNativeArray import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
/** /**
* 无障碍服务 RN 桥接模块plan.md3.6 无障碍服务JS 接线 * 无障碍服务 RN 桥接模块plan.md3.6 无障碍服务JS 接线
@ -20,6 +22,10 @@ import com.facebook.react.bridge.ReadableArray
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) : class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) { ReactContextBaseJavaModule(reactContext) {
companion object {
private const val TAG = "AccessibilityBridge"
}
init { init {
ReactContextHolder.context = reactContext ReactContextHolder.context = reactContext
} }
@ -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 = BillingAccessibilityService.instance ?: reactContext.currentActivity
if (context == null) { if (context == null) {
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例") promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
return return
@ -222,7 +229,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
android.os.Handler(android.os.Looper.getMainLooper()).post { android.os.Handler(android.os.Looper.getMainLooper()).post {
try { try {
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction) val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction, currency)
floatingView.show() floatingView.show()
promise.resolve(true) promise.resolve(true)
} catch (e: Exception) { } catch (e: Exception) {
@ -230,4 +237,88 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
} }
} }
} }
/** 设置悬浮球开关状态。 */
@ReactMethod
fun setFloatingBallEnabled(enabled: Boolean, promise: Promise) {
BillingAccessibilityService.floatingBallEnabled = enabled
val service = BillingAccessibilityService.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)
}
}
} }

View File

@ -49,7 +49,14 @@ class BillingAccessibilityService : AccessibilityService() {
var instance: BillingAccessibilityService? = null var instance: BillingAccessibilityService? = 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 +86,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 +101,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)
}
} }
/** 动态配置服务能力(截图 + 页面变化监听)。 */ /** 动态配置服务能力(截图 + 页面变化监听)。 */
@ -110,7 +126,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,9 +148,8 @@ class BillingAccessibilityService : AccessibilityService() {
// 更新悬浮窗助手状态 // 更新悬浮窗助手状态
updateFloatingHelperVisibility(eventPackage) updateFloatingHelperVisibility(eventPackage)
if (PAYMENT_PACKAGES.contains(eventPackage)) { // 页面切换时检查是否有已记住的签名需要触发文本提取
scheduleContentChange() scheduleContentChange()
}
// 调试:如果是微信或支付宝,延迟 800ms 抓取并打印全屏无障碍文本内容 // 调试:如果是微信或支付宝,延迟 800ms 抓取并打印全屏无障碍文本内容
if (eventPackage == "com.tencent.mm" || eventPackage == "com.eg.android.AlipayGphone") { if (eventPackage == "com.tencent.mm" || eventPackage == "com.eg.android.AlipayGphone") {
@ -159,16 +182,20 @@ class BillingAccessibilityService : AccessibilityService() {
} }
} }
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()
@ -236,6 +263,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
@ -352,11 +380,12 @@ class BillingAccessibilityService : AccessibilityService() {
val pkg = topPackage ?: return val pkg = topPackage ?: return
val activity = topActivity ?: "" val activity = topActivity ?: ""
val sig = "$pkg|$activity" val sig = "$pkg|$activity"
val uiLabels = FloatingUiConfigStore.load(this).labels
if (pageSignatures.add(sig)) { if (pageSignatures.add(sig)) {
savePageSignatures() savePageSignatures()
Log.i(TAG, "已记住页面: $sig") Log.i(TAG, "已记住页面: $sig")
handler.post { handler.post {
android.widget.Toast.makeText(this, "已记住页面签名:\n$sig", android.widget.Toast.LENGTH_LONG).show() android.widget.Toast.makeText(this, "${uiLabels.pageRemembered}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
} }
// 抓取并提取屏幕所有无障碍文本 // 抓取并提取屏幕所有无障碍文本
@ -388,7 +417,7 @@ class BillingAccessibilityService : AccessibilityService() {
} }
} else { } else {
handler.post { handler.post {
android.widget.Toast.makeText(this, "该页面签名已存在:\n$sig", android.widget.Toast.LENGTH_LONG).show() android.widget.Toast.makeText(this, "${uiLabels.pageSignatureExists}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
} }
} }
} }
@ -451,6 +480,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,17 +503,18 @@ 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
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

@ -12,7 +12,33 @@ 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'; /**
* 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) {
@ -26,19 +52,30 @@ function copyDir(src, dest) {
} }
} }
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
function withAccessibilityService(config) { function withAccessibilityService(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.accessibility`;
// 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 源码(只复制 .kt 文件,不含 res/ 子目录)
const ktDest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/accessibility'); const pkgPath = appId.replace(/\./g, '/');
const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'accessibility');
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');
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(ktDest, f), content, 'utf8');
} }
} }
// res/xml 资源 // res/xml 资源
@ -46,6 +83,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, 'BillingAccessibilityService.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)) {
@ -67,12 +121,11 @@ function withAccessibilityService(config) {
let content = modConfig.modResults.contents; let content = modConfig.modResults.contents;
// 2a. 注入 importAccessibilityBridgePackage // 2a. 注入 importAccessibilityBridgePackage
if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) { content = content.replace(/^import\s+[\w.]+\.AccessibilityBridgePackage\s*$/gm, '');
content = content.replace( content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m, /^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`, `$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
); );
}
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage()) // 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
if (!content.includes('add(AccessibilityBridgePackage())')) { if (!content.includes('add(AccessibilityBridgePackage())')) {
@ -93,16 +146,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;
// 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. 添加无障碍服务声明 // 1. 添加无障碍服务声明
const serviceNode = { const serviceNode = {
$: { $: {
'android:name': 'com.beancount.mobile.accessibility.BillingAccessibilityService', 'android:name': `${PACKAGE}.BillingAccessibilityService`,
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE', 'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
'android:label': '账单识别', 'android:label': '浮记-账单识别',
'android:exported': 'false', 'android:exported': 'false',
}, },
'intent-filter': [{ 'intent-filter': [{
@ -125,7 +188,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'] === `${PACKAGE}.BillingAccessibilityService`
); );
if (!exists) { if (!exists) {
app.service.push(serviceNode); app.service.push(serviceNode);
@ -134,7 +197,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': `${PACKAGE}.OcrTileService`,
'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 +208,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'] === `${PACKAGE}.OcrTileService`
); );
if (!tileExists) { if (!tileExists) {
app.service.push(tileServiceNode); app.service.push(tileServiceNode);

View File

@ -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"

View File

@ -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.BillingAccessibilityService
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 * - 白名单过滤复用 BillingAccessibilityService.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 (!BillingAccessibilityService.PAYMENT_PACKAGES.contains(packageName)) return
val notification = sbn.notification val notification = sbn.notification
val extras = notification.extras val extras = notification.extras

View File

@ -11,18 +11,29 @@ 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';
}
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');
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 +47,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 +65,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);

View File

@ -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"

View File

@ -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,43 +23,41 @@ plugins/ppocr/
│ ├── OcrModule.kt # React Native BridgeONNX Runtime 推理 + det/rec 前后处理 │ ├── OcrModule.kt # React Native BridgeONNX 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 |
@ -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

View File

@ -29,11 +29,11 @@ import kotlin.math.max
import kotlin.math.min import kotlin.math.min
/** /**
* PP-OCRv5 (ONNX Runtime) React Native Bridgeplan.md3.4 Layer 2+决策 4 Config Plugin * PP-OCRv6 (ONNX Runtime) React Native Bridgeplan.md3.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.onnxPP-OCRv6 small PaddlePaddle 官方 HuggingFace 下载
* 字典ppocrv5_dict.txtPP-OCRv5 多语言字典18383 字符rec 模型 18385 维输出 = 字典 + blank + 特殊位 * 字典ppocrv6_dict.txtPP-OCRv6 多语言字典18708 字符rec 模型 18710 维输出 = 字典 + blank + 特殊位
* *
* 流水线 * 流水线
* 1. det文本检测bitmap DB 后处理得到文本框 * 1. det文本检测bitmap DB 后处理得到文本框
@ -62,6 +62,9 @@ class OcrModule(private val context: ReactApplicationContext) :
@Volatile private var initialized = false @Volatile private var initialized = false
@Volatile private var initFailed = false @Volatile private var initFailed = false
/** 模型文件目录filesystem 绝对路径)。非空时从该目录加载模型,否则回退到 assets。 */
@Volatile private var modelDir: String? = null
override fun getName(): String = OCR_MODULE_NAME override fun getName(): String = OCR_MODULE_NAME
override fun initialize() { override fun initialize() {
@ -70,7 +73,7 @@ class OcrModule(private val context: ReactApplicationContext) :
scope.launch { initEngine() } scope.launch { initEngine() }
} }
/** 从 assets 加载 det/rec ONNX 模型与字典。 */ /** 从 assets 或 filesystem 加载 det/rec ONNX 模型与字典。 */
private fun initEngine() { private fun initEngine() {
lock.lock() lock.lock()
try { try {
@ -83,22 +86,45 @@ class OcrModule(private val context: ReactApplicationContext) :
// 移动端关闭内存优化里的图优化级别过高(部分模型会崩) // 移动端关闭内存优化里的图优化级别过高(部分模型会崩)
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT) setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT)
} }
val dir = modelDir
val (det, rec, dict) = if (dir != null) {
// 从 filesystem 加载P8模型按需下载到本地目录
val detPath = dir + java.io.File.separator + ASSET_DET_MODEL
val recPath = dir + java.io.File.separator + ASSET_REC_MODEL
val dictPath = dir + java.io.File.separator + ASSET_DICT
Log.i(OCR_MODULE_NAME, "从 filesystem 加载模型: det=$detPath, rec=$recPath")
Triple(
env.createSession(detPath, opts),
env.createSession(recPath, opts),
loadDictionaryFromFile(dictPath)
)
} else {
// 回退:从 assets 加载(兼容未迁移的老用户)
val detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() } val detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() }
val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() } val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() }
val det = env.createSession(detBytes, opts) Log.i(OCR_MODULE_NAME, "从 assets 加载模型(兼容模式)")
val rec = env.createSession(recBytes, opts) Triple(
val dict = loadDictionary() 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}") Log.i(OCR_MODULE_NAME, "PP-OCRv6 ONNX 模型加载成功det+rec, dict=${dict.size}")
} catch (e: Exception) { } catch (e: Exception) {
initFailed = true initFailed = true
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e) Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
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") Log.e(OCR_MODULE_NAME, "请确认 assets 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
}
} finally { } finally {
lock.unlock() lock.unlock()
} }
@ -108,13 +134,13 @@ class OcrModule(private val context: ReactApplicationContext) :
private val recEnv: OrtEnvironment? get() = ortEnv private val recEnv: OrtEnvironment? get() = ortEnv
/** /**
* 加载 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 +153,17 @@ class OcrModule(private val context: ReactApplicationContext) :
return words return words
} }
/** 从 filesystem 路径加载字典文件。 */
private fun loadDictionaryFromFile(path: String): List<String> {
val words = mutableListOf<String>()
java.io.File(path).bufferedReader(Charsets.UTF_8).useLines { lines ->
lines.forEach { line ->
words.add(line.trimEnd('\r', '\n'))
}
}
return words
}
/** /**
* 识别图片文本 * 识别图片文本
* @param imageBase64 base64 编码的图片JPEG/PNG * @param imageBase64 base64 编码的图片JPEG/PNG
@ -209,6 +246,18 @@ class OcrModule(private val context: ReactApplicationContext) :
promise.resolve(initialized) promise.resolve(initialized)
} }
/** 设置模型文件目录(绝对路径)。若引擎已初始化则释放并重新加载。 */
@ReactMethod
fun setModelDir(dir: String, promise: Promise) {
modelDir = dir
if (initialized) {
release()
initFailed = false
scope.launch { initEngine() }
}
promise.resolve(true)
}
// ============== 推理流水线 ============== // ============== 推理流水线 ==============
private fun ensureReady() { private fun ensureReady() {
@ -509,8 +558,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 +577,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++
} }
} }
@ -650,10 +696,10 @@ class OcrModule(private val context: ReactApplicationContext) :
/** rec 单行最大宽度。 */ /** rec 单行最大宽度。 */
private const val REC_MAX_WIDTH = 320 private const val REC_MAX_WIDTH = 320
/** 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.txt6623会导致 CTC 解码乱码 // 注意:必须与 rec 模型配套,错用 v5 字典18383会导致 CTC 解码丢字符
private const val ASSET_DICT = "ppocrv5_dict.txt" private const val ASSET_DICT = "ppocrv6_dict.txt"
} }
} }

View File

@ -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())')) {

File diff suppressed because it is too large Load Diff

View File

@ -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"

View File

@ -12,37 +12,45 @@ 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';
}
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');
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())')) {

View File

@ -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"

View File

@ -3,7 +3,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
function withSizeOptimization(config) { function withSizeOptimization(config) {
// 1. 在 prebuild 时修改 gradle.properties // 1. 在 prebuild 时修改 gradle.properties (开启 R8/ProGuard 代码混淆 + 资源裁剪 + .so 库压缩)
config = withDangerousMod(config, [ config = withDangerousMod(config, [
'android', 'android',
async (modConfig) => { async (modConfig) => {
@ -11,18 +11,42 @@ function withSizeOptimization(config) {
const propertiesPath = path.join(projectRoot, 'gradle.properties'); const propertiesPath = path.join(projectRoot, 'gradle.properties');
if (fs.existsSync(propertiesPath)) { if (fs.existsSync(propertiesPath)) {
let content = fs.readFileSync(propertiesPath, 'utf8'); let content = fs.readFileSync(propertiesPath, 'utf8');
// 限制打包架构为 arm64-v8a
if (content.includes('reactNativeArchitectures=')) { if (content.includes('reactNativeArchitectures=')) {
content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a'); content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a');
} else { } else {
content += '\nreactNativeArchitectures=arm64-v8a\n'; content += '\nreactNativeArchitectures=arm64-v8a\n';
} }
// 1. 开启 R8 代码混淆与摇树裁剪
if (!content.includes('android.enableMinifyInReleaseBuilds=')) {
content += 'android.enableMinifyInReleaseBuilds=true\n';
} else {
content = content.replace(/android\.enableMinifyInReleaseBuilds=.*/, 'android.enableMinifyInReleaseBuilds=true');
}
// 1. 开启无用资源裁剪
if (!content.includes('android.enableShrinkResourcesInReleaseBuilds=')) {
content += 'android.enableShrinkResourcesInReleaseBuilds=true\n';
} else {
content = content.replace(/android\.enableShrinkResourcesInReleaseBuilds=.*/, 'android.enableShrinkResourcesInReleaseBuilds=true');
}
// 2. 开启 .so 动态库在 APK 内的高倍率压缩 (useLegacyPackaging=true)
if (!content.includes('expo.useLegacyPackaging=')) {
content += 'expo.useLegacyPackaging=true\n';
} else {
content = content.replace(/expo\.useLegacyPackaging=.*/, 'expo.useLegacyPackaging=true');
}
fs.writeFileSync(propertiesPath, content, 'utf8'); fs.writeFileSync(propertiesPath, content, 'utf8');
} }
return modConfig; return modConfig;
} }
]); ]);
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包 // 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包 + 字体裁剪
config = withDangerousMod(config, [ config = withDangerousMod(config, [
'android', 'android',
async (modConfig) => { async (modConfig) => {
@ -30,13 +54,99 @@ 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 }`
); );
}
// 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'); 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;
} }

View File

@ -1,4 +1,4 @@
{ {
"name": "size-optimization", "name": "drift-ledger-plugin-size-optimization",
"main": "app.plugin.js" "main": "app.plugin.js"
} }

View File

@ -11,18 +11,29 @@ 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';
}
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');
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 +59,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 +76,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);

View File

@ -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"

View File

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

View File

@ -50,6 +50,8 @@ export function calculateMonthlyStats(
}; };
} }
import { logger } from '../utils/logger';
/** /**
* AI * AI
*/ */
@ -59,6 +61,8 @@ export async function generateMonthlySummary(
month: number, month: number,
provider: AiProvider, provider: AiProvider,
): Promise<MonthlySummaryResult> { ): Promise<MonthlySummaryResult> {
logger.info('aiSummary', `开始生成 AI 月度财务总结 [${year}${month}月]`);
try {
const stats = calculateMonthlyStats(transactions, year, month); const stats = calculateMonthlyStats(transactions, year, month);
const messages = buildMonthlySummaryPrompt({ const messages = buildMonthlySummaryPrompt({
@ -69,23 +73,28 @@ export async function generateMonthlySummary(
}); });
const response = await provider.chat(messages); const response = await provider.chat(messages);
logger.info('aiSummary', `AI 月度财务总结生成成功 [${year}${month}月]`);
return { return {
stats, stats,
summary: removeThink(response), 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}`);
} }

View File

@ -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/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>
); );

View File

@ -1,5 +1,10 @@
/**
* spec §7.1
* header / 5
* /account
*/
import React, { useEffect, useMemo, useRef } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert } from 'react-native'; import { 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 { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
@ -7,14 +12,15 @@ 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/Card';
import { SpeedDial } from '../../components/SpeedDial';
import { TransactionCard } from '../../components/TransactionCard'; import { TransactionCard } from '../../components/TransactionCard';
import { AccountTree } from '../../components/AccountTree'; import { TodoStrip } from '../../components/TodoStrip';
import { TrendLine } from '../../components/charts/TrendLine'; import { TrendLine } from '../../components/charts/TrendLine';
import { calculateNetWorth } from '../../domain/netWorth'; import { calculateNetWorth } from '../../domain/netWorth';
import { groupByMonth } from '../../domain/chartStats'; import { groupByMonth } from '../../domain/chartStats';
import { buildAccountTree } from '../../domain/accountTree';
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/recurring'; import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/recurring';
import { calculateBudgetProgress } from '../../domain/budgets';
import { addDecimals, toDateString } from '../../domain/decimal';
import type { Transaction } from '../../domain/types';
export default function HomeScreen() { export default function HomeScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
@ -23,28 +29,18 @@ 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 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,19 +66,34 @@ 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)); // 最近 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 = (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;
}; };
// 3. 千分位格式化金额 // 问候语 + 日期行
const hour = new Date().getHours();
const greeting = hour < 12 ? t('home.greetingMorning') : hour < 18 ? t('home.greetingAfternoon') : t('home.greetingEvening');
const dateLine = new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' });
// 千分位格式化金额
const 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);
@ -94,105 +105,41 @@ export default function HomeScreen() {
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 }]}> <ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 净资产白卡 */}
{/* 净资产 Hero 卡片 */} <Card>
<Card <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorthTitle')}</Text>
style={{ <Text style={[theme.typography.display, { color: theme.colors.fgPrimary, marginVertical: 6, fontVariant: ['tabular-nums'] }]}>
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accentLight,
shadowColor: theme.colors.accent,
shadowOpacity: 0.15,
shadowRadius: 10,
}}
>
<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)} {fmtAmount(netWorth.netWorth)}
</Text> </Text>
<View style={styles.heroFooter}> <View style={[styles.heroFooter, { borderTopColor: theme.colors.divider }]}>
<View> <View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthExpense')}</Text>
{t('home.assets')} <Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
</Text> -{fmtAmount(currentMonth?.expense.toString() ?? '0')}
<Text style={[theme.typography.h3, { color: '#FFFFFF', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
{fmtAmount(netWorth.assets)}
</Text> </Text>
</View> </View>
<View style={{ alignItems: 'flex-end' }}> <View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthIncome')}</Text>
{t('home.liabilities')} <Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
</Text> +{fmtAmount(currentMonth?.income.toString() ?? '0')}
<Text style={[theme.typography.h3, { color: 'rgba(255,255,255,0.9)', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
{fmtAmount(netWorth.liabilities)}
</Text> </Text>
</View> </View>
</View> {budgetRemaining !== null && (
</Card> <View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.budgetRemaining')}</Text>
{/* Bento 双格排列 */} <Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
{(dueRecurring.length > 0 || currentMonth) && ( {fmtAmount(budgetRemaining)}
<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> </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>
</Card>
) : null}
{currentMonth ? (
<Card title={`${currentMonth.month.slice(5)} ${t('home.title')}`} style={styles.bentoCol}>
<View style={styles.bentoStats}>
<View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.income')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
+{fmtAmount(currentMonth.income.toString())}
</Text>
</View>
<View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.expense')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
-{fmtAmount(currentMonth.expense.toString())}
</Text>
</View>
</View>
</Card>
) : null}
</View> </View>
)} )}
</View>
</Card>
<TodoStrip />
{/* 月度趋势 */} {/* 月度趋势 */}
{monthlyData.length > 0 && ( {monthlyData.length > 0 && (
@ -201,18 +148,18 @@ export default function HomeScreen() {
</Card> </Card>
)} )}
{/* 账户与资产余额树 */} {/* 最近 5 条交易 */}
{accountNodes.length > 0 && ( {recentTxs.length > 0 && (
<Card title={t('home.accountTree')}> <Card title={t('home.recentTransactions')}>
<AccountTree nodes={accountNodes} /> {recentTxs.map(tx => (
<TransactionCard key={tx.id} transaction={tx} categoryId={categoryIdFor(tx)} onPress={() => router.push(`/transaction/${tx.id}`)} />
))}
<Pressable onPress={() => router.push('/(tabs)/transactions')} style={styles.viewAll} accessibilityRole="button" accessibilityLabel={t('home.viewAll')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>{t('home.viewAll')} </Text>
</Pressable>
</Card> </Card>
)} )}
</ScrollView> </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 +167,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 },
}); });

View File

@ -1,11 +1,10 @@
/** /**
* plan.md5.2 +5.3 * plan.md5.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';
@ -20,7 +19,7 @@ import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/Button';
import { MonthlyReport } from '../../components/charts/MonthlyReport'; import { BottomSheet } from '../../components/BottomSheet';
import { CategoryPie } from '../../components/charts/CategoryPie'; import { CategoryPie } from '../../components/charts/CategoryPie';
import { CalendarView } from '../../components/CalendarView'; import { CalendarView } from '../../components/CalendarView';
import { TransactionCard } from '../../components/TransactionCard'; import { TransactionCard } from '../../components/TransactionCard';
@ -28,10 +27,12 @@ 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/decimal';
import { periodRange, shiftAnchor, isoWeekNumber, type ReportPeriod } from '../../domain/periodNav';
export default function ReportScreen() { export default function ReportScreen() {
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 ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
@ -40,16 +41,12 @@ export default function ReportScreen() {
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl); const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl);
const aiModel = useSettingsStore(s => s.aiModel); const aiModel = useSettingsStore(s => s.aiModel);
// 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,111 +60,60 @@ 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 fmtAmount = (num: number) => { const fmtAmount = (num: number) => {
@ -190,9 +136,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 +150,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,20 +161,22 @@ 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, { opacity: pressed ? 0.6 : 1 }]}
accessibilityRole="button"
accessibilityLabel={t('report.menu')}
>
<Ionicons name="ellipsis-horizontal" size={22} color={theme.colors.accent} />
</Pressable> </Pressable>
<Pressable onPress={handleExportImage} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="share-outline" size={22} color={theme.colors.accent} />
</Pressable>
</View>
</View> </View>
{/* Tab 选择切换栏 */} {/* Tab 选择切换栏 */}
@ -239,20 +189,20 @@ export default function ReportScreen() {
<Pressable <Pressable
key={tab.key} key={tab.key}
onPress={() => { onPress={() => {
setActiveTab(tab.key); setPeriod(tab.key);
setSelectedReportDate(null); setSelectedReportDate(null);
}} }}
style={[ style={[
styles.tabBtn, styles.tabBtn,
activeTab === tab.key && { borderBottomColor: theme.colors.accent } period === tab.key && { borderBottomColor: theme.colors.accent }
]} ]}
> >
<Text <Text
style={[ style={[
theme.typography.body, theme.typography.body,
{ {
color: activeTab === tab.key ? theme.colors.accent : theme.colors.fgSecondary, color: period === tab.key ? theme.colors.accent : theme.colors.fgSecondary,
fontWeight: activeTab === tab.key ? '700' : '400', fontWeight: period === tab.key ? '700' : '400',
paddingVertical: 10 paddingVertical: 10
} }
]} ]}
@ -263,57 +213,25 @@ export default function ReportScreen() {
))} ))}
</View> </View>
{/* 日期选择切换器 */} {/* 统一周期切换器 */}
{activeTab === 'weekly' && (
<View style={styles.monthSwitcher}> <View style={styles.monthSwitcher}>
<Pressable onPress={() => changeWeek(-7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}> <Pressable onPress={() => shift(-1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} /> <Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable> </Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{weeklyRange.label} {periodLabel}
</Text> </Text>
<Pressable onPress={() => changeWeek(7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}> <Pressable onPress={() => shift(1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} /> <Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable> </Pressable>
</View> </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) ||
(activeTab === 'annual' && annualTxs.length === 0)) ? (
<Card title={t('report.title')}> <Card title={t('report.title')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{t('report.empty')} {t('report.empty')}
@ -321,72 +239,48 @@ export default function ReportScreen() {
</Card> </Card>
) : ( ) : (
<> <>
{/* 周报表看板 */} {/* 周/月收支看板 */}
{activeTab === 'weekly' && ( {period !== 'annual' && (
<> <>
<Card title={t('report.weeklyTitle')}> <Card title={t(period === 'weekly' ? 'report.weeklyTitle' : 'report.monthlyTitle')}>
<View style={styles.statsRow}> <View style={styles.statsRow}>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyIncome')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyIncome' : 'report.monthlyIncome')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.income)}</Text> <Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(rangeStats.income)}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyExpense')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyExpense' : 'report.monthlyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>-{fmtAmount(weeklyStats.expense)}</Text> <Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(-rangeStats.expense)}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyNet')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyNet' : 'report.monthlyNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.net)}</Text> <Text style={[theme.typography.h3, { color: theme.colors.accent, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(rangeStats.net)}</Text>
</View> </View>
</View> </View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('report.weeklyStats', { count: weeklyStats.count })} {t(period === 'weekly' ? 'report.weeklyStats' : 'report.monthlyStats', { count: rangeStats.count })}
</Text> </Text>
</Card> </Card>
<CategoryPie transactions={weeklyTxs} /> <CategoryPie transactions={rangeTxs} />
</>
)}
{/* 月度报表看板 */} {/* 月 Tab 专属:日历 + 选中日期明细 + 净资产趋势 */}
{activeTab === 'monthly' && ( {period === '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 <CalendarView
transactions={transactions} transactions={transactions}
year={viewYear} year={anchorYear}
month={viewMonth} month={anchorMonth}
onDayPress={setSelectedReportDate} onDayPress={setSelectedReportDate}
/> />
{selectedReportDate && selectedReportDate.startsWith(monthLabel) && ( {selectedReportDate && selectedReportDate.startsWith(monthLabel) && (
<Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}> <Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}>
{selectedDayTxs.map(t => ( {selectedDayTxs.map(tx => (
<TransactionCard <TransactionCard
key={t.id} key={tx.id}
transaction={t} transaction={tx}
onPress={() => router.push(`/transaction/${t.id}`)} onPress={() => router.push(`/transaction/${tx.id}`)}
/> />
))} ))}
{selectedDayTxs.length === 0 && ( {selectedDayTxs.length === 0 && (
@ -400,15 +294,39 @@ export default function ReportScreen() {
<NetWorthChart transactions={transactions} dates={netWorthDates} /> <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,7 +352,6 @@ 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: { padding: 4 }, iconBtn: { padding: 4 },
monthSwitcher: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20, paddingBottom: 12 }, 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' }, monthBtn: { width: 32, height: 32, borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center' },
@ -443,4 +360,5 @@ const styles = StyleSheet.create({
tabBtn: { flex: 1, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' }, tabBtn: { flex: 1, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' }, statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' },
statItem: { alignItems: 'center', gap: 4 }, statItem: { alignItems: 'center', gap: 4 },
menuRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14 },
}); });

View File

@ -1,11 +1,12 @@
import React from 'react'; import React from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, type Href } from 'expo-router'; import { useRouter, type Href } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Touchable } from '../../components/Touchable';
export default function SettingsScreen() { export default function SettingsScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
@ -13,55 +14,69 @@ export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const renderNavLink = (icon: keyof typeof Ionicons.glyphMap, label: string, path: Href) => ( const renderNavLink = (icon: keyof typeof Ionicons.glyphMap, label: string, path: Href) => (
<Pressable <Touchable
onPress={() => router.push(path)} onPress={() => router.push(path)}
accessibilityRole="link" accessibilityRole="link"
accessibilityLabel={label} accessibilityLabel={label}
style={({ pressed }) => [ style={[
styles.navRow, styles.navRow,
{ {
borderBottomColor: theme.colors.divider, borderBottomColor: theme.colors.divider,
opacity: pressed ? 0.6 : 1,
}, },
]} ]}
> >
<Ionicons name={icon} size={20} color={theme.colors.accent} /> <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 }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 12 }]}>
{label} {label}
</Text> </Text>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} /> <Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
</Pressable> </Touchable>
); );
return ( return (
<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 }]}> <ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 数据与基础配置跳转 */} {/* 账户与分类 */}
<Card title={t('settings.dataManagement')}> <Card title={t('settings.groupAccount')}>
<View style={styles.listGroup}> <View style={styles.listGroup}>
{renderNavLink('git-branch-outline', t('tab.rules'), '/rules')}
{renderNavLink('wallet-outline', t('account.title'), '/account')} {renderNavLink('wallet-outline', t('account.title'), '/account')}
{renderNavLink('list', t('settings.categories'), '/category')} {renderNavLink('list', t('settings.categories'), '/category')}
{renderNavLink('pricetags', t('settings.tags'), '/tag')} {renderNavLink('pricetags', t('settings.tags'), '/tag')}
{renderNavLink('pie-chart', t('settings.budgets'), '/budget')} {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('card-outline', t('settings.creditCards'), '/credit-card' as Href)}
{renderNavLink('text-outline', t('remark.title'), '/remark-template' as Href)} {renderNavLink('text-outline', t('remark.title'), '/remark-template' as Href)}
</View> </View>
</Card> </Card>
{/* 系统及高级配置跳转 */} {/* 记账自动化 */}
<Card title={t('settings.systemFeaturesTitle')}> <Card title={t('settings.groupAutomation')}>
<View style={styles.listGroup}> <View style={styles.listGroup}>
{renderNavLink('cog-outline', t('settings.aiSettingsTitle'), '/settings/ai')} {renderNavLink('git-branch-outline', t('tab.rules'), '/rules')}
{renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)} {renderNavLink('repeat-outline', t('settings.recurringTitle'), '/recurring')}
{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)} {renderNavLink('flash-outline', t('automation.title'), '/automation' as Href)}
{renderNavLink('download-outline', t('settings.import'), '/import' as Href)}
</View>
</Card>
{/* 数据 */}
<Card title={t('settings.groupData')}>
<View style={styles.listGroup}>
{renderNavLink('cloud-upload-outline', t('settings.syncBackup'), '/settings/sync')}
{renderNavLink('pulse-outline', t('settings.diagnostics'), '/settings/diagnostics' as Href)}
{renderNavLink('document-text-outline', t('settings.appLogs'), '/settings/logs' as Href)}
</View>
</Card>
{/* 偏好 */}
<Card title={t('settings.groupPreferences')}>
<View style={styles.listGroup}>
{renderNavLink('phone-portrait-outline', t('settings.preferencesEntry'), '/settings/preferences')}
{renderNavLink('cog-outline', t('settings.llmTitle'), '/settings/ai')}
{renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)}
</View> </View>
</Card> </Card>

View File

@ -1,71 +1,105 @@
/** /**
* plan.md1.4 * spec §7.2 + 线
* * chip FilterSheet//
* : * // + /
* - SearchBar (narration/payee) * /settings/diagnostics
* - (///)
* - useSearch
* - TransactionCard +
* -
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { Alert, 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 { useNumpadUiStore } from '../../store/numpadUiStore';
import { useTheme, createCommonStyles } from '../../theme'; import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { SearchBar } from '../../components/SearchBar'; import { SearchBar } from '../../components/SearchBar';
import { TransactionCard } from '../../components/TransactionCard'; import { FilterSheet, type AdvancedFilterValues } from '../../components/FilterSheet';
import { Ionicons } from '@expo/vector-icons'; import { SwipeableTransactionCard } from '../../components/SwipeableTransactionCard';
import { groupTransactionsByDate } from '../../components/transactionGroups';
import { useSearch, type SearchFilters } from '../../hooks/useSearch'; import { useSearch, type SearchFilters } from '../../hooks/useSearch';
import { toDateString } from '../../domain/decimal';
import type { Transaction } from '../../domain/types';
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 router = useRouter(); const router = useRouter();
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const deleteTransaction = useLedgerStore(s => s.deleteTransaction);
const categories = useMetadataStore(s => s.categories);
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('');
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 = (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;
};
// 左滑:复制(预填今天的录入页)
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 移除)
const handleDelete = (tx: Transaction) => {
Alert.alert(
t('transactions.deleteTitle'),
t('transactions.deleteMessage', { name: tx.narration || tx.payee || tx.date.slice(0, 10) }),
[
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => { deleteTransaction(tx.raw).catch(e => Alert.alert(t('common.error'), String(e))); } },
],
);
};
const 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 +114,79 @@ 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')} />
value={keyword}
onChangeText={setKeyword}
placeholder={t('transactions.searchPlaceholder')}
/>
{/* 方向筛选 */} {/* 方向筛选 + 高级筛选入口 */}
<View style={styles.filterRow}> <View style={styles.filterRow}>
{directionTabs.map(tab => ( {directionTabs.map(tab => (
<Pressable <Pressable key={tab.key} onPress={() => setDirection(tab.key)}
key={tab.key} style={[commonStyles.chip, direction === tab.key && commonStyles.chipActive]}>
onPress={() => setDirection(tab.key)} <Text style={[commonStyles.chipText, direction === tab.key && commonStyles.chipTextActive]}>{tab.label}</Text>
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.flex1} />
{/* 高级筛选切换 */}
<View style={[styles.filterRow, { paddingBottom: 0 }]}>
<Pressable <Pressable
onPress={() => setShowAdvancedFilters(!showAdvancedFilters)} onPress={() => setFilterSheetOpen(true)}
style={({ pressed }) => [ style={[commonStyles.chip, advancedActive && commonStyles.chipActive, styles.filterBtn]}
commonStyles.chip, accessibilityRole="button"
showAdvancedFilters && commonStyles.chipActive, accessibilityLabel={t('transactions.filter')}
{ opacity: pressed ? 0.8 : 1 },
]}
> >
<Text style={[ <Ionicons name="options-outline" size={14} color={advancedActive ? theme.colors.fgInverse : theme.colors.fgSecondary} />
commonStyles.chipText, <Text style={[commonStyles.chipText, advancedActive && commonStyles.chipTextActive]}>{t('transactions.filter')}</Text>
showAdvancedFilters && commonStyles.chipTextActive,
{ fontSize: 12 },
]}>
{t('transactions.advancedFilter')}
</Text>
</Pressable> </Pressable>
</View> </View>
{/* 高级筛选面板 */} <SectionList
{showAdvancedFilters && ( sections={sections}
<View style={{ paddingHorizontal: 16, paddingBottom: 8, gap: 6 }}> keyExtractor={(item, index) => `${item.id}-${index}`}
{/* 账户筛选 */}
<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>
)}
<FlatList
data={filtered}
keyExtractor={(item) => item.id}
renderItem={({ item: tx }) => ( renderItem={({ item: tx }) => (
<TransactionCard <SwipeableTransactionCard
transaction={tx} transaction={tx}
categoryId={categoryIdFor(tx)}
showDate={false}
onPress={() => router.push(`/transaction/${tx.id}`)} onPress={() => router.push(`/transaction/${tx.id}`)}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
/> />
)} )}
renderSectionHeader={({ section }) => (
<View style={styles.sectionHeader}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{dateLabel(section.date)}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
{t('transactions.daySummary', { income: `+${section.income}`, expense: `-${section.expense}` })}
</Text>
</View>
)}
ListHeaderComponent={ ListHeaderComponent={
<View> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingBottom: 8 }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingHorizontal: 16, paddingBottom: 8 }]}>
{filtered.length === allTransactions.length {filtered.length === allTransactions.length
? t('transactions.countTotal', { count: filtered.length }) ? t('transactions.countTotal', { count: filtered.length })
: t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })} : t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })}
</Text> </Text>
</View>
} }
ListEmptyComponent={ ListEmptyComponent={
<Card title={t('transactions.noMatchTitle')}> <Card title={t('transactions.noMatchTitle')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{allTransactions.length === 0 {allTransactions.length === 0 ? t('transactions.noMatchEmpty') : t('transactions.noMatchFiltered')}
? t('transactions.noMatchEmpty')
: t('transactions.noMatchFiltered')}
</Text> </Text>
</Card> </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 +194,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 },
}); });

View File

@ -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>

View File

@ -3,47 +3,80 @@ 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 { parseDecimal } from '../domain/decimal';
import { useAutomationStore } from '../store/automationStore'; import { useAutomationStore } from '../store/automationStore';
import { FileSystemBackend } from '../services/fileSystemBackend'; import { FileSystemBackend } from '../services/fileSystemBackend';
import { useNumpadUiStore } from '../store/numpadUiStore';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { LockScreen } from '../components/LockScreen'; import { LockScreen } from '../components/LockScreen';
import { NumpadSheetHost } from '../components/NumpadSheetHost';
import { 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 { buildPostings } from '../domain/transactionBuilder';
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, pendingDrafts } from '../services/automationPipeline';
import { getAccessibilityBridge } from '../services/accessibilityBridge'; import { getAccessibilityBridge } from '../services/accessibilityBridge';
import { pushFloatingUiConfig } from '../services/floatingUiConfig';
import { ensureOcrModels } from '../services/modelDownloader';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { import { expoLogBackend } from '../services/logBackend';
useFonts,
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);
}, [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;
}
function sanitizeLogText(text: string): string {
if (!text) return '';
// 脱敏验证码 (4-6位) 及长卡号/身份证 (8位以上数字)
return text
.replace(/\b(\d{4,6})\b(?=.*(?:验证码|动态码|校验码|code|Code))/gi, '***')
.replace(/\b(\d{4})\d{8,11}(\d{4})\b/g, '$1****$2');
}
/** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */ /** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */
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,16 +86,6 @@ 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);
@ -72,8 +95,15 @@ function AppShell() {
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,31 +135,27 @@ 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) { // P8确保 OCR 模型文件可用(首次启动从 APK assets 拷贝到 filesDir
const mobileContent = await FileSystem.readAsStringAsync(mobilePath); ensureOcrModels().catch(e => {
if (mobileContent.trim()) { logger.warn('layout', 'OCR 模型初始化失败(非关键路径,截图 OCR 将降级)', e);
const mainInfo = await FileSystem.getInfoAsync(mainPath); });
let mainContent = '';
if (mainInfo.exists) { const mainPath = FileSystem.documentDirectory + 'main.bean';
mainContent = await FileSystem.readAsStringAsync(mainPath);
} // 架构说明main.bean 作为主账本文件,包含所有 Beancount 指令open/close/option/include 等),
const mergedContent = `${mainContent.trimEnd()}\n\n${mobileContent.trim()}\n`.trim(); // App 直接对其进行读写与解析。
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);
}
// 2. 检查并读取本地真实主账本 // 2. 检查并读取本地真实主账本
FileSystem.getInfoAsync(mainPath).then(async (info) => { FileSystem.getInfoAsync(mainPath).then(async (info) => {
@ -147,6 +173,14 @@ function AppShell() {
const completed = useSettingsStore.getState().onboardingCompleted; const completed = useSettingsStore.getState().onboardingCompleted;
const locked = useSettingsStore.getState().appLockEnabled; const locked = useSettingsStore.getState().appLockEnabled;
const floatingEnabled = useSettingsStore.getState().floatingBallEnabled;
// 开启/初始化时同步悬浮球开关至原生无障碍服务
const bridge = getAccessibilityBridge();
if (bridge) {
bridge.setFloatingBallEnabled(floatingEnabled).catch(() => {});
}
if (!completed) { if (!completed) {
setPhase('onboarding'); setPhase('onboarding');
} else if (locked) { } else if (locked) {
@ -156,7 +190,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 +202,7 @@ function AppShell() {
if (phase === 'ready') { if (phase === 'ready') {
const cleanup = setupDeepLinking((action) => { const cleanup = setupDeepLinking((action) => {
if (action.type === 'add-transaction') { if (action.type === 'add-transaction') {
router.push('/transaction/new'); useNumpadUiStore.getState().open();
} else if (action.type === 'open-tab') { } else if (action.type === 'open-tab') {
if (action.tab === 'home') router.push('/(tabs)'); if (action.tab === 'home') router.push('/(tabs)');
else if (action.tab === 'transactions') router.push('/(tabs)/transactions'); else if (action.tab === 'transactions') router.push('/(tabs)/transactions');
@ -178,7 +212,7 @@ function AppShell() {
} else if (action.type === 'import-csv') { } else if (action.type === 'import-csv') {
router.push('/import'); router.push('/import');
} else if (action.type === 'ocr-camera' || action.type === 'ocr-image') { } else if (action.type === 'ocr-camera' || action.type === 'ocr-image') {
router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } }); useNumpadUiStore.getState().open({ autoOcr: true });
} else if (action.type === 'voice-input') { } else if (action.type === 'voice-input') {
router.push('/(tabs)/settings'); router.push('/(tabs)/settings');
} }
@ -196,13 +230,14 @@ function AppShell() {
const subscriptions = [ const subscriptions = [
DeviceEventEmitter.addListener('billingNotification', (event) => { DeviceEventEmitter.addListener('billingNotification', (event) => {
try { try {
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}, 内容: ${event.text}`); const safeText = sanitizeLogText(event.text || '');
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}, 内容: ${safeText}`);
const bill = parseNotification(event); const bill = parseNotification(event);
if (bill) { if (bill) {
logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${event.text}`); logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${safeText}`);
handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`); handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`);
} else { } else {
logger.debug('layout', `通知未匹配为账单: [${event.title}] ${event.text}`); logger.debug('layout', `通知未匹配为账单: [${event.title}] ${safeText}`);
} }
} catch (e) { } catch (e) {
logger.error('layout', '通知解析失败', e); logger.error('layout', '通知解析失败', e);
@ -210,7 +245,8 @@ function AppShell() {
}), }),
DeviceEventEmitter.addListener('billingSms', (event) => { DeviceEventEmitter.addListener('billingSms', (event) => {
try { try {
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}, 内容: ${event.body}`); const safeBody = sanitizeLogText(event.body || '');
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}, 内容: ${safeBody}`);
const smsEvent = { const smsEvent = {
sender: event.sender || event.address || '', sender: event.sender || event.address || '',
body: event.body, body: event.body,
@ -218,75 +254,74 @@ function AppShell() {
}; };
const bill = parseSms(smsEvent); const bill = parseSms(smsEvent);
if (bill) { if (bill) {
logger.debug('layout', `短信解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始短信: ${event.body}`); logger.debug('layout', `短信解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始短信: ${safeBody}`);
handleIncomingBillEvent('sms', bill, event.body); handleIncomingBillEvent('sms', bill, event.body);
} else { } else {
logger.debug('layout', `短信未匹配为账单: ${event.body}`); logger.debug('layout', `短信未匹配为账单: ${safeBody}`);
} }
} catch (e) { } catch (e) {
logger.error('layout', '短信解析失败', e); logger.error('layout', '短信解析失败', e);
} }
}), }),
DeviceEventEmitter.addListener('billingScreenshot', (event) => { DeviceEventEmitter.addListener('billingScreenshot', async (event) => {
try {
logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`); logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
processScreenshotEvent(event).catch((e: unknown) => { await processScreenshotEvent(event);
logger.error('layout', '截图 OCR 处理失败', e); } catch (e) {
}); logger.error('layout', `截图 OCR 处理失败 (包名: ${event.packageName}, 是否有Base64: ${Boolean(event.imageBase64)})`, e instanceof Error ? { message: e.message, stack: e.stack } : String(e));
}
}), }),
DeviceEventEmitter.addListener('billingOpenApp', (res) => { DeviceEventEmitter.addListener('billingOpenApp', async (event) => {
try { try {
const res = event as NativeOpenAppEvent;
logger.info('layout', `收到原生悬浮窗跳转 App 请求: ${JSON.stringify(res)}`); logger.info('layout', `收到原生悬浮窗跳转 App 请求: ${JSON.stringify(res)}`);
if (res.draftId) { if (res.confirmed) {
try { // 已由原生悬浮窗确认入账,前端仅记录日志或提示
const { pendingDrafts } = require('../services/automationPipeline'); return;
pendingDrafts.delete(res.draftId);
} catch (err) {
// 忽略
} }
if (!res.amount && !res.merchant) {
logger.warn('layout', 'billingOpenApp 数据不完整,跳过', JSON.stringify(res));
return;
} }
const amt = res.amount || '0'; const amt = res.amount ?? '0';
const currency = 'CNY'; const validAmount = parseDecimal(amt);
let postings: any[] = []; if (!validAmount) {
if (res.direction === 'expense') { if (amt.length > 0) {
postings = [ logger.warn('layout', 'billingOpenApp 金额格式错误', amt);
{ account: res.account, amount: `-${amt}`, currency },
{ account: res.category, amount: amt, currency }
];
} else if (res.direction === 'income') {
postings = [
{ account: res.account, amount: amt, currency },
{ account: res.category, amount: `-${amt}`, currency }
];
} else { } else {
postings = [ logger.warn('layout', 'billingOpenApp 金额无效', amt);
{ account: res.account, amount: `-${amt}`, currency },
{ account: res.category, amount: amt, currency }
];
} }
return;
}
const direction = res.direction ?? 'expense';
const categoryAccount = res.category ?? 'Expenses:未分类';
const sourceAccount = res.account ?? 'Assets:未知';
const currency = res.currency ?? 'CNY';
const postings = [
{ account: sourceAccount, amount: direction === 'expense' ? `-${validAmount}` : validAmount, currency },
{ account: categoryAccount, amount: direction === 'expense' ? validAmount : `-${validAmount}`, currency }
];
const draft = { const draft = {
date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0], date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0],
payee: res.merchant || undefined, payee: res.merchant || undefined,
narration: res.narration || '', narration: res.narration || '',
postings postings
}; };
router.push({ useNumpadUiStore.getState().open({ draftJson: JSON.stringify(draft) });
pathname: '/transaction/new',
params: { draftJson: JSON.stringify(draft) }
});
} catch (e) { } catch (e) {
logger.error('layout', '处理悬浮窗跳转 App 失败', e); logger.error('layout', '处理悬浮窗跳转 App 失败', e);
} }
}), }),
DeviceEventEmitter.addListener('billingPageRemembered', (event) => { DeviceEventEmitter.addListener('billingPageRemembered', (event) => {
logger.info('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`); logger.debug('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`);
logger.info('layout', `[无障碍调试] 记住该页面时捕获的所有文本内容: ${JSON.stringify(event.texts)}`); logger.debug('layout', `[无障碍调试] 记住该页面时捕获的所有文本内容: ${JSON.stringify(event.texts)}`);
}), }),
DeviceEventEmitter.addListener('billingDebugNodes', async (event) => { DeviceEventEmitter.addListener('billingDebugNodes', async (event) => {
const { package: pkg, activity, texts, isManual } = event; const { package: pkg, activity, texts, isManual } = event;
const sigKey = `${pkg}|${activity}`; const sigKey = `${pkg}|${activity}`;
logger.info('layout', `[无障碍监听] 页面特征信号: ${sigKey}`); logger.debug('layout', `[无障碍监听] 页面特征信号: ${sigKey}`);
logger.info('layout', `[无障碍监听] 页面提取到的文本内容: ${JSON.stringify(texts)}`); logger.debug('layout', `[无障碍监听] 页面提取到的文本内容: ${JSON.stringify(texts?.slice(0, 5))}`);
try { try {
const bridge = getAccessibilityBridge(); const bridge = getAccessibilityBridge();
@ -295,7 +330,7 @@ function AppShell() {
const signatures = await bridge.getPageSignatures(); const signatures = await bridge.getPageSignatures();
const isWhitelisted = signatures.some(s => s.signature === sigKey); const isWhitelisted = signatures.some(s => s.signature === sigKey);
if (!isWhitelisted) { if (!isWhitelisted) {
logger.info('layout', `[无障碍监听] 页面 ${sigKey} 未在自动记账白名单中,拒绝弹窗`); logger.debug('layout', `[无障碍监听] 页面 ${sigKey} 未在自动记账白名单中,拒绝弹窗`);
return; return;
} }
} }
@ -314,11 +349,12 @@ function AppShell() {
if (isDetail) { if (isDetail) {
const success = await parseAndProcessAccessibilityTexts(texts, pkg); const success = await parseAndProcessAccessibilityTexts(texts, pkg);
if (!success) { if (!success) {
logger.info('layout', '[无障碍] 微信详情页直接文本解析未成功,降级触发 OCR 识别'); const textSnippet = texts.slice(0, 5).join(' | ');
logger.info('layout', `[无障碍] 微信详情页直接文本解析未成功 (截获文本前5句: [${textSnippet}]),降级触发 OCR 识别`);
bridge?.triggerManualOcr(); bridge?.triggerManualOcr();
} }
} else { } else {
logger.info('layout', '[无障碍] 微信非详情页面,不触发记账与截图'); logger.debug('layout', '[无障碍] 微信非详情页面,不触发记账与截图');
} }
} else if (pkg === 'com.eg.android.AlipayGphone') { } else if (pkg === 'com.eg.android.AlipayGphone') {
// 支付宝:识别是否是详情页 // 支付宝:识别是否是详情页
@ -326,11 +362,12 @@ function AppShell() {
if (isDetail) { if (isDetail) {
const success = await parseAndProcessAccessibilityTexts(texts, pkg); const success = await parseAndProcessAccessibilityTexts(texts, pkg);
if (!success) { if (!success) {
logger.info('layout', '[无障碍] 支付宝详情页直接文本解析未成功,降级触发 OCR 识别'); const textSnippet = texts.slice(0, 5).join(' | ');
logger.info('layout', `[无障碍] 支付宝详情页直接文本解析未成功 (截获文本前5句: [${textSnippet}]),降级触发 OCR 识别`);
bridge?.triggerManualOcr(); bridge?.triggerManualOcr();
} }
} else { } else {
logger.info('layout', '[无障碍] 支付宝非详情页面,不触发记账与截图'); logger.debug('layout', '[无障碍] 支付宝非详情页面,不触发记账与截图');
} }
} else { } else {
// 其他白名单应用如各大银行、QQ、TIM等直接通过截图 + OCR 识别 // 其他白名单应用如各大银行、QQ、TIM等直接通过截图 + OCR 识别
@ -349,8 +386,11 @@ function AppShell() {
// 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 +399,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 +430,6 @@ function AppShell() {
<StatusBar style={isDark ? 'light' : 'dark'} /> <StatusBar style={isDark ? 'light' : 'dark'} />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}> <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}>
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="transaction/new" options={{ headerShown: 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 +440,34 @@ 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 (
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider> <ThemeProvider>
<FloatingUiConfigSyncer />
<AppShell /> <AppShell />
</ThemeProvider> </ThemeProvider>
</GestureHandlerRootView>
); );
} }

View File

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

View File

@ -1,14 +1,15 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, 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 { 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/Card';
import { AccountCreateModal } from '../../components/AccountCreateModal';
import { FormModal, type FormField } from '../../components/FormModal'; import { FormModal, type FormField } from '../../components/FormModal';
import { addDecimals } from '../../domain/decimal'; import { ScreenHeader } from '../../components/ScreenHeader';
import { toDateString } from '../../domain/decimal';
import { computeAccountBalances } from '../../domain/ledger'; import { computeAccountBalances } from '../../domain/ledger';
type TabType = 'assets' | 'liabilities' | 'expenses' | 'income'; type TabType = 'assets' | 'liabilities' | 'expenses' | 'income';
@ -16,7 +17,6 @@ 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 ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts); const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
@ -111,7 +111,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'));
@ -134,18 +134,29 @@ export default function AccountScreen() {
{ key: 'income', label: t('account.tabIncome') }, { key: 'income', label: t('account.tabIncome') },
]; ];
const rootTypeOptions = [
{ label: t('account.rootTypes.Assets'), value: 'Assets', subLabel: 'Assets' },
{ label: t('account.rootTypes.Liabilities'), value: 'Liabilities', subLabel: 'Liabilities' },
{ label: t('account.rootTypes.Expenses'), value: 'Expenses', subLabel: 'Expenses' },
{ label: t('account.rootTypes.Income'), value: 'Income', subLabel: 'Income' },
{ label: t('account.rootTypes.Equity'), value: 'Equity', subLabel: 'Equity' },
];
const fields: FormField[] = [ const fields: FormField[] = [
{ {
key: 'type', key: 'type',
label: t('account.fieldType'), label: t('account.fieldType'),
placeholder: 'Assets / Liabilities / Expenses / Income', type: 'dropdown',
options: rootTypeOptions,
defaultValue: tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income', defaultValue: tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income',
flex: 1,
}, },
{ {
key: 'name', key: 'name',
label: t('account.fieldName'), label: t('account.fieldName'),
placeholder: t('account.fieldNamePlaceholder'), placeholder: t('account.fieldNamePlaceholder'),
defaultValue: '', defaultValue: '',
flex: 1.5,
}, },
{ {
key: 'currency', key: 'currency',
@ -157,15 +168,7 @@ export default function AccountScreen() {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
{/* 头部导航 */} <ScreenHeader title={t('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 }]}> <View style={[styles.tabBar, { borderBottomColor: theme.colors.border }]}>
@ -207,6 +210,8 @@ export default function AccountScreen() {
{filteredAccounts.map(account => { {filteredAccounts.map(account => {
const shortName = account.slice(prefix.length); const shortName = account.slice(prefix.length);
const rootType = account.split(':')[0] as 'Assets' | 'Liabilities' | 'Expenses' | 'Income' | 'Equity';
const localizedRoot = t(`account.rootLabels.${rootType}`);
const balance = balances.get(account) ?? '0.00'; const balance = balances.get(account) ?? '0.00';
const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY'; const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY';
@ -217,7 +222,7 @@ export default function AccountScreen() {
{t('account.fullName')} {t('account.fullName')}
</Text> </Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{account} {account} {localizedRoot ? `(${localizedRoot})` : ''}
</Text> </Text>
</View> </View>
<View style={styles.infoRow}> <View style={styles.infoRow}>
@ -266,10 +271,9 @@ export default function AccountScreen() {
</ScrollView> </ScrollView>
{/* 新开户对话框 */} {/* 新开户对话框 */}
<FormModal <AccountCreateModal
visible={isAdding} visible={isAdding}
title={t('account.addModalTitle')} defaultType={tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income'}
fields={fields}
onConfirm={handleAddAccount} onConfirm={handleAddAccount}
onCancel={() => setIsAdding(false)} onCancel={() => setIsAdding(false)}
/> />
@ -289,7 +293,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,7 +305,6 @@ 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 },
tabBar: { flexDirection: 'row', borderBottomWidth: 1, paddingHorizontal: 8 }, tabBar: { flexDirection: 'row', borderBottomWidth: 1, paddingHorizontal: 8 },
tabItem: { flex: 1, alignItems: 'center', paddingVertical: 12 }, tabItem: { flex: 1, alignItems: 'center', paddingVertical: 12 },
content: { padding: 16, gap: 12 }, content: { padding: 16, gap: 12 },

View File

@ -17,8 +17,10 @@ import { useRouter } from 'expo-router';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai'; import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai';
import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant'; import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant';
import { ScreenHeader } from '../../components/ScreenHeader';
interface UiMessage { interface UiMessage {
role: 'user' | 'assistant'; role: 'user' | 'assistant';
@ -113,26 +115,33 @@ export default function AIChatScreen() {
{item.content} {item.content}
</Text> </Text>
{/* 账单卡片 */} {/* 账单卡片 */}
{item.billCards && item.billCards.map((card, i) => ( {item.billCards && item.billCards.map((card, i) => {
// 方向符号 + 金额上色与全局方向色约定一致TransactionCardincome 绿 / 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 }]}
> >
<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>
); );
@ -140,14 +149,7 @@ export default function AIChatScreen() {
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, { 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}
@ -192,7 +194,6 @@ 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%', borderRadius: 12, padding: 12, borderWidth: 1 },
billCard: { marginTop: 8, padding: 10, borderRadius: 8, borderWidth: 1 }, billCard: { marginTop: 8, padding: 10, borderRadius: 8, borderWidth: 1 },

View File

@ -5,7 +5,7 @@
* - 5 ///OCR/ * - 5 ///OCR/
* - * -
* - "处理全部" BillPipeline 稿 * - "处理全部" BillPipeline 稿
* - /稿 mobile.bean * - /稿 main.bean
* - ScreenshotMonitor * - ScreenshotMonitor
* - * -
* - / * - /
@ -15,47 +15,54 @@
* - * -
* - 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, 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 { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { createCommonStyles } from '../../theme/commonStyles';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useAutomationStore } from '../../store/automationStore'; import { useRouter } from 'expo-router';
import { useLedgerStore } from '../../store/ledgerStore'; import { useSettingsStore } from '../../store/settingsStore';
import { useMetadataStore } from '../../store/metadataStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/Button';
import type { AutomationSource } from '../../store/automationStore'; import { ScreenHeader } from '../../components/ScreenHeader';
import { import {
getAccessibilityBridge, getAccessibilityBridge,
getPackageLabel, getPackageLabel,
type PageSignature, type PageSignature,
} from '../../services/accessibilityBridge'; } from '../../services/accessibilityBridge';
import { ensureOcrModels, downloadOcrModels } from '../../services/modelDownloader';
const SOURCE_LABELS: Record<AutomationSource, string> = {
notification: 'automation.sourceNotification',
sms: 'automation.sourceSms',
screenshot: 'automation.sourceScreenshot',
ocr: 'automation.sourceOcr',
manual: 'automation.sourceManual',
};
export default function AutomationScreen() { export default function AutomationScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT(); const t = useT();
const router = useRouter(); const 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 setOcrModelVersion = useSettingsStore(s => s.setOcrModelVersion);
const setOcrModelDir = useSettingsStore(s => s.setOcrModelDir);
const aiEnabled = useSettingsStore(s => s.aiEnabled);
const aiProviderId = useSettingsStore(s => s.aiProviderId);
const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || '';
const aiModel = useSettingsStore(s => s.aiModel) || '';
const updateAiConfig = useSettingsStore(s => s.updateAiConfig);
const dedupEnabled = useSettingsStore(s => s.dedupEnabled);
const setDedupEnabled = useSettingsStore(s => s.setDedupEnabled);
const transferRecognitionEnabled = useSettingsStore(s => s.transferRecognitionEnabled);
const setTransferRecognitionEnabled = useSettingsStore(s => s.setTransferRecognitionEnabled);
// 无障碍服务状态 // 无障碍服务状态
const [serviceRunning, setServiceRunning] = useState(false); const [serviceRunning, setServiceRunning] = useState(false);
@ -64,7 +71,74 @@ export default function AutomationScreen() {
const [screenshotActive, setScreenshotActive] = useState(false); const [screenshotActive, setScreenshotActive] = useState(false);
const [topApp, setTopApp] = useState<{ package: string; activity: string } | null>(null); const [topApp, setTopApp] = useState<{ package: string; activity: string } | null>(null);
const sourceEntries = useMemo(() => Object.entries(stats) as [AutomationSource, number][], [stats]); // 权限状态
const [notifEnabled, setNotifEnabled] = useState<boolean | null>(null);
const [smsGranted, setSmsGranted] = useState<boolean | null>(null);
const [storageGranted, setStorageGranted] = useState<boolean | null>(null);
const [overlayGranted, setOverlayGranted] = useState<boolean | null>(null);
// OCR 模型安装/下载状态
const [modelStatus, setModelStatus] = useState<'idle' | 'installing' | 'verifying' | 'done' | 'error'>('idle');
const [modelProgress, setModelProgress] = useState(0);
const handleInstallModel = async () => {
setModelStatus('installing');
try {
await ensureOcrModels((p) => setModelProgress(p));
setModelStatus('done');
} catch (e) {
setModelStatus('error');
Alert.alert(t('common.error'), String(e));
}
};
const handleRedownloadModel = async () => {
setModelStatus('installing');
setModelProgress(0);
try {
await downloadOcrModels(undefined, (p) => setModelProgress(p));
setModelStatus('done');
} catch (e) {
setModelStatus('error');
Alert.alert(t('automation.ocrModelDownloadFail'), String(e));
}
};
const handleL2Toggle = (val: boolean) => {
if (val && !ocrModelVersion) {
Alert.alert(t('automation.ocrModelTitle'), t('automation.ocrModelNotInstalled'), [
{ text: t('common.cancel'), onPress: () => setLayer2OcrEnabled(false) },
{ text: t('automation.ocrModelInstall'), onPress: handleInstallModel },
]);
return;
}
setLayer2OcrEnabled(val);
};
const scrollRef = useRef<ScrollView>(null);
const handleL3Toggle = (val: boolean) => {
if (val && (!aiEnabled || !aiApiKey)) {
Alert.alert(t('automation.layer3Ai'), t('automation.layer3AiDisabled'), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.confirm'), onPress: () => router.push('/settings/ai') },
]);
return;
}
setLayer3AiEnabled(val);
};
const providerDefaultUrls: Record<string, string> = {
openai: 'https://api.openai.com/v1',
gemini: 'https://generativelanguage.googleapis.com/v1beta',
deepseek: 'https://api.deepseek.com/v1',
};
const providerDefaultModels: Record<string, string> = {
openai: 'gpt-4o-mini',
gemini: 'gemini-2.0-flash',
deepseek: 'deepseek-chat',
};
// 刷新无障碍状态 // 刷新无障碍状态
const refreshAccessibilityState = useCallback(async () => { const refreshAccessibilityState = useCallback(async () => {
@ -100,27 +174,56 @@ export default function AutomationScreen() {
}; };
}, [refreshAccessibilityState]); }, [refreshAccessibilityState]);
const handleProcess = async () => { // 检查权限状态(挂载 + 从系统设置返回前台时重新检查)
if (!ledger) return; useEffect(() => {
const rules = useMetadataStore.getState().rules; if (Platform.OS !== 'android') return;
const categories = useMetadataStore.getState().categories;
const history = ledger.transactions; const checkPermissions = () => {
try { const bridge = getAccessibilityBridge();
await processAll(ledger, rules, categories, history); if (bridge && typeof bridge.isNotificationListenerEnabled === 'function') {
} catch (e) { bridge.isNotificationListenerEnabled().then(setNotifEnabled).catch(() => setNotifEnabled(false));
Alert.alert(String(e));
} }
if (bridge && typeof bridge.canDrawOverlays === 'function') {
bridge.canDrawOverlays().then(setOverlayGranted).catch(() => setOverlayGranted(false));
}
const smsPerm = 'android.permission.RECEIVE_SMS';
PermissionsAndroid.check(smsPerm).then(async (granted) => {
if (granted) { setSmsGranted(true); return; }
try {
const result = await PermissionsAndroid.request(smsPerm);
setSmsGranted(result === PermissionsAndroid.RESULTS.GRANTED);
} catch { setSmsGranted(false); }
}).catch(() => setSmsGranted(false));
const storagePerm = Number(Platform.Version) >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
PermissionsAndroid.check(storagePerm).then(g => setStorageGranted(g as boolean)).catch(() => setStorageGranted(false));
}; };
const handleConfirm = async (index: number) => { checkPermissions();
const draft = confirmDraft(index);
if (!draft) return; const sub = AppState.addEventListener('change', (state) => {
try { if (state === 'active') checkPermissions();
await addTransaction(draft.draft); });
Alert.alert(t('automation.confirmed')); return () => sub.remove();
} catch (e) { }, []);
Alert.alert(String(e));
} const handleOpenNotificationSettings = () => {
if (Platform.OS !== 'android') return;
Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS').catch(() => {});
};
const handleRequestSms = async () => {
const result = await PermissionsAndroid.request('android.permission.RECEIVE_SMS');
setSmsGranted(result === PermissionsAndroid.RESULTS.GRANTED);
};
const handleOpenOverlaySettings = () => {
Linking.sendIntent('android.settings.action.MANAGE_OVERLAY_PERMISSION').catch(() => {});
};
const handleRequestStorage = async () => {
const perm = Number(Platform.Version) >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
const result = await PermissionsAndroid.request(perm);
setStorageGranted(result === PermissionsAndroid.RESULTS.GRANTED);
}; };
const handleScreenshotToggle = () => { const handleScreenshotToggle = () => {
@ -246,46 +349,10 @@ 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']}>
<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 +370,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 {}
}
}}
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 +468,202 @@ 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 规则匹配 */}
<View style={[styles.layerRow, { marginBottom: 8 }]}>
<Ionicons name="flash-outline" size={20} color={layer1RuleEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('automation.layer1Rule')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.noEvents')} {t('automation.layer1RuleDesc')}
</Text> </Text>
) : (
<View style={{ gap: 4 }}>
{detected.map(ev => (
<Text key={ev.id} style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
[{t(SOURCE_LABELS[ev.source])}] {ev.event.counterparty} · {ev.event.amount} {ev.event.currency}
</Text>
))}
<View style={{ marginTop: 8 }}>
<Button label={t('automation.process')} onPress={handleProcess} />
</View> </View>
<Switch
value={layer1RuleEnabled}
onValueChange={setLayer1RuleEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* L2 OCR 识别 */}
<View style={[styles.layerRow, { marginBottom: 8 }]}>
<Ionicons name="scan-outline" size={20} color={layer2OcrEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('automation.layer2Ocr')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.layer2OcrDesc')}
</Text>
</View>
<Switch
value={layer2OcrEnabled}
onValueChange={handleL2Toggle}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* L3 AI 视觉 */}
<View style={styles.layerRow}>
<Ionicons name="sparkles-outline" size={20} color={layer3AiEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('automation.layer3Ai')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.layer3AiDesc')}
</Text>
</View>
<Switch
value={layer3AiEnabled}
onValueChange={handleL3Toggle}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* --- Card B: OCR 模型 --- */}
<Card title={t('automation.ocrModelTitle')}>
{/* 状态行 */}
<View style={styles.layerRow}>
<Ionicons
name="hardware-chip-outline"
size={20}
color={ocrModelVersion ? theme.colors.success : theme.colors.fgSecondary}
/>
<Text style={[theme.typography.body, { flex: 1, marginLeft: 10, color: ocrModelVersion ? theme.colors.success : theme.colors.fgSecondary }]}>
{ocrModelVersion
? t('automation.ocrModelInstalled', { version: ocrModelVersion })
: t('automation.ocrModelNotInstalled')}
</Text>
</View>
{/* 下载进度条 */}
{modelStatus === 'installing' && (
<View style={{ marginTop: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<ActivityIndicator size="small" color={theme.colors.accent} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, flex: 1 }]}>
{modelProgress > 0
? t('automation.ocrModelDownloading', { progress: String(modelProgress) })
: t('automation.ocrModelCopying')}
</Text>
</View>
{modelProgress > 0 && (
<View style={[styles.progressBar, { backgroundColor: theme.colors.progressBg, marginTop: 6 }]}>
<View style={[styles.progressFill, { backgroundColor: theme.colors.accent, width: `${modelProgress}%` }]} />
</View>
)}
</View>
)}
{/* 操作按钮行 */}
{modelStatus !== 'installing' && (
<View style={[styles.layerRow, { marginTop: 8 }]}>
{!ocrModelVersion || modelStatus === 'error' ? (
<Button label={t('automation.ocrModelDownload')} onPress={handleInstallModel} variant="secondary" />
) : (
<Button label={t('automation.ocrModelRedownload')} onPress={handleRedownloadModel} variant="secondary" />
)}
</View> </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 style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.dedupLabel')}
</Text> </Text>
</View> <Switch
} value={dedupEnabled}
ListEmptyComponent={ onValueChange={setDedupEnabled}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}> trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
{t('automation.noDrafts')} thumbColor={theme.colors.fgInverse}
</Text>
}
contentContainerStyle={styles.content}
/> />
</View>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.transferLabel')}
</Text>
<Switch
value={transferRecognitionEnabled}
onValueChange={setTransferRecognitionEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* 其他权限 */}
{Platform.OS === 'android' && (
<Card title={t('automation.otherPermissions')}>
{/* 通知监听 */}
<View style={styles.permRow}>
<Ionicons name="notifications-outline" size={20} color={notifEnabled ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.notificationTitle')}</Text>
<Text style={{ color: notifEnabled ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{notifEnabled ? t('automation.notificationEnabled') : t('automation.notificationDisabled')}
</Text>
{!notifEnabled && <Button label={t('automation.notificationOpenSettings')} onPress={handleOpenNotificationSettings} variant="secondary" />}
</View>
{/* 短信 */}
<View style={[styles.permRow, { marginTop: 8 }]}>
<Ionicons name="chatbubble-outline" size={20} color={smsGranted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.smsPermissionTitle')}</Text>
<Text style={{ color: smsGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{t('automation.smsPermGranted')}
</Text>
{!smsGranted && <Button label={t('automation.smsPermRequest')} onPress={handleRequestSms} variant="secondary" />}
</View>
{/* 存储 */}
<View style={[styles.permRow, { marginTop: 8 }]}>
<Ionicons name="images-outline" size={20} color={storageGranted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.storagePermTitle')}</Text>
<Text style={{ color: storageGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{t('automation.storagePermGranted')}
</Text>
{!storageGranted && <Button label={t('automation.storagePermRequest')} onPress={handleRequestStorage} variant="secondary" />}
</View>
{/* 悬浮窗 */}
<View style={[styles.permRow, { marginTop: 8 }]}>
<Ionicons name="tablet-landscape-outline" size={20} color={overlayGranted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.overlayPermTitle')}</Text>
<Text style={{ color: overlayGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{overlayGranted ? t('automation.overlayPermGranted') : t('automation.overlayPermNotGranted')}
</Text>
{!overlayGranted && <Button label={t('automation.overlayPermOpenSettings')} onPress={handleOpenOverlaySettings} variant="secondary" />}
</View>
</Card>
)}
</View>
</ScrollView>
</SafeAreaView> </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' }, statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
statItem: { alignItems: 'center', gap: 4 }, 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' },
}); });

View File

@ -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/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/ManagementScreen';
import { calculateBudgetProgress } from '../../domain/budgets'; import { calculateBudgetProgress } from '../../domain/budgets';
import { toDateString } from '../../domain/decimal';
import type { Budget } from '../../domain/budgets'; import type { Budget } from '../../domain/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,68 +27,23 @@ 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} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('budget.add')}</Text>
</Pressable>
{budgets.map(budget => {
const progress = calculateBudgetProgress(budget, transactions, today); const progress = calculateBudgetProgress(budget, transactions, today);
return ( return (
<Pressable <Pressable onPress={openEdit} onLongPress={confirmDelete}>
key={budget.id}
onPress={() => setModal({ type: 'edit', budget })}
onLongPress={() => handleDelete(budget)}
>
<Card title={budget.name}> <Card title={budget.name}>
<View style={styles.budgetRow}> <View style={styles.budgetRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
@ -122,39 +73,48 @@ export default function BudgetScreen() {
</Card> </Card>
</Pressable> </Pressable>
); );
})} }}
{budgets.length === 0 && ( formTitle={editing => (editing ? t('budget.editTitle') : t('budget.add'))}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}> formFields={editing => [
{t('budget.empty')} { key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder'), defaultValue: editing?.name },
</Text> { key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), defaultValue: editing?.amount, keyboardType: 'decimal-pad' },
)} { key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly', defaultValue: editing?.period },
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> { 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 },
]}
onSubmit={(values, editing) => {
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 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 },

View File

@ -1,135 +1,102 @@
/** /**
* plan.md1.1 category/index+ 1 * plan.md1.1 category/index+ 1
* *
* /+ // * / chips + //
* metadataStorelinkedAccount Beancount * metadataStorelinkedAccount 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/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/ManagementScreen';
import { Touchable } from '../../components/Touchable';
import type { Category } from '../../domain/categories'; import type { Category } from '../../domain/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'); return (
const incomeCats = categories.filter(c => c.type === 'income'); <ManagementScreen<Category>
title={t('category.title')}
const handleDelete = (cat: Category) => { items={categories.filter(c => c.type === catType)}
Alert.alert(t('category.deleteTitle'), t('category.deleteConfirm', { name: cat.name }), [ keyExtractor={cat => cat.id}
{ text: t('common.cancel'), style: 'cancel' }, addLabel={t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') })}
{ text: t('common.delete'), style: 'destructive', onPress: () => removeCategory(cat.id) }, headerContent={
]); <View style={styles.chipRow}>
}; {(['expense', 'income'] as const).map(tp => (
<Touchable
const handleConfirm = (values: Record<string, string>) => { key={tp}
if (modal?.type === 'add') { onPress={() => setCatType(tp)}
addCategory({ style={[commonStyles.chip, catType === tp && commonStyles.chipActive]}
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={[commonStyles.chipText, catType === tp && commonStyles.chipTextActive]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{cat.name}</Text> {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> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
{cat.keywords.length > 0 && ( {cat.keywords.length > 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{t('category.keywordsLabel')}: {cat.keywords.join(', ')} {t('category.keywordsLabel')}: {cat.keywords.join(', ')}
</Text> </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 (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<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('category.title')}</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Card title={t('category.expense')}>
<Pressable onPress={() => setModal({ type: 'add', catType: 'expense' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('category.addExpense')}</Text>
</Pressable>
{expenseCats.map(renderRow)}
</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> : t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') }))}
</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:Uncategorized' : 'Expenses:Uncategorized'),
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 },
}); });

View File

@ -1,29 +1,26 @@
/** /**
* plan.md1.1 credit-card/index+ 1 * plan.md1.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/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/ManagementScreen';
import { AccountCreateModal } from '../../components/AccountCreateModal';
import { Touchable } from '../../components/Touchable';
import type { CreditCard } from '../../domain/creditCards'; import type { CreditCard } from '../../domain/creditCards';
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards'; import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards';
type ModalMode = { type: 'add' } | { type: 'edit'; card: CreditCard } | null;
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,83 +28,64 @@ export default function CreditCardScreen() {
const removeCreditCard = useMetadataStore(s => s.removeCreditCard); const removeCreditCard = useMetadataStore(s => s.removeCreditCard);
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
const transactions = ledger?.transactions ?? []; const transactions = ledger?.transactions ?? [];
const [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}
onPress={() => setModal({ type: 'edit', card })}
onLongPress={() => handleDelete(card)}
>
<Card title={card.name}>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
@ -146,35 +124,112 @@ export default function CreditCardScreen() {
</View> </View>
</View> </View>
</Card> </Card>
</Pressable>
); );
})} }}
{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) {
const shortName = newValue.includes(':') ? newValue.slice(newValue.lastIndexOf(':') + 1) : newValue;
const updates: Record<string, string> = {};
if (shortName) {
updates.name = shortName;
if (shortName.includes('招商') || shortName.toUpperCase().includes('CMB')) updates.bankName = '招商银行';
else if (shortName.includes('支付宝') || shortName.includes('花呗')) updates.bankName = '支付宝';
else if (shortName.includes('微信') || shortName.includes('微粒贷')) updates.bankName = '微信';
else if (shortName.includes('建设') || shortName.toUpperCase().includes('CCB')) updates.bankName = '建设银行';
else if (shortName.includes('工商') || shortName.toUpperCase().includes('ICBC')) updates.bankName = '工商银行';
else if (shortName.includes('中国银行') || shortName.toUpperCase().includes('BOC')) updates.bankName = '中国银行';
else if (shortName.includes('农业') || shortName.toUpperCase().includes('ABC')) updates.bankName = '农业银行';
else if (shortName.includes('交通') || shortName.toUpperCase().includes('BOCOM')) updates.bankName = '交通银行';
else if (!currentValues.bankName) updates.bankName = shortName;
}
return updates;
}
}}
formFields={editing => [
// Row 1: linkedAccount (1.5) + name (1.0)
liabilityOptions.length > 0
? {
key: 'linkedAccount',
label: t('creditCard.fieldLinkedAccount'),
type: 'dropdown',
options: liabilityOptions,
placeholder: '请选择关联信用账户',
defaultValue: editing?.linkedAccount ?? '',
flex: 1.5,
row: 1,
}
: {
key: 'linkedAccount',
label: t('creditCard.fieldLinkedAccount'),
placeholder: 'Liabilities:CreditCard:CMB',
defaultValue: editing?.linkedAccount ?? '',
flex: 1.5,
row: 1,
},
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: editing?.name, flex: 1.0, row: 1 },
// Row 2: bankName (1.0) + lastFour (1.0) + currency (1.0)
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: '招商银行', defaultValue: editing?.bankName, flex: 1.0, row: 2 },
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: editing?.lastFour, keyboardType: 'numeric', flex: 1.0, row: 2 },
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: editing?.currency ?? 'CNY', flex: 1.0, row: 2 },
// Row 3: billingDay (1.0) + paymentDay (1.0) + creditLimit (1.2)
{ key: 'billingDay', label: t('creditCard.fieldBillingDay'), placeholder: '5', defaultValue: editing ? String(editing.billingDay) : '5', keyboardType: 'numeric', flex: 1.0, row: 3 },
{ key: 'paymentDay', label: t('creditCard.fieldPaymentDay'), placeholder: '25', defaultValue: editing ? String(editing.paymentDay) : '25', keyboardType: 'numeric', flex: 1.0, row: 3 },
{ key: 'creditLimit', label: t('creditCard.fieldLimit'), placeholder: '10000', defaultValue: editing?.creditLimit ?? '10000', keyboardType: 'decimal-pad', flex: 1.2, row: 3 },
]}
formExtra={() => (
<Touchable
onPress={() => setIsQuickAddingAccount(true)}
style={styles.quickAddBtn}
>
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontWeight: '600' }]}>
</Text> </Text>
</Touchable>
)} )}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> 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')} {t('common.clickEditLongDelete')}
</Text> </Text>
</ScrollView> }
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('creditCard.editTitle') : t('creditCard.add')}
fields={fieldsFor(modal?.type === 'edit' ? modal.card : undefined)}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/> />
</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 },
}); });

View File

@ -1,28 +1,29 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { DuplicateDetail } from '../../domain/dedup';
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native'; import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { 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 origDecoder = (globalThis as any).TextDecoder;
const origDecoder = global.TextDecoder; const origEncoder = (globalThis as any).TextEncoder;
// @ts-ignore try {
const origEncoder = global.TextEncoder; (globalThis as any).TextDecoder = undefined;
// @ts-ignore (globalThis as any).TextEncoder = undefined;
global.TextDecoder = undefined;
// @ts-ignore
global.TextEncoder = undefined;
const dec = require('text-encoding-gbk').TextDecoder; const dec = require('text-encoding-gbk').TextDecoder;
// @ts-ignore
global.TextDecoder = origDecoder;
// @ts-ignore
global.TextEncoder = origEncoder;
return dec; return dec;
} catch {
return origDecoder;
} finally {
(globalThis as any).TextDecoder = origDecoder;
(globalThis as any).TextEncoder = origEncoder;
}
})(); })();
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';
@ -31,6 +32,7 @@ import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/Button';
import { DedupBanner } from '../../components/DedupBanner'; import { DedupBanner } from '../../components/DedupBanner';
import { ScreenHeader } from '../../components/ScreenHeader';
import { classifyWithCategories } from '../../domain/rules'; import { classifyWithCategories } from '../../domain/rules';
import { validateTransaction } from '../../domain/ledger'; import { validateTransaction } from '../../domain/ledger';
import type { ImportedEvent } from '../../domain/types'; import type { ImportedEvent } from '../../domain/types';
@ -67,7 +69,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);
@ -143,7 +144,7 @@ export default function ImportScreen() {
// 6. 自动执行 Pipeline用户无需手动查找运行按钮 // 6. 自动执行 Pipeline用户无需手动查找运行按钮
if (ledger) { if (ledger) {
setStatus(t('importFlow.pipelineRunning')); setStatus(t('importFlow.pipelineRunning'));
processEvents(ledger, []).then(result => { processEvents(ledger, ledger.transactions || []).then(result => {
setManualDuplicates(result.duplicates); setManualDuplicates(result.duplicates);
setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length })); setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length }));
}).catch(e => { }).catch(e => {
@ -158,7 +159,7 @@ export default function ImportScreen() {
const runPipeline = () => { const runPipeline = () => {
if (!ledger) return; if (!ledger) return;
processEvents(ledger, []).then(result => { processEvents(ledger, ledger.transactions || []).then(result => {
setManualDuplicates(result.duplicates); setManualDuplicates(result.duplicates);
setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount })); setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount }));
}).catch(e => setStatus(String(e))); }).catch(e => setStatus(String(e)));
@ -245,7 +246,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 +270,59 @@ export default function ImportScreen() {
const rules = useMetadataStore.getState().rules; const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories; const categories = useMetadataStore.getState().categories;
const classification = classifyWithCategories(event, rules, categories, ledger); const classification = classifyWithCategories(event, rules, categories, ledger);
await addTransaction(classification.draft); await addTransaction(classification.draft, undefined, true);
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id)); setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
setStatus(t('importFlow.forcedImport')); setStatus(t('importFlow.forcedImport'));
Alert.alert('记账成功', `已成功将「${event.counterparty || event.memo || '交易'}」单独记入账本`);
} catch (err) { } catch (err) {
setStatus(String(err)); setStatus(String(err));
Alert.alert('记账失败', String(err));
}
};
const handleLinkDuplicate = async (event: ImportedEvent) => {
if (!ledger) return;
try {
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const classification = classifyWithCategories(event, rules, categories, ledger);
// 寻找对应的历史匹配交易
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === event.id);
const matchedTx = detail?.matchedWith?.rawTransaction;
let linkTag = matchedTx?.links?.[0] || 'lnk-' + Math.random().toString(36).slice(2, 8);
// 为新草稿添加链接
const newDraft = {
...classification.draft,
links: Array.from(new Set([...(classification.draft.links || []), linkTag])),
};
await addTransaction(newDraft, undefined, true);
// 如果有历史匹配交易且它还没有此 linkTag同步写回历史交易 raw
if (matchedTx && !matchedTx.links.includes(linkTag)) {
const currentRaw = matchedTx.raw;
const firstLineEnd = currentRaw.indexOf('\n');
const header = firstLineEnd !== -1 ? currentRaw.slice(0, firstLineEnd) : currentRaw;
const rest = firstLineEnd !== -1 ? currentRaw.slice(firstLineEnd) : '';
const updatedHeader = `${header} ^${linkTag}`;
const updatedRaw = `${updatedHeader}${rest}`;
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
if (mobileBean.includes(currentRaw)) {
const newContent = mobileBean.replace(currentRaw, updatedRaw);
await replaceMobileBean(newContent);
}
}
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
setStatus(`已成功关联交易 (标签 ^${linkTag})`);
Alert.alert('关联成功', `已成功将「${event.counterparty || event.memo || '交易'}」与历史账单关联(关联标签 ^${linkTag}`);
} catch (err) {
setStatus(String(err));
Alert.alert('关联失败', String(err));
} }
}; };
@ -284,15 +333,10 @@ 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']}>
<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 +393,45 @@ export default function ImportScreen() {
{showDuplicates && ( {showDuplicates && (
<View style={styles.duplicatesList}> <View style={styles.duplicatesList}>
{manualDuplicates.map((item) => { {manualDuplicates.map((item) => {
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === item.id);
const matchedTx = detail?.matchedWith?.rawTransaction;
const reason = detail?.reason || t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') });
const result = { const result = {
isDuplicate: true, isDuplicate: true,
confidence: 'medium' as const, confidence: (detail?.confidence ?? 'medium') as any,
reason: t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') }) reason,
}; };
return ( return (
<Card key={item.id} title={`${item.occurredAt} · ${item.counterparty}`}> <Card
key={item.id}
title={`${item.occurredAt} · ${item.counterparty || '未知来源'}`}
onPress={() => handleLinkDuplicate(item)}
>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{item.memo || t('importFlow.noDesc')} {item.memo || t('importFlow.noDesc')}
</Text> </Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })} {t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })}
</Text> </Text>
{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 +456,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 },
}); });

View File

@ -1,121 +1,47 @@
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/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/ManagementScreen';
import { toDateString } from '../../domain/decimal';
import type { RecurringTransaction } from '../../domain/recurring'; import type { RecurringTransaction } from '../../domain/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>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>
{t('recurring.add')}
</Text>
</Pressable>
{recurringTransactions.map(item => {
const amount = item.draft.postings[1]?.amount || '0.00'; const amount = item.draft.postings[1]?.amount || '0.00';
const fromAcc = item.draft.postings[0]?.account || ''; const fromAcc = item.draft.postings[0]?.account || '';
const toAcc = item.draft.postings[1]?.account || ''; const toAcc = item.draft.postings[1]?.account || '';
return ( return (
<Card key={item.id} title={item.name}> <Card title={item.name}>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
@ -137,9 +63,10 @@ export default function RecurringScreen() {
<Text style={[theme.typography.body, { color: theme.colors.financial.expense, fontWeight: '700' }]}>{amount} CNY</Text> <Text style={[theme.typography.body, { color: theme.colors.financial.expense, fontWeight: '700' }]}>{amount} CNY</Text>
</View> </View>
{/* 显式编辑/删除按钮(本页不用长按手势,交互更显式) */}
<View style={styles.cardActions}> <View style={styles.cardActions}>
<Pressable <Pressable
onPress={() => setModal({ type: 'edit', item })} onPress={openEdit}
style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]} style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]}
> >
<Ionicons name="create-outline" size={14} color={theme.colors.accent} /> <Ionicons name="create-outline" size={14} color={theme.colors.accent} />
@ -148,8 +75,8 @@ export default function RecurringScreen() {
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => handleDelete(item)} onPress={confirmDelete}
style={[styles.closeBtn, { borderColor: theme.colors.border }]} style={[styles.actionBtn, { borderColor: theme.colors.border }]}
> >
<Ionicons name="trash-outline" size={14} color={theme.colors.error} /> <Ionicons name="trash-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 }]}>
@ -159,33 +86,70 @@ export default function RecurringScreen() {
</View> </View>
</Card> </Card>
); );
})} }}
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;
{recurringTransactions.length === 0 && ( if (!nameVal || !fromAccount || !toAccount || !amountVal) {
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}> Alert.alert(t('recurring.inputError'), t('recurring.inputErrorDesc'));
{t('recurring.empty')} return false;
</Text> }
)}
</ScrollView>
<FormModal const draft = {
visible={modal !== null} date: dateVal,
title={modal?.type === 'edit' ? t('recurring.editTitle') : t('recurring.addTitle')} narration: nameVal,
fields={fieldsFor(modal?.type === 'edit' ? modal.item : undefined)} postings: [
onConfirm={handleConfirm} { account: fromAccount, amount: `-${amountVal}`, currency: 'CNY' },
onCancel={() => setModal(null)} { account: toAccount, amount: amountVal, currency: 'CNY' },
],
};
if (editing) {
updateRecurring(editing.id, {
name: nameVal,
draft,
frequency: freq,
nextDueDate: dateVal,
});
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')}
/> />
</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', 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 },
}); });

View File

@ -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/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/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>
</Pressable>
{templates.map(tpl => (
<Pressable
key={tpl.id}
onPress={() => setModal({ type: 'edit', tpl })}
onLongPress={() => handleDelete(tpl)}
>
<Card title={tpl.name}> <Card title={tpl.name}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
{tpl.template} {tpl.template}
</Text> </Text>
</Card> </Card>
</Pressable> </Pressable>
))}
{templates.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
{t('remark.empty')}
</Text>
)} )}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> formTitle={editing => (editing ? t('remark.editTitle') : t('remark.add'))}
formFields={editing => [
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮', defaultValue: editing?.name },
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}', defaultValue: editing?.template },
]}
onSubmit={(values, editing) => {
const name = values.name?.trim() || t('common.untitled');
const template = values.template?.trim() || '';
if (editing) {
updateTemplate(editing.id, { name, template });
} else {
addTemplate({ id: generateId('tpl'), name, template });
}
return true;
}}
onDelete={tpl => removeTemplate(tpl.id)}
deleteConfirmText={tpl => t('remark.deleteConfirm', { name: tpl.name })}
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 },
});

View File

@ -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/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/ManagementScreen';
import type { Rule } from '../../domain/types'; import type { Rule } from '../../domain/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,30 +32,19 @@ 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}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('rules.add')}</Text>
</Pressable>
<Card title={t('rules.title')}>
{sorted.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('rules.empty')}</Text>
)}
{sorted.map(rule => (
<Pressable <Pressable
key={rule.id} onPress={openEdit}
onPress={() => setModal({ type: 'edit', rule })} onLongPress={confirmDelete}
onLongPress={() => handleDelete(rule)} style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}
style={({ pressed }) => [styles.row, { borderTopColor: theme.colors.divider, opacity: pressed ? 0.6 : 1 }]}
> >
<View style={{ flex: 1 }}>
<View style={styles.ruleHeader}> <View style={styles.ruleHeader}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}>{rule.narration || rule.id}</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> <Text style={[theme.typography.caption, { color: theme.colors.accent }]}>P{rule.priority}</Text>
@ -109,35 +54,52 @@ export default function RulesScreen() {
</Text> </Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}>
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} /> <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 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })}) {rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
</Text> </Text>
</View> </View>
</View>
</Pressable> </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:Uncategorized',
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' },
}); });

View File

@ -1,156 +1,156 @@
/**
* LLM / AI P8
*
*
* - AI openai / gemini / deepseek
* - API Key
* - Base URL
* -
* - AI
*/
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Switch, Alert } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, TextInput, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { 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/Card';
import { Button } from '../../components/Button'; import { ScreenHeader } from '../../components/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']}>
<View style={styles.header}> <ScreenHeader title={t('settings.llmTitle')} />
<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.aiSettingsTitle')}
</Text>
</View>
<ScrollView contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={styles.content}>
{/* 自动记账与算法开关 */} {/* AI 总开关 */}
<Card title={t('settings.algorithmConfig')}> <Card>
<View style={styles.switchRow}> <View style={styles.row}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.dedupLabel')}
</Text>
<Switch
value={dedupEnabled}
onValueChange={setDedupEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
<View style={[styles.switchRow, { marginTop: 12 }]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.transferLabel')}
</Text>
<Switch
value={transferRecognitionEnabled}
onValueChange={setTransferRecognitionEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* AI 智能助理配置 */}
<Card title={t('settings.aiAssistantTitle')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.enableAiHint')} {t('settings.enableAiHint')}
</Text> </Text>
<Switch <Switch
value={aiEnabled} value={aiEnabled}
onValueChange={(val) => updateAiConfig({ aiEnabled: val })} onValueChange={val => updateAiConfig({ aiEnabled: val })}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }} trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse} thumbColor={theme.colors.fgInverse}
/> />
</View> </View>
{aiEnabled && ( </Card>
<View style={{ marginTop: 12, gap: 8 }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> {/* 服务商选择 */}
{t('settings.aiProvider')} <Card title={t('automation.aiProviderLabel')}>
</Text> <View style={styles.chipRow}>
<View style={styles.optionRow}> {PROVIDERS.map(p => (
{['openai', 'gemini', 'deepseek'].map((provider) => {
const active = aiProviderId === provider;
return (
<Pressable <Pressable
key={provider} key={p.key}
onPress={() => updateAiConfig({ aiProviderId: provider as any })} onPress={() => updateAiConfig({ aiProviderId: p.key as 'openai' | 'gemini' | 'deepseek' })}
style={[ style={[
styles.option, commonStyles.chip,
{ aiProviderId === p.key && commonStyles.chipActive,
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : theme.colors.border,
},
]} ]}
> >
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontWeight: active ? '700' : '400' }}> <Text style={[commonStyles.chipText, aiProviderId === p.key && commonStyles.chipTextActive]}>
{provider.toUpperCase()} {p.label}
</Text> </Text>
</Pressable> </Pressable>
); ))}
})}
</View> </View>
<View style={{ marginTop: 4 }}> </Card>
<Button label={t('settings.aiConfigBtn')} onPress={() => setAiModalVisible(true)} variant="secondary" />
</View> {/* API Key */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}> <Card title={t('settings.aiFieldApiKey')}>
{t('settings.aiCurrentModel', { model: aiModel, url: aiBaseUrl })} <TextInput
</Text> style={inputStyle}
</View> value={localKey}
)} onChangeText={setLocalKey}
onBlur={() => updateAiConfig({ aiApiKey: localKey })}
placeholder="sk-..."
placeholderTextColor={theme.colors.fgSecondary}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
/>
</Card>
{/* Base URL */}
<Card title={t('settings.aiFieldBaseUrl')}>
<TextInput
style={inputStyle}
value={localUrl}
onChangeText={setLocalUrl}
onBlur={() => updateAiConfig({ aiBaseUrl: localUrl })}
placeholder={PROVIDER_DEFAULT_URLS[aiProviderId] || PROVIDER_DEFAULT_URLS.openai}
placeholderTextColor={theme.colors.fgSecondary}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</Card>
{/* 模型 */}
<Card title={t('settings.aiFieldModel')}>
<TextInput
style={inputStyle}
value={localModel}
onChangeText={setLocalModel}
onBlur={() => updateAiConfig({ aiModel: localModel })}
placeholder={PROVIDER_DEFAULT_MODELS[aiProviderId] || PROVIDER_DEFAULT_MODELS.openai}
placeholderTextColor={theme.colors.fgSecondary}
autoCapitalize="none"
autoCorrect={false}
/>
</Card> </Card>
</ScrollView> </ScrollView>
{/* AI 配置模态框 */}
<FormModal
visible={aiModalVisible}
title={t('settings.aiConfigTitle')}
fields={aiFields}
onConfirm={saveAI}
onCancel={() => setAiModalVisible(false)}
/>
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 }, content: { padding: 16, paddingBottom: 64, gap: 12 },
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 },
}); });

View File

@ -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/Card';
import { ScreenHeader } from '../../components/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']}>
<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 },
});

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

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

View File

@ -1,17 +1,16 @@
import React from 'react'; import React 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 { 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/Card';
import { ScreenHeader } from '../../components/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 +23,8 @@ 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 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' },
@ -38,14 +39,7 @@ 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']}>
<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}>
{/* 外观设置 */} {/* 外观设置 */}
@ -106,6 +100,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}>
@ -188,19 +200,42 @@ export default function PreferencesScreen() {
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700' }]}> <Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700' }]}>
{String(reminderHour).padStart(2, '0')}:{String(reminderMinute).padStart(2, '0')} {String(reminderHour).padStart(2, '0')}:{String(reminderMinute).padStart(2, '0')}
</Text> </Text>
<View style={{ flexDirection: 'row', gap: 4, marginLeft: 12 }}>
<Pressable
onPress={async () => {
const newHour = (reminderHour - 1 + 24) % 24;
updateReminderConfig({ reminderHour: newHour });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
}}
style={{ padding: 4 }}
>
<Ionicons name="remove-circle-outline" size={20} color={theme.colors.accent} />
</Pressable>
<Pressable <Pressable
onPress={async () => { onPress={async () => {
// 简单的时间调整:小时 +1 循环
const newHour = (reminderHour + 1) % 24; const newHour = (reminderHour + 1) % 24;
updateReminderConfig({ reminderHour: newHour }); updateReminderConfig({ reminderHour: newHour });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder'); const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute); await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
}} }}
style={{ marginLeft: 12, padding: 4 }} style={{ padding: 4 }}
>
<Ionicons name="add-circle-outline" size={20} color={theme.colors.accent} />
</Pressable>
<Pressable
onPress={async () => {
const newMinute = (reminderMinute + 5) % 60;
updateReminderConfig({ reminderMinute: newMinute });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), reminderHour, newMinute);
}}
style={{ padding: 4 }}
> >
<Ionicons name="time-outline" size={20} color={theme.colors.accent} /> <Ionicons name="time-outline" size={20} color={theme.colors.accent} />
</Pressable> </Pressable>
</View> </View>
</View>
)} )}
</Card> </Card>
</ScrollView> </ScrollView>
@ -210,7 +245,6 @@ export default function PreferencesScreen() {
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, gap: 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, borderRadius: 4 },

View File

@ -1,8 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Platform } from 'react-native'; import { Pressable, 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';
@ -15,6 +13,7 @@ import { useMetadataStore } from '../../store/metadataStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/Button';
import { FormModal, type FormField } from '../../components/FormModal'; import { FormModal, type FormField } from '../../components/FormModal';
import { ScreenHeader } from '../../components/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';
@ -24,7 +23,6 @@ import { runMaintenance, ExpoMaintenanceFs, ExpoMaintenanceDb } from '../../serv
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,9 +233,9 @@ 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) {
@ -456,14 +454,7 @@ export default function SyncSettingsScreen() {
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}> <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}>
{/* 云同步配置与云端操作 */} {/* 云同步配置与云端操作 */}
@ -539,7 +530,6 @@ 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, gap: 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 },

View File

@ -1,104 +1,55 @@
/** /**
* plan.md1.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/ManagementScreen';
import { FormModal, type FormField } from '../../components/FormModal';
import { isValidTagName } from '../../domain/tags'; import { isValidTagName } from '../../domain/tags';
import type { Tag } from '../../domain/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>
<View style={[styles.grid, { gap: 8, paddingTop: 4 }]}>
{tags.map(tag => (
<Pressable <Pressable
key={tag.id} onPress={openEdit}
onPress={() => openModal({ type: 'edit', tag })} onLongPress={confirmDelete}
onLongPress={() => handleDelete(tag)}
style={[styles.chip, { backgroundColor: tag.color }]} style={[styles.chip, { backgroundColor: tag.color }]}
> >
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text> <Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
</Pressable> </Pressable>
))}
{tags.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('tag.empty')}</Text>
)} )}
</View> formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))}
</Card> formFields={editing => [
{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name },
{/* 颜色选择器(模态打开时显示) */} ]}
{modal && ( formExtra={() => (
<Card title={t('tag.selectColor')}>
<View style={styles.colorRow}> <View style={styles.colorRow}>
{COLORS.map(c => ( {TAG_COLORS.map(c => (
<Pressable <Pressable
key={c} key={c}
onPress={() => setSelectedColor(c)} onPress={() => setSelectedColor(c)}
@ -110,32 +61,32 @@ export default function TagScreen() {
/> />
))} ))}
</View> </View>
</Card>
)} )}
onSubmit={(values, editing) => {
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> 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 })}
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 },
}); });

View File

@ -15,13 +15,16 @@ import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../../theme'; import { useTheme, createCommonStyles } from '../../theme';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { hash } from '../../domain/ledger'; import { hash } from '../../domain/ledger';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { TransactionCard } from '../../components/TransactionCard'; import { TransactionCard } from '../../components/TransactionCard';
import { ScreenHeader } from '../../components/ScreenHeader';
import { 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 }>();
@ -48,7 +51,7 @@ export default function TransactionDetailScreen() {
// 筛选可以用来关联的其他账单 // 筛选可以用来关联的其他账单
const linkableTransactions = useMemo(() => { const linkableTransactions = useMemo(() => {
if (!ledger) return []; if (!ledger) return [];
return ledger.transactions const list = ledger.transactions
.filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l))) .filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l)))
.filter(t => { .filter(t => {
if (!searchQuery) return true; if (!searchQuery) return true;
@ -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,10 +223,7 @@ 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>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('transaction.detailTitle')}</Text>
</View>
<ScrollView contentContainerStyle={[styles.content, { gap: 12 }]}> <ScrollView contentContainerStyle={[styles.content, { gap: 12 }]}>
{/* 摘要卡片 */} {/* 摘要卡片 */}
<Card title={t('transaction.summary')}> <Card title={t('transaction.summary')}>
@ -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;
let categoryTag = t('account.title');
let iconName: keyof typeof Ionicons.glyphMap = 'swap-horizontal-outline';
let tagBg = `${theme.colors.accent}15`;
let tagColor = theme.colors.accent;
if (rootType === 'Assets') {
categoryTag = t('account.rootTypes.Assets');
iconName = 'wallet-outline';
tagBg = `${theme.colors.financial.income}15`;
tagColor = theme.colors.financial.income;
} else if (rootType === 'Expenses') {
categoryTag = t('account.rootTypes.Expenses');
iconName = 'cart-outline';
tagBg = `${theme.colors.financial.expense}15`;
tagColor = theme.colors.financial.expense;
} else if (rootType === 'Income') {
categoryTag = t('account.rootTypes.Income');
iconName = 'cash-outline';
tagBg = `${theme.colors.financial.income}15`;
tagColor = theme.colors.financial.income;
} else if (rootType === 'Liabilities') {
categoryTag = t('account.rootTypes.Liabilities');
iconName = 'card-outline';
tagBg = `${theme.colors.warning}15`;
tagColor = theme.colors.warning;
} else if (rootType === 'Equity') {
categoryTag = t('account.rootTypes.Equity');
iconName = 'options-outline';
}
const amountVal = parseFloat(p.amount ?? '0');
const isNegative = amountVal < 0;
return (
<View
key={i}
style={[
styles.flowCardItem,
{
backgroundColor: theme.colors.bgTertiary,
borderRadius: theme.radii.lg,
},
]}
>
<View style={[styles.flowIconBox, { backgroundColor: tagBg }]}>
<Ionicons name={iconName} size={18} color={tagColor} />
</View>
<View style={{ flex: 1, marginLeft: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View style={[styles.miniTypeTag, { backgroundColor: `${theme.colors.fgSecondary}15` }]}>
<Text style={{ fontSize: 10, color: theme.colors.fgSecondary, fontWeight: '600' }}>
{categoryTag}
</Text> </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]) => ( {p.metadata && Object.entries(p.metadata).map(([k, v]) => (
<Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{k}: {v} {k}: {v}
</Text> </Text>
))} ))}
</View> </View>
<View style={{ alignItems: 'flex-end' }}>
<View style={{ alignItems: 'flex-end', justifyContent: 'center' }}>
{p.amount && ( {p.amount && (
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}> <Text
style={[
theme.typography.body,
{
color: isNegative ? theme.colors.financial.expense : theme.colors.fgPrimary,
fontWeight: '700',
},
]}
>
{p.amount} {p.currency ?? ''} {p.amount} {p.currency ?? ''}
</Text> </Text>
)} )}
{p.cost && ( {p.cost && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{p.cost} : {p.cost}
</Text> </Text>
)} )}
{p.price && ( {p.price && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
@ {p.price} : @ {p.price}
</Text> </Text>
)} )}
</View> </View>
</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} />
@ -426,4 +548,7 @@ const styles = StyleSheet.create({
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 }, metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' }, editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 }, editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 },
flowCardItem: { flexDirection: 'row', alignItems: 'center', padding: 10 },
flowIconBox: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
miniTypeTag: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
}); });

View File

@ -1,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 },
});

View File

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

View File

@ -1,6 +1,7 @@
import React from 'react'; import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { useT } from '../i18n';
import { calculateAccountTotalBalance, type AccountNode } from '../domain/accountTree'; import { calculateAccountTotalBalance, type AccountNode } from '../domain/accountTree';
interface AccountTreeProps { interface AccountTreeProps {
@ -11,7 +12,6 @@ interface AccountTreeProps {
/** 账户树展示(主题化,缩进显示层级 + 余额)。 */ /** 账户树展示(主题化,缩进显示层级 + 余额)。 */
export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) { export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
const { theme } = useTheme();
return ( return (
<View style={{ gap: 4 }}> <View style={{ gap: 4 }}>
{nodes.map(node => ( {nodes.map(node => (
@ -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 },

View File

@ -0,0 +1,89 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { useTheme } from '../theme';
import { useSettingsStore } from '../store/settingsStore';
import { useNumpadUiStore } from '../store/numpadUiStore';
import type { IoniconName } from './categoryIcons';
/** 路由名 → 图标(与 (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/newP3 NumpadSheet
*/
export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
const { theme } = useTheme();
const router = useRouter();
const insets = useSafeAreaInsets();
const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
const openNumpad = useNumpadUiStore(s => s.open);
const renderTab = (route: (typeof state.routes)[number]) => {
const routeIndex = state.routes.indexOf(route);
const focused = state.index === routeIndex;
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={() => openNumpad()}
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 },
});

View File

@ -0,0 +1,51 @@
import React from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
interface BottomSheetProps {
visible: boolean;
onClose: () => void;
title?: string;
/** 内容可滚动maxHeight 480适合长表单。 */
scrollable?: boolean;
children: React.ReactNode;
}
/** 通用底部弹层容器:顶部 xl 圆角 + 把手、遮罩点击关闭、Android 返回键关闭。 */
export function BottomSheet({ visible, onClose, title, scrollable, children }: BottomSheetProps) {
const { theme } = useTheme();
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onClose}>
<Pressable
style={[styles.sheet, {
backgroundColor: theme.colors.bgSecondary,
borderTopLeftRadius: theme.radii.xl,
borderTopRightRadius: theme.radii.xl,
}]}
onPress={(e) => e.stopPropagation()}
>
<View style={[styles.handle, { backgroundColor: theme.colors.border }]} />
{title ? (
<Text style={[theme.typography.h3, styles.title, { color: theme.colors.fgPrimary }]}>{title}</Text>
) : null}
{scrollable ? (
<ScrollView style={styles.scroll} keyboardShouldPersistTaps="handled">
{children}
</ScrollView>
) : (
children
)}
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'flex-end' },
sheet: { maxHeight: '85%', paddingHorizontal: 20, paddingBottom: 36 },
handle: { width: 40, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 8, marginBottom: 4 },
title: { marginTop: 8, marginBottom: 12 },
scroll: { maxHeight: 480 },
});

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react'; import React, { useCallback, useRef } from 'react';
import { Animated, Pressable, StyleSheet, Text } from 'react-native'; import { Animated, Pressable, StyleSheet, Text } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
@ -14,23 +14,23 @@ export function Button({ label, onPress, variant = 'primary', disabled }: Button
const { theme } = useTheme(); const { theme } = useTheme();
const scale = useRef(new Animated.Value(1)).current; const scale = useRef(new Animated.Value(1)).current;
const handlePressIn = () => { const handlePressIn = useCallback(() => {
Animated.spring(scale, { Animated.spring(scale, {
toValue: 0.96, toValue: 0.96,
useNativeDriver: true, useNativeDriver: true,
tension: 180, tension: 180,
friction: 7, friction: 7,
}).start(); }).start();
}; }, [scale]);
const handlePressOut = () => { const handlePressOut = useCallback(() => {
Animated.spring(scale, { Animated.spring(scale, {
toValue: 1.0, toValue: 1.0,
useNativeDriver: true, useNativeDriver: true,
tension: 180, tension: 180,
friction: 7, friction: 7,
}).start(); }).start();
}; }, [scale]);
const bg = variant === 'primary' ? theme.colors.accent const bg = variant === 'primary' ? theme.colors.accent
: variant === 'danger' ? theme.colors.error : variant === 'danger' ? theme.colors.error
@ -53,7 +53,7 @@ export function Button({ label, onPress, variant = 'primary', disabled }: Button
styles.button, styles.button,
{ {
backgroundColor: bg, backgroundColor: bg,
borderRadius: theme.radii.lg, // 使用 radii.lg 实现现代大圆角 borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)
opacity: disabled ? 0.5 : 1, opacity: disabled ? 0.5 : 1,
borderWidth: variant === 'secondary' ? StyleSheet.hairlineWidth : 0, borderWidth: variant === 'secondary' ? StyleSheet.hairlineWidth : 0,
borderColor: theme.colors.border, borderColor: theme.colors.border,
@ -61,7 +61,7 @@ export function Button({ label, onPress, variant = 'primary', disabled }: Button
}, },
]} ]}
> >
<Text style={[styles.text, { color: fg, fontFamily: theme.typography.body.fontFamily }]}>{label}</Text> <Text style={[styles.text, { color: fg }]}>{label}</Text>
</Animated.View> </Animated.View>
</Pressable> </Pressable>
); );

View File

@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { useT } from '../i18n';
import { getMonthGrid, type DayCell } from '../domain/calendarGrid'; import { getMonthGrid, type DayCell } from '../domain/calendarGrid';
import { dailyExpenseMap } from '../domain/chartStats'; import { dailyExpenseMap } from '../domain/chartStats';
import type { Transaction } from '../domain/types'; import type { Transaction } from '../domain/types';
@ -15,27 +16,28 @@ interface CalendarViewProps {
onDayPress?: (date: string) => void; onDayPress?: (date: string) => void;
} }
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
export function CalendarView({ transactions, year, month, onDayPress }: CalendarViewProps) { export function CalendarView({ transactions, year, month, onDayPress }: CalendarViewProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT();
// 星期头周日起(与 getMonthGrid 网格一致),文案走 i18n
const weekdays = useMemo(() => t('calendar.weekdays').split(','), [t]);
const [selectedDate, setSelectedDate] = useState<string | null>(null); const [selectedDate, setSelectedDate] = useState<string | null>(null);
const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]); const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]);
const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]); const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]);
const maxDaily = useMemo(() => Math.max(...dailyMap.values(), 1), [dailyMap]); const maxDaily = useMemo(() => Math.max(...dailyMap.values(), 1), [dailyMap]);
const handlePress = (cell: DayCell) => { const handlePress = useCallback((cell: DayCell) => {
if (!cell.isCurrentMonth) return; if (!cell.isCurrentMonth) return;
setSelectedDate(cell.date); setSelectedDate(cell.date);
onDayPress?.(cell.date); onDayPress?.(cell.date);
}; }, [onDayPress]);
return ( return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.sm }]}> <View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.sm }]}>
{/* 星期表头 */} {/* 星期表头 */}
<View style={styles.weekdayRow}> <View style={styles.weekdayRow}>
{WEEKDAYS.map(d => ( {weekdays.map(d => (
<Text key={d} style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', flex: 1 }]}> <Text key={d} style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', flex: 1 }]}>
{d} {d}
</Text> </Text>

View File

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

View File

@ -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' },
});

View File

@ -1,7 +1,10 @@
import React from 'react'; import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { getCategoryColor } from '../theme/palette';
import type { Category } from '../domain/categories'; import type { Category } from '../domain/categories';
import { CategoryIcon } from './CategoryIcon';
import { Touchable } from './Touchable';
interface CategoryPickerProps { interface CategoryPickerProps {
categories: Category[]; categories: Category[];
@ -9,44 +12,6 @@ interface CategoryPickerProps {
onSelect: (cat: Category) => void; 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) { export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPickerProps) {
const { theme } = useTheme(); const { theme } = useTheme();
@ -55,29 +20,26 @@ export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPic
<View style={styles.grid}> <View style={styles.grid}>
{categories.map(cat => { {categories.map(cat => {
const active = cat.id === selectedId; const active = cat.id === selectedId;
const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback; const color = getCategoryColor(cat.id);
const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;
return ( return (
<Pressable <Touchable
key={cat.id} key={cat.id}
onPress={() => onSelect(cat)} onPress={() => onSelect(cat)}
style={({ pressed }) => [ style={[
styles.item, styles.item,
{ {
backgroundColor: active ? color + '15' : theme.colors.bgTertiary, backgroundColor: active ? color + '15' : theme.colors.bgTertiary,
borderColor: active ? color : theme.colors.border, borderColor: active ? color : theme.colors.border,
opacity: pressed ? 0.75 : 1,
} }
]} ]}
> >
<Text style={[styles.icon, { color }]}>{icon}</Text> <CategoryIcon categoryId={cat.id} size={32} />
<Text <Text
style={[ style={[
styles.label, styles.label,
{ {
color: active ? theme.colors.fgPrimary : theme.colors.fgSecondary, color: active ? theme.colors.fgPrimary : theme.colors.fgSecondary,
fontFamily: theme.typography.caption.fontFamily,
fontWeight: active ? '700' : '500', fontWeight: active ? '700' : '500',
} }
]} ]}
@ -85,7 +47,7 @@ export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPic
> >
{cat.name} {cat.name}
</Text> </Text>
</Pressable> </Touchable>
); );
})} })}
</View> </View>
@ -109,9 +71,6 @@ const styles = StyleSheet.create({
gap: 6, gap: 6,
padding: 4, padding: 4,
}, },
icon: {
fontSize: 22,
},
label: { label: {
fontSize: 12, fontSize: 12,
}, },

View File

@ -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 './BottomSheet';
import { buildMonthGrid } from './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 },
});

View File

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

View File

@ -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 './BottomSheet';
import { DatePickerField } from './DatePickerField';
import { Button } from './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 },
});

View File

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

View File

@ -9,6 +9,7 @@ import { authenticate, RealAuthProvider, hashPin, verifyPin } from '../services/
/** SecureStore 中存储 PIN 哈希的 key。 */ /** SecureStore 中存储 PIN 哈希的 key。 */
const PIN_HASH_KEY = 'app_pin_hash'; const PIN_HASH_KEY = 'app_pin_hash';
const PIN_SALT_KEY = 'app_pin_salt';
interface LockScreenProps { interface LockScreenProps {
onUnlock: () => void; onUnlock: () => void;
@ -44,7 +45,7 @@ export function LockScreen({ onUnlock }: LockScreenProps) {
}); });
// 尝试生物识别 // 尝试生物识别
handleAuth(); handleAuth();
}, []); }, [handleAuth]);
const handleKeyPress = async (num: string) => { const handleKeyPress = async (num: string) => {
setError(null); setError(null);
@ -55,7 +56,8 @@ export function LockScreen({ onUnlock }: LockScreenProps) {
// 验证 PIN // 验证 PIN
const storedHash = await SecureStore.getItemAsync(PIN_HASH_KEY); const storedHash = await SecureStore.getItemAsync(PIN_HASH_KEY);
if (storedHash) { if (storedHash) {
const valid = await verifyPin(nextPin, storedHash); const storedSalt = await SecureStore.getItemAsync(PIN_SALT_KEY);
const valid = await verifyPin(nextPin, storedHash, storedSalt || undefined);
if (valid) { if (valid) {
onUnlock(); onUnlock();
} else { } else {
@ -199,7 +201,7 @@ export function LockScreen({ onUnlock }: LockScreenProps) {
</Text> </Text>
</Pressable> </Pressable>
{renderKey('0')} {renderKey('0')}
<Pressable onPress={handleBackspace} style={styles.utilityBtn} accessibilityRole="button" accessibilityLabel="Backspace"> <Pressable onPress={handleBackspace} style={styles.utilityBtn} accessibilityRole="button" accessibilityLabel={t('lockScreen.backspace')}>
<Ionicons name="backspace-outline" size={24} color={theme.colors.fgSecondary} /> <Ionicons name="backspace-outline" size={24} color={theme.colors.fgSecondary} />
</Pressable> </Pressable>
</View> </View>
@ -228,12 +230,14 @@ export function LockScreen({ onUnlock }: LockScreenProps) {
/** 导出 PIN 设置/验证辅助函数供 settings 页面使用。 */ /** 导出 PIN 设置/验证辅助函数供 settings 页面使用。 */
export async function setPin(pin: string): Promise<void> { export async function setPin(pin: string): Promise<void> {
const hash = await hashPin(pin); const { hash, salt } = await hashPin(pin);
await SecureStore.setItemAsync(PIN_HASH_KEY, hash); await SecureStore.setItemAsync(PIN_HASH_KEY, hash);
await SecureStore.setItemAsync(PIN_SALT_KEY, salt);
} }
export async function clearPin(): Promise<void> { export async function clearPin(): Promise<void> {
await SecureStore.deleteItemAsync(PIN_HASH_KEY); await SecureStore.deleteItemAsync(PIN_HASH_KEY);
await SecureStore.deleteItemAsync(PIN_SALT_KEY);
} }
export async function hasPinSet(): Promise<boolean> { export async function hasPinSet(): Promise<boolean> {

View File

@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, View, type ViewStyle } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { ScreenHeader } from './ScreenHeader';
import { FormModal, type FormField } from './FormModal';
import { Touchable } from './Touchable';
/**
* P2 + + + + FormModal
* 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;
/** 删除确认弹窗标题(默认通用「删除」)。 */
deleteConfirmTitle?: string;
/** 字段值变更时的联动回调,返回新值覆盖。 */
onValuesChange?: (changedKey: string, newValue: string, prevValues: Record<string, string>) => Record<string, string> | void;
/** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */
formExtra?: (editing: T | null) => React.ReactNode;
/** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */
onOpenForm?: (editing: T | null) => void;
/** 列表上方的额外内容(分组 Tab 等)。 */
headerContent?: React.ReactNode;
footer?: React.ReactNode;
/** 条目容器的额外样式(如标签页的 flexDirection row + flexWrap。 */
listStyle?: ViewStyle;
}
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(props.deleteConfirmTitle ?? t('common.delete'), props.deleteConfirmText(item), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => props.onDelete(item) },
]);
};
const closeModal = () => {
setModalOpen(false);
setEditing(null);
};
const handleSubmit = (values: Record<string, string>) => {
if (props.onSubmit(values, editing)) closeModal();
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader
title={props.title}
right={
<Touchable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}>
<Ionicons name="add" size={26} color={theme.colors.accent} />
</Touchable>
}
/>
<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}
<View style={props.listStyle}>
{props.items.map(item => (
<View key={props.keyExtractor(item)}>
{props.renderItem(item, {
openEdit: () => openForm(item),
confirmDelete: () => confirmDelete(item),
})}
</View>
))}
</View>
{props.footer}
</ScrollView>
<FormModal
visible={modalOpen}
title={props.formTitle(editing)}
fields={props.formFields(editing)}
onValuesChange={props.onValuesChange}
onConfirm={handleSubmit}
onCancel={closeModal}
>
{props.formExtra?.(editing)}
</FormModal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16, gap: 12 },
});

View File

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

View File

@ -0,0 +1,844 @@
/**
* NumpadSheet P3
*
* chip chip
* ////PostingEditor
*
* - presentation="page"/transaction/new 宿 editId/draftJson
* - presentation="modal"NumpadSheetHost
* buildAndSaveTransaction()/editTransaction
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView,
StyleSheet, Switch, Text, TextInput, View,
} from 'react-native';
import { useNavigation, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n';
import { useLedgerStore } from '../store/ledgerStore';
import { useMetadataStore } from '../store/metadataStore';
import { useSettingsStore } from '../store/settingsStore';
import { TAG_COLORS } from '../theme/palette';
import { AccountCreateModal } from './AccountCreateModal';
import { BottomSheet } from './BottomSheet';
import { Button } from './Button';
import { CategoryIcon } from './CategoryIcon';
import { CategoryPicker } from './CategoryPicker';
import { DatePickerField } from './DatePickerField';
import { FormModal, type FormField } from './FormModal';
import { NumpadKeyboard } from './NumpadKeyboard';
import { PostingEditor } from './PostingEditor';
import { TagPicker } from './TagPicker';
import { pressKey, evaluateExpression, type NumpadKey } from './numpadExpression';
import { useOcrScan } from './useOcrScan';
import { buildAndSaveTransaction, buildPostings } from '../domain/transactionBuilder';
import { parsePostingsToLegs } from '../domain/postingLegs';
import { serializeTransaction } from '../domain/ledger';
import { addDecimals, compareDecimals, toDateString } from '../domain/decimal';
import { matchCategory } from '../domain/categories';
import type { Category } from '../domain/categories';
import type { Tag } from '../domain/tags';
import type { Posting } from '../domain/types';
import { logger } from '../utils/logger';
type Direction = 'expense' | 'income' | 'transfer';
interface NumpadSheetProps {
presentation?: 'modal' | 'page';
editId?: string;
draftJson?: string;
autoOcr?: boolean;
onClose?: () => void;
/** 表单脏状态变化上报modal 模式宿主用它做未保存守卫)。 */
onDirtyChange?: (dirty: boolean) => void;
}
export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr, onClose, onDirtyChange }: NumpadSheetProps) {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const navigation = useNavigation();
const ledger = useLedgerStore(s => s.ledger);
const editTransaction = useLedgerStore(s => s.editTransaction);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
const categories = useMetadataStore(s => s.categories);
const tags = useMetadataStore(s => s.tags);
const lastUsedAccount = useSettingsStore(s => s.lastUsedAccount);
const lastUsedCategoryId = useSettingsStore(s => s.lastUsedCategoryId);
const setLastUsedEntry = useSettingsStore(s => s.setLastUsedEntry);
const [direction, setDirection] = useState<Direction>('expense');
const [expr, setExpr] = useState('');
const [currency, setCurrency] = useState('CNY');
const [date, setDate] = useState(() => toDateString(new Date()));
const [narration, setNarration] = useState('');
const [payee, setPayee] = useState('');
const [sourceAccount, setSourceAccount] = useState('');
const [targetAccount, setTargetAccount] = useState('');
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
const [showMore, setShowMore] = useState(false);
const [isAdvanced, setIsAdvanced] = useState(false);
const [postings, setPostings] = useState<Posting[]>([]);
const [status, setStatus] = useState('');
const [submitting, setSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [accountPicker, setAccountPicker] = useState<'source' | 'target' | null>(null);
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false);
const [isQuickAddingAccount, setIsQuickAddingAccount] = useState(false);
const handleQuickAddAccount = async (values: Record<string, string>) => {
const typeVal = values.type?.trim();
const nameVal = values.name?.trim();
if (!typeVal || !nameVal) {
Alert.alert(t('account.addFail'), t('account.addFailEmpty'));
return;
}
const rawAccount = `${typeVal}:${nameVal}`;
let sanitized = rawAccount.replace(//g, ':');
sanitized = sanitized
.split(':')
.map((seg, idx) => {
if (idx === 0) return seg;
let cleaned = seg.replace(/[\(\)\[\]]/g, '-');
cleaned = cleaned.replace(/-+/g, '-');
cleaned = cleaned.replace(/^-|-$/g, '');
cleaned = cleaned.replace(/[^\w\u4e00-\u9fa5-]/g, '');
return cleaned;
})
.join(':');
try {
await autoOpenAccounts([sanitized]);
setIsQuickAddingAccount(false);
if (accountPicker === 'source') {
setSourceAccount(sanitized);
} else if (accountPicker === 'target') {
setTargetAccount(sanitized);
}
setAccountPicker(null);
} catch (e) {
Alert.alert(t('account.openFail'), e instanceof Error ? e.message : String(e));
}
};
// 编辑模式保留字段
const [oldRaw, setOldRaw] = useState('');
const [editFlag, setEditFlag] = useState<string | undefined>(undefined);
const [editLinks, setEditLinks] = useState<string[] | undefined>(undefined);
const [editMetadata, setEditMetadata] = useState<Record<string, string> | undefined>(undefined);
const amount = useMemo(() => evaluateExpression(expr), [expr]);
const { scan, status: ocrStatus } = useOcrScan();
// ===== 账户列表双腿之一的资金账户Assets + Liabilities =====
const assetAccounts = useMemo(() => {
if (!ledger) return [] as string[];
return Array.from(ledger.accounts.keys()).filter(
a => a.startsWith('Assets') || a.startsWith('Liabilities'),
);
}, [ledger]);
// chip 行:上次使用优先,最多 4 个 + 「⋯」
const chipAccounts = useMemo(() => {
const rest = assetAccounts.filter(a => a !== lastUsedAccount);
const pinned = lastUsedAccount && assetAccounts.includes(lastUsedAccount) ? [lastUsedAccount] : [];
return [...pinned, ...rest].slice(0, 4);
}, [assetAccounts, lastUsedAccount]);
// 默认资金账户:上次使用 > 列表第一个
useEffect(() => {
if (sourceAccount) return;
const def = lastUsedAccount && assetAccounts.includes(lastUsedAccount)
? lastUsedAccount
: assetAccounts[0];
if (def) setSourceAccount(def);
}, [assetAccounts, lastUsedAccount, sourceAccount]);
// ===== 分类快捷行 =====
const availableCategories = useMemo(
() => categories.filter(c => c.type === (direction === 'income' ? 'income' : 'expense')),
[categories, direction],
);
const quickCategories = useMemo(() => {
const pinned = availableCategories.find(c => c.id === lastUsedCategoryId);
const rest = availableCategories.filter(c => c.id !== lastUsedCategoryId);
return [...(pinned ? [pinned] : []), ...rest].slice(0, 3);
}, [availableCategories, lastUsedCategoryId]);
const parsedOriginalDraftRef = useRef<{ payee?: string; categoryAccount?: string; sourceAccount?: string; amount?: string; direction?: string } | null>(null);
// ===== 预填draftJson草稿 =====
useEffect(() => {
if (!draftJson) return;
try {
const draft = JSON.parse(draftJson);
if (draft.date) setDate(String(draft.date).slice(0, 10));
setNarration(draft.narration || '');
setPayee(draft.payee || '');
const legs = parsePostingsToLegs(draft.postings ?? []);
if (legs) {
setDirection(legs.direction);
setExpr(legs.amount);
setCurrency(legs.currency);
setSourceAccount(legs.sourceAccount);
setTargetAccount(legs.targetAccount);
if (legs.direction !== 'transfer') {
const matched = categories.find(c => c.linkedAccount === legs.targetAccount);
if (matched) setSelectedCategory(matched);
}
}
parsedOriginalDraftRef.current = {
payee: (draft.payee || '').trim(),
categoryAccount: legs?.targetAccount || '',
sourceAccount: legs?.sourceAccount || '',
amount: legs?.amount || '',
direction: legs?.direction || 'expense',
};
if (Array.isArray(draft.postings) && draft.postings.length > 0) {
setPostings(draft.postings.map((p: any) => ({
account: p.account, amount: p.amount, currency: p.currency,
cost: p.cost, price: p.price, metadata: p.metadata,
})));
// 带 cost/price/metadata 的分录在简单模式会丢失 → 进高级模式
const hasExtras = (draft.postings as Posting[]).some(
(p: Posting) => p.cost || p.price || (p.metadata && Object.keys(p.metadata).length > 0),
);
// 无法双腿解析(拆分交易等)→ 直接进高级模式,避免丢腿
if (!legs || hasExtras) setIsAdvanced(true);
}
// 标签预填(与 editId 预填同逻辑store 按 name 查找,找不到用兜底色)
if (Array.isArray(draft.tags) && draft.tags.length > 0) {
setSelectedTags(draft.tags.map((name: string) => {
const found = tags.find(tg => tg.name === name);
return found ?? { id: name, name, color: TAG_COLORS[3] };
}));
}
} catch (e) {
console.error('Failed to parse draftJson', e);
}
}, [draftJson, categories, tags]);
// ===== 预填editId编辑模式 =====
const editingTx = useMemo(
() => (editId ? ledger?.transactions.find(txn => txn.id === editId) : undefined),
[editId, ledger],
);
useEffect(() => {
if (!editingTx) return;
setOldRaw(editingTx.raw);
setDate(editingTx.date.slice(0, 10));
setNarration(editingTx.narration || '');
setPayee(editingTx.payee || '');
setEditFlag(editingTx.flag || '*');
setEditLinks(editingTx.links || []);
setEditMetadata(editingTx.metadata);
const legs = parsePostingsToLegs(editingTx.postings);
if (legs) {
setDirection(legs.direction);
setExpr(legs.amount);
setCurrency(legs.currency);
setSourceAccount(legs.sourceAccount);
setTargetAccount(legs.targetAccount);
if (legs.direction !== 'transfer') {
const matched = categories.find(c => c.linkedAccount === legs.targetAccount);
if (matched) setSelectedCategory(matched);
}
}
setPostings(editingTx.postings.map(p => ({
account: p.account, amount: p.amount, currency: p.currency,
cost: p.cost, price: p.price, metadata: p.metadata,
})));
// 带 cost/price/metadata 的分录在简单模式会丢失 → 进高级模式
const hasExtras = editingTx.postings.some(
p => p.cost || p.price || (p.metadata && Object.keys(p.metadata).length > 0),
);
// 无法双腿解析(拆分交易等)→ 直接进高级模式,避免丢腿
if ((!legs || hasExtras) && editingTx.postings.length > 0) setIsAdvanced(true);
if (editingTx.tags.length > 0) {
setSelectedTags(editingTx.tags.map(name => {
const found = tags.find(tg => tg.name === name);
return found ?? { id: name, name, color: TAG_COLORS[3] };
}));
}
}, [editingTx, categories, tags]);
// ===== OCR =====
const applyOcrEvent = (event: import('./useOcrScan').OcrEvent) => {
// 金额/摘要仅在当前为空时填充,避免覆盖用户已输入内容
if (event.amount) setExpr(prev => prev || event.amount!.replace(/^-/, ''));
if (event.direction === 'income' || event.direction === 'transfer') setDirection(event.direction);
else setDirection('expense');
const text = [event.counterparty, event.memo].filter(Boolean).join(' - ');
if (text) setNarration(prev => prev || text);
const matchText = [event.counterparty, event.memo].filter(Boolean).join(' ');
if (matchText) {
const type = event.direction === 'income' ? 'income' : 'expense';
const match = matchCategory(matchText, categories.filter(c => c.type === type));
if (match.category) setSelectedCategory(match.category);
}
};
const handleOcr = async () => {
const event = await scan();
if (event) applyOcrEvent(event);
};
useEffect(() => {
if (autoOcr) handleOcr();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoOcr]);
// ===== 未保存守卫 =====
const initialSnapshotRef = useRef<{ expr: string; narration: string; payee: string; tags: string[] } | null>(null);
useEffect(() => {
if (editingTx) {
const legs = parsePostingsToLegs(editingTx.postings);
initialSnapshotRef.current = {
expr: legs?.amount || '',
narration: editingTx.narration || '',
payee: editingTx.payee || '',
tags: editingTx.tags || [],
};
} else if (draftJson) {
try {
const draft = JSON.parse(draftJson);
const legs = parsePostingsToLegs(draft.postings ?? []);
initialSnapshotRef.current = {
expr: legs?.amount || '',
narration: draft.narration || '',
payee: draft.payee || '',
tags: Array.isArray(draft.tags) ? draft.tags : [],
};
} catch {}
}
}, [editingTx, draftJson]);
const formRef = useRef({ expr, narration, payee, selectedTags, isAdvanced, postings, isSubmitted });
formRef.current = { expr, narration, payee, selectedTags, isAdvanced, postings, isSubmitted };
// 脏状态计算(读 formRef整页守卫与 modal 宿主共用)
const computeDirty = () => {
const s = formRef.current;
if (s.isSubmitted) return false;
const init = initialSnapshotRef.current;
if (init) {
const tagsChanged = JSON.stringify(s.selectedTags.map(t => t.name).sort()) !== JSON.stringify([...init.tags].sort());
return (
evaluateExpression(s.expr) !== evaluateExpression(init.expr) ||
s.narration.trim() !== init.narration.trim() ||
s.payee.trim() !== init.payee.trim() ||
tagsChanged
);
}
return (
evaluateExpression(s.expr) !== '' ||
s.narration.trim() !== '' ||
s.payee.trim() !== '' ||
s.selectedTags.length > 0 ||
(s.isAdvanced && s.postings.some(p => p.account.trim() !== '' || (p.amount || '').trim() !== ''))
);
};
// modal 模式每次渲染后把脏状态上报给宿主Host 的遮罩/返回键守卫用)
useEffect(() => {
onDirtyChange?.(computeDirty());
});
// 整页模式beforeRemove 守卫
useEffect(() => {
if (presentation !== 'page') return;
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
if (!computeDirty()) 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;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigation, presentation, t]);
// ===== 键盘 =====
const handleKey = (key: string) => {
if (key === 'done') { submit(); return; }
if (key === 'today') { setDate(toDateString(new Date())); return; }
setExpr(prev => pressKey(prev, key as NumpadKey));
};
const swapAccounts = () => {
const s = sourceAccount;
setSourceAccount(targetAccount);
setTargetAccount(s);
};
const closeSoon = () => {
onClose?.();
};
// modal 工具行关闭按钮:脏数据时先确认(提交成功走 closeSoon 直关isSubmitted 已置位不会误拦)
const requestModalClose = () => {
if (!computeDirty()) { onClose?.(); return; }
Alert.alert(t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [
{ text: t('common.cancel'), style: 'cancel', onPress: () => {} },
{ text: t('common.discard'), style: 'destructive', onPress: () => onClose?.() },
]);
};
// ===== 提交 =====
const submit = async () => {
if (submitting || isSubmitted) return;
setSubmitting(true);
const tagNames = selectedTags.map(tg => tg.name);
try {
if (isAdvanced) {
// ===== 高级模式:多行分录 =====
const validPostings = postings.filter(p => p.account.trim());
if (validPostings.length < 2) { setStatus(t('transaction.atLeast2Postings')); return; }
const currencySum: Record<string, string[]> = {};
for (const p of validPostings) {
if (p.amount && p.currency) {
(currencySum[p.currency] = currencySum[p.currency] || []).push(p.amount);
}
}
const balanced = Object.values(currencySum).every(amounts => compareDecimals(addDecimals(amounts), '0') === 0);
if (!balanced) { 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 && editLinks.length > 0 ? editLinks : undefined,
metadata: editId ? editMetadata : undefined,
};
if (editId && oldRaw) {
await editTransaction(oldRaw, serializeTransaction(draft));
setStatus(t('transaction.editSuccess'));
} else {
await useLedgerStore.getState().addTransaction(draft);
setStatus(t('transaction.appended'));
}
setIsSubmitted(true);
closeSoon();
return;
}
// ===== 简单模式:双腿 =====
const amt = amount;
if (!amt || compareDecimals(amt, '0') <= 0) { setStatus(t('transaction.amountRequired')); return; }
if (!sourceAccount) { setStatus(t('numpad.noAccount')); return; }
if (direction === 'transfer' && sourceAccount === targetAccount) {
setStatus(t('transaction.sameAccountError'));
return;
}
// 转账双方必须是资金/负债账户,防止分类账户污染目标腿
if (direction === 'transfer') {
const notAssetLike = (a: string) => !a.startsWith('Assets') && !a.startsWith('Liabilities');
if (notAssetLike(sourceAccount) || notAssetLike(targetAccount)) {
setStatus(t('numpad.transferTargetError'));
return;
}
}
const categoryAccount = direction === 'transfer'
? targetAccount
: selectedCategory?.linkedAccount ?? (direction === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized');
const defaultNarration =
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
direction === 'income' ? t('transaction.narrationDefaultIncome') :
t('transaction.narrationDefaultTransfer');
if (editId && oldRaw) {
const draft = {
date,
payee: payee.trim() || undefined,
flag: editFlag,
narration: narration.trim() || defaultNarration,
postings: buildPostings(direction, sourceAccount, categoryAccount, amt, currency),
tags: tagNames,
links: editLinks && editLinks.length > 0 ? editLinks : undefined,
metadata: editMetadata,
};
await editTransaction(oldRaw, serializeTransaction(draft));
setStatus(t('transaction.editSuccess'));
setIsSubmitted(true);
closeSoon();
} else {
if (parsedOriginalDraftRef.current) {
const orig = parsedOriginalDraftRef.current;
const userPayee = payee.trim();
const userCategory = categoryAccount;
const userSource = sourceAccount;
const userAmount = amt;
const userDirection = direction;
const isCategoryChanged = userCategory !== orig.categoryAccount;
const isPayeeChanged = userPayee !== orig.payee;
const isAmountChanged = userAmount !== orig.amount;
const isSourceChanged = userSource !== orig.sourceAccount;
const isDirectionChanged = userDirection !== orig.direction;
if (isCategoryChanged || isPayeeChanged || isAmountChanged || isSourceChanged || isDirectionChanged) {
logger.info('userCorrection', `[用户修正草稿记账] 预填推荐 vs 用户手动修正:`, {
original: { payee: orig.payee, category: orig.categoryAccount, sourceAccount: orig.sourceAccount, amount: orig.amount, direction: orig.direction },
corrected: { payee: userPayee, category: userCategory, sourceAccount: userSource, amount: userAmount, direction: userDirection },
changes: { categoryChanged: isCategoryChanged, payeeChanged: isPayeeChanged, amountChanged: isAmountChanged, sourceChanged: isSourceChanged, directionChanged: isDirectionChanged },
});
}
}
await buildAndSaveTransaction({
date,
amount: amt,
payee: payee.trim() || undefined,
narration: narration.trim() || defaultNarration,
categoryAccount,
sourceAccount,
direction,
currency,
tags: tagNames,
}, useLedgerStore.getState());
setLastUsedEntry(sourceAccount || null, direction === 'transfer' ? lastUsedCategoryId : (selectedCategory?.id ?? null));
setStatus(t('transaction.appended'));
setIsSubmitted(true);
setExpr('');
setNarration('');
setSelectedCategory(null);
setSelectedTags([]);
closeSoon();
}
} catch (e) {
setStatus(String(e instanceof Error ? e.message : e));
} finally {
setSubmitting(false);
}
};
// ===== 高级模式切换时初始化分录 =====
const toggleAdvanced = (val: boolean) => {
setIsAdvanced(val);
if (val) {
const amt = amount;
const categoryAccount = direction === 'transfer'
? targetAccount
: selectedCategory?.linkedAccount ?? (direction === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized');
setPostings(buildPostings(direction, sourceAccount, categoryAccount, amt || '0', currency)
.map(p => ({ ...p, amount: p.amount === '-0' || p.amount === '0' ? '' : p.amount })));
}
};
const directions: { key: Direction; label: string }[] = [
{ key: 'expense', label: t('transaction.directionExpense') },
{ key: 'income', label: t('transaction.directionIncome') },
{ key: 'transfer', label: t('transaction.directionTransfer') },
];
const shortName = (acct: string) => acct.split(':').pop() || acct;
const previewTarget = direction === 'transfer'
? targetAccount
: selectedCategory?.linkedAccount ?? '';
const renderAccountRow = (label: string, value: string, onSelect: (a: string) => void, pickerKey: 'source' | 'target') => (
<View style={styles.accountRow}>
<Text style={[theme.typography.caption, styles.accountLabel, { color: theme.colors.fgSecondary }]}>{label}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.accountChips}>
{assetAccounts.length === 0 ? (
<Pressable onPress={() => router.push('/account')} style={commonStyles.chip}>
<Text style={[commonStyles.chipText, { color: theme.colors.accent }]}>{t('numpad.noAccount')}</Text>
</Pressable>
) : (
<>
{chipAccounts.map(acct => {
const active = value === acct;
return (
<Pressable
key={`${pickerKey}-${acct}`}
onPress={() => onSelect(acct)}
style={[commonStyles.chip, active && commonStyles.chipActive]}
>
<Text style={[commonStyles.chipText, active && commonStyles.chipTextActive]}>{shortName(acct)}</Text>
</Pressable>
);
})}
<Pressable onPress={() => setAccountPicker(pickerKey)} style={commonStyles.chip}>
<Text style={[commonStyles.chipText, { color: theme.colors.fgSecondary }]}></Text>
</Pressable>
</>
)}
</ScrollView>
</View>
);
return (
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={presentation === 'page' ? styles.flex1 : undefined}>
<View style={[
styles.sheet,
presentation === 'modal' && {
backgroundColor: theme.colors.bgSecondary,
borderTopLeftRadius: theme.radii.xl,
borderTopRightRadius: theme.radii.xl,
},
presentation === 'page' && { backgroundColor: theme.colors.bgPrimary },
]}>
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={styles.content}>
{/* 工具行 */}
<View style={styles.toolRow}>
{presentation === 'modal' ? (
<Pressable onPress={requestModalClose} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('numpad.close')}>
<Ionicons name="chevron-down" size={24} color={theme.colors.fgSecondary} />
</Pressable>
) : null}
<View style={styles.flex1} />
<Pressable onPress={handleOcr} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('home.ocrScan')}>
<Ionicons name="camera-outline" size={22} color={theme.colors.fgSecondary} />
</Pressable>
{presentation === 'page' ? (
<Pressable onPress={() => router.push('/import')} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('tab.import')} style={{ marginLeft: 16 }}>
<Ionicons name="download-outline" size={22} color={theme.colors.fgSecondary} />
</Pressable>
) : null}
</View>
{/* 方向 */}
<View style={styles.directionRow}>
{directions.map(d => (
<Pressable
key={d.key}
onPress={() => {
setDirection(d.key);
setSelectedCategory(null);
// 切到转账时清掉非资金/负债的目标账户,避免分类账户污染转账腿
if (d.key === 'transfer' && targetAccount
&& !targetAccount.startsWith('Assets') && !targetAccount.startsWith('Liabilities')) {
setTargetAccount('');
}
}}
style={[commonStyles.chip, direction === d.key && commonStyles.chipActive, styles.directionChip]}
>
<Text style={[commonStyles.chipText, direction === d.key && commonStyles.chipTextActive]}>{d.label}</Text>
</Pressable>
))}
</View>
{/* 金额 */}
<Text style={[theme.typography.display, styles.amount, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
¥ {amount || '0'}
</Text>
{/* 账户行(双腿之一) */}
{direction === 'transfer' ? (
<>
{renderAccountRow(t('numpad.outFrom'), sourceAccount, setSourceAccount, 'source')}
<Pressable onPress={swapAccounts} style={styles.swapBtn} accessibilityRole="button" accessibilityLabel={t('numpad.swap')}>
<Ionicons name="swap-vertical" size={18} color={theme.colors.fgSecondary} />
</Pressable>
{renderAccountRow(t('numpad.transferTo'), targetAccount, setTargetAccount, 'target')}
</>
) : (
renderAccountRow(direction === 'income' ? t('numpad.to') : t('numpad.from'), sourceAccount, setSourceAccount, 'source')
)}
{/* 分类快捷行(另一腿,非转账) */}
{direction !== 'transfer' && (
<View style={styles.categoryRow}>
{quickCategories.map(cat => {
const active = selectedCategory?.id === cat.id;
return (
<Pressable
key={cat.id}
onPress={() => setSelectedCategory(active ? null : cat)}
style={[styles.categoryCell, {
backgroundColor: active ? theme.colors.accentLight : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : 'transparent',
borderRadius: theme.radii.md,
}]}
>
<CategoryIcon categoryId={cat.id} size={28} />
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary }]} numberOfLines={1}>{cat.name}</Text>
</Pressable>
);
})}
<Pressable
onPress={() => setCategoryPickerOpen(true)}
style={[styles.categoryCell, { backgroundColor: theme.colors.bgTertiary, borderColor: 'transparent', borderRadius: theme.radii.md }]}
>
<Ionicons name="grid-outline" size={22} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('numpad.allCategories')}</Text>
</Pressable>
</View>
)}
{/* 数字键盘(简单模式) */}
{!isAdvanced && (
<NumpadKeyboard onKey={handleKey} todayLabel={t('numpad.today')} doneLabel={t('numpad.done')} backspaceLabel={t('numpad.backspace')} />
)}
{/* 分录预览(常驻小字) */}
{!isAdvanced && amount && sourceAccount && previewTarget ? (
<Text style={[theme.typography.caption, styles.preview, { color: theme.colors.fgSecondary }]} numberOfLines={1}>
{shortName(sourceAccount)} {shortName(previewTarget)} · ¥{amount} · {date}
</Text>
) : null}
{/* 更多抽屉 */}
<Pressable onPress={() => setShowMore(v => !v)} style={styles.moreToggle} accessibilityRole="button">
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>
{showMore ? t('numpad.less') : t('numpad.more')}
</Text>
<Ionicons name={showMore ? 'chevron-up-outline' : 'chevron-down-outline'} size={16} color={theme.colors.accent} />
</Pressable>
{showMore && (
<View style={styles.moreSection}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('transaction.formDate')}</Text>
<DatePickerField value={date} onChange={setDate} />
<Text style={[theme.typography.caption, styles.fieldGap, { color: theme.colors.fgSecondary }]}>{t('transaction.formCurrency')}</Text>
<TextInput
value={currency}
onChangeText={setCurrency}
autoCapitalize="characters"
maxLength={8}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
<Text style={[theme.typography.caption, styles.fieldGap, { color: theme.colors.fgSecondary }]}>{t('transaction.formNarration')}</Text>
<TextInput
value={narration}
onChangeText={setNarration}
placeholder={t('transaction.formNarration')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
<Text style={[theme.typography.caption, styles.fieldGap, { color: theme.colors.fgSecondary }]}>{t('transaction.formPayee')}</Text>
<TextInput
value={payee}
onChangeText={setPayee}
placeholder={t('transaction.formPayeePlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
<Text style={[theme.typography.caption, styles.fieldGap, { color: theme.colors.fgSecondary }]}>{t('transaction.formTags')}</Text>
<TagPicker
tags={tags}
selectedNames={selectedTags.map(tg => tg.name)}
onToggle={(tag) => setSelectedTags(prev =>
prev.some(x => x.name === tag.name) ? prev.filter(x => x.name !== tag.name) : [...prev, tag],
)}
/>
{/* 高级模式 */}
<View style={[commonStyles.switchRow, styles.advancedRow]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{t('numpad.advanced')}
</Text>
<Switch
value={isAdvanced}
onValueChange={toggleAdvanced}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</View>
)}
{/* 高级模式分录编辑器 */}
{isAdvanced && (
<View style={styles.advancedSection}>
<PostingEditor postings={postings} onChange={setPostings} />
<View style={styles.postingBtnRow}>
<View style={styles.flex1}>
<Button label={t('transaction.addPosting')} onPress={() => setPostings([...postings, { account: '', amount: '', currency }])} variant="secondary" />
</View>
<View style={styles.flex1}>
<Button label={t('transaction.deleteLastPosting')} onPress={() => postings.length > 2 && setPostings(postings.slice(0, -1))} variant="secondary" />
</View>
</View>
<Button label={t('transaction.submit')} onPress={submit} disabled={submitting || isSubmitted} />
</View>
)}
{status || ocrStatus ? (
<Text style={[theme.typography.bodySmall, styles.status, { color: theme.colors.fgSecondary }]}>{status || ocrStatus}</Text>
) : null}
</ScrollView>
{/* 全部账户 BottomSheet */}
<BottomSheet visible={accountPicker !== null} onClose={() => setAccountPicker(null)} title={t('numpad.allAccounts')}>
<ScrollView style={styles.accountList}>
<Pressable
onPress={() => setIsQuickAddingAccount(true)}
style={[styles.accountListItem, { borderBottomColor: theme.colors.divider, flexDirection: 'row', alignItems: 'center' }]}
>
<Ionicons name="add-circle-outline" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700', marginLeft: 6 }]}>
{t('account.addNew')}
</Text>
</Pressable>
{assetAccounts.map(acct => (
<Pressable
key={acct}
onPress={() => {
if (accountPicker === 'source') setSourceAccount(acct);
else if (accountPicker === 'target') setTargetAccount(acct);
setAccountPicker(null);
}}
style={[styles.accountListItem, { borderBottomColor: theme.colors.divider }]}
>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{acct}</Text>
</Pressable>
))}
</ScrollView>
</BottomSheet>
{/* 快捷新建账户 AccountCreateModal (全应用 100% 统一复用) */}
<AccountCreateModal
visible={isQuickAddingAccount}
defaultType="Assets"
onConfirm={handleQuickAddAccount}
onCancel={() => setIsQuickAddingAccount(false)}
/>
{/* 全部分类 BottomSheet */}
<BottomSheet visible={categoryPickerOpen} onClose={() => setCategoryPickerOpen(false)} title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
<ScrollView style={styles.accountList}>
<CategoryPicker
categories={availableCategories}
selectedId={selectedCategory?.id}
onSelect={(cat) => { setSelectedCategory(cat); setCategoryPickerOpen(false); }}
/>
</ScrollView>
</BottomSheet>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex1: { flex: 1 },
sheet: { maxHeight: '100%' },
content: { paddingHorizontal: 16, paddingBottom: 24 },
toolRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8 },
directionRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
directionChip: { flex: 1, alignItems: 'center' },
amount: { textAlign: 'right', paddingVertical: 8, fontVariant: ['tabular-nums'] },
accountRow: { flexDirection: 'row', alignItems: 'center', marginTop: 4 },
accountLabel: { width: 34 },
accountChips: { gap: 6, paddingRight: 8 },
swapBtn: { alignSelf: 'center', padding: 4 },
categoryRow: { flexDirection: 'row', gap: 6, marginTop: 10, marginBottom: 6 },
categoryCell: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 8, gap: 4, borderWidth: 1.5 },
preview: { textAlign: 'center', marginTop: 6 },
moreToggle: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 10 },
moreSection: { gap: 4 },
fieldGap: { marginTop: 10, marginBottom: 4 },
advancedRow: { marginTop: 14 },
advancedSection: { marginTop: 8, gap: 8 },
postingBtnRow: { flexDirection: 'row', gap: 8 },
status: { textAlign: 'center', marginTop: 8 },
accountList: { maxHeight: 360 },
accountListItem: { paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth },
});

View File

@ -0,0 +1,47 @@
import React, { useRef } from 'react';
import { Alert, Modal, Pressable, StyleSheet } from 'react-native';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { NumpadSheet } from './NumpadSheet';
import { useNumpadUiStore } from '../store/numpadUiStore';
/** 全局记账面板宿主:挂载于根布局,任意页面可唤起(不丢上下文)。 */
export function NumpadSheetHost() {
const { theme } = useTheme();
const t = useT();
const visible = useNumpadUiStore(s => s.visible);
const prefill = useNumpadUiStore(s => s.prefill);
const close = useNumpadUiStore(s => s.close);
// 表单脏状态NumpadSheet 每次渲染后上报),遮罩/返回键关闭前据此弹未保存确认
const dirtyRef = useRef(false);
const requestClose = () => {
if (!dirtyRef.current) { close(); return; }
Alert.alert(t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.discard'), style: 'destructive', onPress: close },
]);
};
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={requestClose}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={requestClose}>
<Pressable style={styles.sheetWrap} onPress={e => e.stopPropagation()}>
<NumpadSheet
presentation="modal"
editId={prefill?.editId}
draftJson={prefill?.draftJson}
autoOcr={prefill?.autoOcr}
onClose={close}
onDirtyChange={d => { dirtyRef.current = d; }}
/>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'flex-end' },
sheetWrap: { maxHeight: '94%' },
});

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useCallback, useMemo, useRef } from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native'; import { StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme, createCommonStyles } from '../theme'; import { useTheme, createCommonStyles } from '../theme';
import { BalanceIndicator } from './BalanceIndicator'; import { BalanceIndicator } from './BalanceIndicator';
@ -12,29 +12,41 @@ interface PostingEditorProps {
accounts?: string[]; accounts?: string[];
} }
/** 生成 Posting 的稳定 key基于内容的复合 key。 */
function postingKey(p: Posting, index: number): string {
return `${p.account ?? ''}|${p.amount ?? ''}|${p.currency ?? ''}|${index}`;
}
/** 分类编辑器(主题化):编辑多行 account/amount/currency实时显示平衡状态。 */ /** 分类编辑器(主题化):编辑多行 account/amount/currency实时显示平衡状态。 */
export function PostingEditor({ postings, onChange }: PostingEditorProps) { export function PostingEditor({ postings, onChange }: PostingEditorProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = createCommonStyles(theme); const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const postingsRef = useRef(postings);
postingsRef.current = postings;
const update = (index: number, patch: Partial<Posting>) => { // 使用函数式更新,避免 onChange 依赖 postings 导致每次渲染重建
onChange(postings.map((p, i) => i === index ? { ...p, ...patch } : p)); const update = useCallback((index: number, patch: Partial<Posting>) => {
}; const current = postingsRef.current;
onChange(current.map((p, i) => i === index ? { ...p, ...patch } : p));
}, [onChange]);
// 计算各币种余额 // 计算各币种余额
const balances = useMemo(() => {
const grouped = new Map<string, string[]>(); const grouped = new Map<string, string[]>();
for (const p of postings) { for (const p of postings) {
if (p.amount && p.currency) { if (p.amount && p.currency) {
grouped.set(p.currency, [...(grouped.get(p.currency) ?? []), p.amount]); grouped.set(p.currency, [...(grouped.get(p.currency) ?? []), p.amount]);
} }
} }
const balances: Record<string, string> = {}; const result: Record<string, string> = {};
for (const [cur, amts] of grouped) balances[cur] = addDecimals(amts); for (const [cur, amts] of grouped) result[cur] = addDecimals(amts);
return result;
}, [postings]);
return ( return (
<View style={{ gap: 8 }}> <View style={{ gap: 8 }}>
{postings.map((p, i) => ( {postings.map((p, i) => (
<View key={i} style={styles.row}> <View key={postingKey(p, i)} style={styles.row}>
<TextInput <TextInput
value={p.account} value={p.account}
onChangeText={v => update(i, { account: v })} onChangeText={v => update(i, { account: v })}
@ -48,7 +60,7 @@ export function PostingEditor({ postings, onChange }: PostingEditorProps) {
placeholder="金额" placeholder="金额"
keyboardType="decimal-pad" keyboardType="decimal-pad"
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace' }]} // 金额输入也采用等宽字体更专业 style={[commonStyles.input, { flex: 1, fontVariant: ['tabular-nums'] }]}
/> />
<TextInput <TextInput
value={p.currency ?? ''} value={p.currency ?? ''}

View File

@ -0,0 +1,44 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '../theme';
import { Touchable } from './Touchable';
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}>
<Touchable
onPress={onBack ?? (() => router.back())}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={backLabel}
>
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
</Touchable>
<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 },
});

View File

@ -17,7 +17,7 @@ export function SearchBar({ value, onChangeText, placeholder }: SearchBarProps)
styles.container, styles.container,
{ {
backgroundColor: theme.colors.bgTertiary, backgroundColor: theme.colors.bgTertiary,
borderRadius: theme.radii.lg, // 升级为 lg 圆角 borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border, borderColor: theme.colors.border,
}, },

View File

@ -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,
},
});

View File

@ -0,0 +1,41 @@
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'] },
});

View File

@ -0,0 +1,64 @@
/**
* spec §7.2 /
* gesture-handler Swipeable reanimated worklet
*/
import React, { useRef } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { TransactionCard } from './TransactionCard';
import type { Transaction } from '../domain/types';
interface SwipeableTransactionCardProps {
transaction: Transaction;
categoryId?: string;
showDate?: boolean;
onPress: (t: Transaction) => void;
/** 复制:跳转录入页预填(由调用方实现)。 */
onDuplicate: (t: Transaction) => void;
/** 删除:调用方负责 confirm + deleteTransaction。 */
onDelete: (t: Transaction) => void;
}
export function SwipeableTransactionCard({ transaction, categoryId, showDate, onPress, onDuplicate, onDelete }: SwipeableTransactionCardProps) {
const { theme } = useTheme();
const t = useT();
const swipeRef = useRef<Swipeable>(null);
const renderRightActions = () => (
<View style={styles.actions}>
<Pressable
onPress={() => { swipeRef.current?.close(); onDuplicate(transaction); }}
style={[styles.actionBtn, { backgroundColor: theme.colors.bgTertiary, borderRadius: theme.radii.md }]}
accessibilityRole="button"
accessibilityLabel={t('transactions.duplicate')}
>
<Ionicons name="copy-outline" size={20} color={theme.colors.fgPrimary} />
<Text style={[styles.actionText, { color: theme.colors.fgPrimary }]}>{t('transactions.duplicate')}</Text>
</Pressable>
<Pressable
onPress={() => { swipeRef.current?.close(); onDelete(transaction); }}
style={[styles.actionBtn, { backgroundColor: theme.colors.financial.expense, borderRadius: theme.radii.md }]}
accessibilityRole="button"
accessibilityLabel={t('common.delete')}
>
<Ionicons name="trash-outline" size={20} color={theme.colors.fgInverse} />
<Text style={[styles.actionText, { color: theme.colors.fgInverse }]}>{t('common.delete')}</Text>
</Pressable>
</View>
);
return (
<Swipeable ref={swipeRef} renderRightActions={renderRightActions} overshootRight={false}>
<TransactionCard transaction={transaction} categoryId={categoryId} showDate={showDate} onPress={onPress} />
</Swipeable>
);
}
const styles = StyleSheet.create({
actions: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingLeft: 8, marginBottom: 8 },
actionBtn: { width: 64, alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 10 },
actionText: { fontSize: 11, fontWeight: '600' },
});

View File

@ -1,4 +1,4 @@
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/tags';
@ -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}>

View File

@ -0,0 +1,110 @@
/**
* spec §7.1 / /
*
*/
import React, { useMemo } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { Card } from './Card';
import { useLedgerStore } from '../store/ledgerStore';
import { useMetadataStore } from '../store/metadataStore';
import { useAutomationStore } from '../store/automationStore';
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../domain/recurring';
import { calculateBillingPeriod } from '../domain/creditCards';
import type { CreditCard } from '../domain/creditCards';
import { toDateString } from '../domain/decimal';
import { shiftAnchor } from '../domain/periodNav';
export function TodoStrip() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const addTransaction = useLedgerStore(s => s.addTransaction);
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
const creditCards = useMetadataStore(s => s.creditCards);
const draftsCount = useAutomationStore(s => s.drafts.length);
const todayStr = useMemo(() => toDateString(new Date()), []);
const weekLater = useMemo(() => shiftAnchor(todayStr, 'weekly', 1), [todayStr]);
// 下一还款日当月还款日已过则取下月周期月末跨月场景filter 与展示共用)
const nextDueDate = (card: CreditCard): string => {
const now = new Date();
const period = calculateBillingPeriod(card, now);
if (period.dueDate >= todayStr) return period.dueDate;
return calculateBillingPeriod(card, new Date(now.getFullYear(), now.getMonth() + 1, 1)).dueDate;
};
// autoConfirm 的由首页自动处理,待办条只列需手动确认的
const dueRecurring = useMemo(
() => getDueRecurring(recurringTransactions, todayStr).filter(r => !r.autoConfirm),
[recurringTransactions, todayStr],
);
const dueCards = useMemo(
() => creditCards.filter(card => {
const due = nextDueDate(card);
return due >= todayStr && due <= weekLater;
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[creditCards, todayStr, weekLater],
);
if (dueRecurring.length === 0 && dueCards.length === 0 && draftsCount === 0) return null;
const handleConfirmRecurring = async (rec: (typeof dueRecurring)[number]) => {
try {
const draft = instantiateRecurring(rec);
await addTransaction(draft);
const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate);
updateRecurringTransaction(rec.id, { nextDueDate });
Alert.alert(t('home.recurringSuccess'), t('home.recurringSuccessDesc', { name: rec.name }));
} catch (e) {
Alert.alert(t('home.recurringFail'), String(e));
}
};
const renderRow = (icon: keyof typeof Ionicons.glyphMap, text: string, key: string, action?: { label: string; onPress: () => void }) => (
<View key={key} style={[styles.row, { borderBottomColor: theme.colors.divider }]}>
<Ionicons name={icon} size={18} color={theme.colors.accent} />
<Text style={[theme.typography.bodySmall, styles.rowText, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
{text}
</Text>
{action ? (
<Pressable
onPress={action.onPress}
style={[styles.actionBtn, { backgroundColor: theme.colors.accent, borderRadius: theme.radii.full }]}
accessibilityRole="button"
accessibilityLabel={action.label}
>
<Text style={[styles.actionText, { color: theme.colors.fgInverse }]}>{action.label}</Text>
</Pressable>
) : (
<Ionicons name="chevron-forward" size={16} color={theme.colors.fgSecondary} />
)}
</View>
);
return (
<Card title={t('todo.title')}>
{dueRecurring.map(rec =>
renderRow('repeat-outline', t('todo.dueRecurring', { name: rec.name }), `rec-${rec.id}`, { label: t('todo.confirm'), onPress: () => handleConfirmRecurring(rec) }),
)}
{dueCards.map(card =>
renderRow('card-outline', t('todo.creditCardDue', { name: card.name, date: nextDueDate(card).slice(5) }), `card-${card.id}`, { label: t('todo.view'), onPress: () => router.push('/credit-card') }),
)}
{draftsCount > 0 &&
renderRow('flash-outline', t('todo.pendingDrafts', { count: draftsCount }), 'drafts', { label: t('todo.view'), onPress: () => router.push('/automation') })}
</Card>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth },
rowText: { flex: 1 },
actionBtn: { paddingHorizontal: 12, paddingVertical: 5 },
actionText: { fontSize: 12, fontWeight: '700' },
});

View File

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

Some files were not shown because too many files have changed in this diff Show More