feat: OCR 模型按需下载 + 三层独立开关 + 日志持久化 + 原生浮层主题同步 + 文档重构
OCR 模型按需下载(P8 瘦身): - 移除 plugins/ppocr/assets/ 内置模型(det 9.8MB + rec 21MB + dict),APK 减包 ~31MB - 新增 modelDownloader.ts:优先从 HuggingFace/CDN 下载,兜底从 APK assets 拷贝 - OcrModule.kt 新增 setModelDir,支持从 filesystem 加载模型,回退 assets 兼容旧用户 - settingsStore 持久化 ocrModelVersion / ocrModelDir - 自动化页集成模型状态检查与一键下载 UI OCR 三层独立控制: - OcrProcessorConfig 从单一 aiVisionEnabled 拆分为 layer1/2/3 三个独立开关 - 设置页可按层启停(L1 正则规则 / L2 本地 OCR / L3 AI Vision) - OCR 处理增加耗时与字符数日志 日志系统升级: - logger.ts 新增 LogFileBackend 抽象,支持磁盘持久化(按日期 app-YYYY-MM-DD.log) - 新增 logBackend.ts(ExpoLogFileBackend)+ 日志中心页 settings/logs.tsx - 日志中心:实时缓冲 + 历史文件、4 级过滤、Tag/关键词搜索、JSON 展开、分享导出、7 天过期清理 - _layout.tsx 启动时初始化文件后端 + 日志脱敏(验证码/卡号) 原生浮层 UI 主题同步(P6): - 新增 floatingUiConfig.ts:JS 侧从 theme tokens + i18n 构建 FloatingUiConfig 推送原生 - 新增 FloatingUiConfigStore.kt:SharedPreferences 存储,三浮层组件读取 - FloatingBillView 重设计:颜色/文案走配置、新增币种 chip、金额校验改 BigDecimal - FloatingHelper / FloatingTip 同步适配 - _layout.tsx 新增 FloatingUiConfigSyncer,主题/语言切换自动推送 UI 与组件增强: - FormModal 新增 select/dropdown 控件、行内布局(row/flex)、联动回调 onValuesChange - 新增 Touchable 通用触摸组件、AccountCreateModal 快速建账弹窗 - 信用卡页展示账单周期/到期还款日/本期应还/剩余可用额度,关联账户改下拉选择 - AI 设置页重做:OpenAI/Gemini/DeepSeek 预设 + 默认 URL/模型 - 引导页新增 Android 权限检查步骤(无障碍/通知/短信/存储/悬浮窗) 去重优化: - 对手方匹配改为模糊包含(includes),双方均无对手方时判定低置信度重复 - DedupResult 新增 matchedItem 返回匹配对比项 文档重构: - README.md 重写为入口索引(品牌更新 + 模块概览 + 文档导航表) - 新增 AGENTS.md(AI 助手贡献指南)、docs/architecture.md(Mermaid 数据流/分层/OCR 级联图) - 新增 docs/development.md(环境/命令/编码规范/测试/提交规范)、plugins/README.md - UI 重设计文档(design spec + p1-p8)移入 docs/design/ 其他: - i18n 新增权限/信用卡详情/日志中心/AI 设置等翻译键 - ppocr Config Plugin 修复 import 注入去重;size-optimization 增强 - 新增测试:logger.test.ts、floating-ui-config.test.ts
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
# 系统架构
|
||||
|
||||
## 核心不变量
|
||||
|
||||
`.bean` 文件是唯一事实来源;SQLite 仅为可丢弃的读缓存。
|
||||
|
||||
## 数据流
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph DataSource[数据源]
|
||||
A[".bean 文件"]
|
||||
B["OCR / 无障碍 / 通知 / 短信"]
|
||||
C[“手动录入 / CSV 导入"]
|
||||
end
|
||||
|
||||
subgraph Pipeline[处理层]
|
||||
D["BillPipeline 串行互斥锁"]
|
||||
E[“转账识别"]
|
||||
F[“去重"]
|
||||
G[“规则分类"]
|
||||
end
|
||||
|
||||
subgraph Storage[存储层]
|
||||
H["main.bean 追加写入"]
|
||||
I["SQLite 缓存"]
|
||||
J["Zustand Store 内存索引"]
|
||||
end
|
||||
|
||||
subgraph UI[展示层]
|
||||
K["Expo Router 页面"]
|
||||
L[“报表 / 图表"]
|
||||
end
|
||||
|
||||
A -->|parseLedger| J
|
||||
B -->|原始事件| D
|
||||
C -->|TransactionDraft| D
|
||||
D --> E --> F --> G
|
||||
G -->|确认写入| H
|
||||
H -->|reparse| J
|
||||
J --> K
|
||||
J --> L
|
||||
I -.->|搜索查询| J
|
||||
```
|
||||
|
||||
## 模块分层
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph UILayer["UI 层: src/app + src/components"]
|
||||
R[“Expo Router 页面"]
|
||||
CO[“可复用组件"]
|
||||
end
|
||||
|
||||
subgraph StateLayer["状态层: src/store"]
|
||||
S[“Zustand Stores"]
|
||||
end
|
||||
|
||||
subgraph DomainLayer["领域层: src/domain — 纯 TS 零 RN 依赖"]
|
||||
LE["ledger.ts 解析器"]
|
||||
P["billPipeline.ts"]
|
||||
D2["dedup / transfer / rules"]
|
||||
O["ocrProcessor.ts"]
|
||||
end
|
||||
|
||||
subgraph Infra[“基础设施"]
|
||||
FS["expo-file-system"]
|
||||
DB["expo-sqlite"]
|
||||
NAT["plugins/ 原生模块"]
|
||||
end
|
||||
|
||||
R --> S
|
||||
CO --> S
|
||||
S --> LE
|
||||
S --> P
|
||||
P --> D2
|
||||
P --> O
|
||||
LE --> FS
|
||||
D2 --> DB
|
||||
O --> NAT
|
||||
```
|
||||
|
||||
## BillPipeline 处理顺序
|
||||
|
||||
所有导入渠道(手动、CSV、OCR、无障碍、通知、短信、截图)统一经过 `BillPipeline`,严格按序执行:
|
||||
|
||||
1. **转账识别** — 配对收入+支出 → 单笔转账(必须先于去重)
|
||||
2. **批内去重** — 时间窗口 + 金额 + 交易对手
|
||||
3. **历史去重** — 按日期索引比对已提交交易
|
||||
4. **规则分类** — 规则匹配 → 关键词 → `Uncategorized` 兆底
|
||||
|
||||
## 原生模块
|
||||
|
||||
原生功能以 Expo Config Plugin 形式封装在 `plugins/<name>/`,详见 [plugins/README.md](../plugins/README.md)。
|
||||
|
||||
原生服务**不直接写入**数据库或文件,而是通过 `NativeEventEmitter` 将原始事件推送到 JS 层管道。
|
||||
|
||||
## OCR 三层级联
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[“图像输入"] --> B["Layer 1: 正则规则"]
|
||||
B -->|未命中| C["Layer 2: 本地 OCR PP-OCRv6"]
|
||||
C -->|未命中| D["Layer 3: AI Vision 付费"]
|
||||
```
|
||||
|
||||
## 双轨映射
|
||||
|
||||
Beancount 无原生“分类/预算”概念,应用在本地 SQLite 维护 UI 元数据,写入时映射回 Beancount 语义:
|
||||
|
||||
| 概念 | 本地表 | 映射到 `.bean` |
|
||||
|------|--------|----------------|
|
||||
| 分类 | `categories` | `linkedAccount` → posting 账户 |
|
||||
| 标签 | `tags` | narration 中的 `#tag` |
|
||||
| 预算 | `budgets` | 仅本地,不影响余额 |
|
||||
| 信用卡 | `credit_cards` | 账户在 `.bean`,UI 字段本地 |
|
||||
@@ -9,6 +9,7 @@
|
||||
**与 spec 的偏差**:①account 页是「账户浏览器」(类型 Tab + 开户/调余额/关户三个交互),不套 ManagementScreen,只做 ScreenHeader 统一;②`numpadGlobalEntry` 翻默认只影响新安装(已持久化的 false 不覆盖,尊重用户选择)。
|
||||
|
||||
**模板适配决策**(已通读 7 页源码):
|
||||
|
||||
- budget/rules/remark-template → 直套(T1)
|
||||
- category → `headerContent` 放支出/收入 chips,新增按当前类型(T2)
|
||||
- recurring → 卡片保留显式编辑/删除按钮(用 handlers.openEdit/confirmDelete),不用长按(T2)
|
||||
@@ -19,6 +20,7 @@
|
||||
### Task 1: 管理页模板化批次 1(budget / rules / remark-template)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/app/budget/index.tsx`、`src/app/rules/index.tsx`、`src/app/remark-template/index.tsx`
|
||||
|
||||
- [ ] **Step 1: 三页套 ManagementScreen**(参照试点 `src/app/tag/index.tsx` 与模板 `src/components/ManagementScreen.tsx`)
|
||||
@@ -26,6 +28,7 @@
|
||||
通用映射:页面 state 消失(modal/editing 由模板持有);手写 header/addBtn/Alert/FormModal 全删;`footer` 传 `common.clickEditLongDelete` 提示;`deleteConfirmTitle` 传各页 `t('xxx.deleteTitle')`。
|
||||
|
||||
**budget/index.tsx**:
|
||||
|
||||
- `items={budgets}`、`keyExtractor={b => b.id}`、`addLabel={t('budget.add')}`、`emptyText={t('budget.empty')}`
|
||||
- renderItem:保留现有 Card + 进度条结构(Pressable onPress={openEdit} onLongPress={confirmDelete} 包裹)。`today` 改用 `toDateString(new Date())`(修掉 `toISOString().slice(0,10)` 的 UTC 偏移)
|
||||
- formFields:5 字段(name/amount decimal-pad/period/categoryAccount/startDate),defaultValue 从 editing 取
|
||||
@@ -33,12 +36,14 @@
|
||||
- onDelete:`removeBudget(budget.id)`;deleteConfirmText:`t('budget.deleteConfirm', { name: budget.name })`
|
||||
|
||||
**rules/index.tsx**:
|
||||
|
||||
- `items={[...rules].sort((a, b) => b.priority - a.priority)}`
|
||||
- renderItem:保留规则行(narration/P 徽标/条件/目标账户+命中次数);**删除 `fontFamily: theme.typography.caption.fontFamily`**(审计项)
|
||||
- formFields:7 字段(priority numeric/counterpartyContains/memoContains/sourceAccount/categoryAccount/narration/tags)
|
||||
- onSubmit:保留现有 Rule 组装逻辑;addRule `{...rule, id: generateId('rule'), hits: 0}` / updateRule
|
||||
|
||||
**remark-template/index.tsx**:
|
||||
|
||||
- 2 字段(name/template);renderItem 的模板文本 `fontFamily: 'monospace'` 改 `fontVariant: ['tabular-nums']`(审计项)
|
||||
- onSubmit:name 空回退 `t('common.untitled')`
|
||||
|
||||
@@ -52,6 +57,7 @@ Run: `grep -ln "FormModal\|arrow-back" src/app/budget/index.tsx src/app/rules/in
|
||||
### Task 2: 管理页模板化批次 2(category / recurring / credit-card)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/app/category/index.tsx`、`src/app/recurring/index.tsx`、`src/app/credit-card/index.tsx`
|
||||
|
||||
- [ ] **Step 1: category/index.tsx — headerContent 类型 chips**
|
||||
@@ -87,6 +93,7 @@ Run: `grep -ln "FormModal\|arrow-back" src/app/category/index.tsx src/app/recurr
|
||||
### Task 3: ScreenHeader 统一(account + settings 三页)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/app/account/index.tsx`、`src/app/settings/sync.tsx`、`src/app/settings/ai.tsx`、`src/app/settings/preferences.tsx`
|
||||
|
||||
- [ ] **Step 1: account/index.tsx**
|
||||
@@ -107,6 +114,7 @@ Run: `grep -rn "arrow-back" src/app/ | grep -v ScreenHeader` → 无输出
|
||||
### Task 4: 图表与杂项清理(fontFamily / emoji / 星期头 i18n)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/components/charts/CategoryPie.tsx`、`src/components/charts/TrendLine.tsx`、`src/components/charts/NetWorthChart.tsx`、`src/components/CalendarView.tsx`、`src/components/charts/CalendarHeatmap.tsx`、`src/components/DatePickerField.tsx`、`src/components/NumpadKeyboard.tsx`、`src/components/NumpadSheet.tsx`、`src/ai/monthlySummary.ts`、`src/app/ai/chat.tsx`、`src/app/transaction/[id].tsx`、`src/app/_error.tsx`、`src/app/import/index.tsx`、`src/components/PostingEditor.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
|
||||
|
||||
- [ ] **Step 1: fontFamily 清零**
|
||||
@@ -142,6 +150,7 @@ Run: `grep -rn "fontFamily" src/ --include="*.tsx" | grep -v "_error.tsx"` →
|
||||
### Task 5: 收尾(翻默认 + modal 守卫 + MonthlyReport 删除 + budgets 时区修复 + duplicate 增强)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/store/settingsStore.ts`、`src/components/NumpadSheet.tsx`、`src/components/NumpadSheetHost.tsx`、`src/domain/budgets.ts`、`src/app/(tabs)/transactions.tsx`、`tests/budgets.test.ts`(或现有 budget 测试文件)
|
||||
- Delete: `src/components/charts/MonthlyReport.tsx`
|
||||
|
||||
@@ -207,6 +216,7 @@ Run: `npm run typecheck` → 无错误
|
||||
|
||||
Run: `npm run android`
|
||||
走查清单(浅/暗双主题):
|
||||
|
||||
1. 6 个管理页(分类/预算/规则/周期/信用卡/备注模板):+新增、点按编辑、删除(recurring 为显式按钮,其余长按)、空态文案
|
||||
2. account 页:类型 Tab、开户、调余额、关户
|
||||
3. settings/sync、ai、preferences:header 统一有返回箭头
|
||||
@@ -0,0 +1,317 @@
|
||||
# UI 重设计 P6:原生浮层(悬浮球/悬浮窗)重设计 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。
|
||||
|
||||
**Goal:** 原生浮层(FloatingHelper 悬浮球 / FloatingBillView 浮窗 / FloatingTip 提示)视觉与文案对齐新设计系统:颜色与文案由 JS 层(theme tokens + i18n)通过 bridge 下发,原生不再硬编码中文与旧靛蓝;悬浮球位置持久化;浮窗补币种字段;前台账单 Alert i18n 化并去 emoji。
|
||||
|
||||
**背景决策(已确认):**
|
||||
- 原生 View 无法直接用 RN theme,采用 **`FloatingUiConfig` 下发模式**:JS 侧从当前 theme + i18n 构建 config → bridge `setFloatingUiConfig()` → 原生存 SharedPreferences(JSON)→ 三个浮层组件读取。原生保留现有硬编码值作为**默认值兜底**(JS 未推送时行为不变)。
|
||||
- 浮窗卡片**跟随 App 主题**(浅色=白卡近黑 accent,深色=OLED 黑卡白 accent),卡片用实色(不再半透明磨砂),保证在其他 App 上的可读性。
|
||||
- 浮窗金额校验从 `toDoubleOrNull` 改正则 + BigDecimal;自动消失 15s → 30s。
|
||||
- 改动范围:`plugins/accessibility/`(Kotlin)+ `src/services/`(bridge/config)+ `src/app/_layout.tsx` + `src/services/automationPipeline.ts` + `src/i18n/`。**不碰 domain 写路径。**
|
||||
|
||||
---
|
||||
|
||||
## FloatingUiConfig 契约(T1-T5 共同遵守,先读这里)
|
||||
|
||||
### JS → 原生:`setFloatingUiConfig(config: ReadableMap): Promise<boolean>`
|
||||
|
||||
```typescript
|
||||
interface FloatingUiConfig {
|
||||
colors: {
|
||||
accent: string; // 按钮/选中态背景(= theme accent:light #111318 / dark #F3F4F6)
|
||||
accentFg: string; // accent 上的文字色(= fgInverse)
|
||||
cardBg: string; // 卡片底色(= bgSecondary)
|
||||
inputBg: string; // 输入框/未选中 chip 底色(= bgTertiary)
|
||||
fgPrimary: string;
|
||||
fgSecondary: string;
|
||||
border: string;
|
||||
income: string; // financial.income
|
||||
expense: string; // financial.expense
|
||||
transfer: string; // financial.transfer
|
||||
};
|
||||
labels: {
|
||||
billTitle: string; // 浮窗标题
|
||||
dirExpense: string; dirIncome: string; dirTransfer: string;
|
||||
amountLabel: string; payeeLabel: string;
|
||||
narrationLabel: string; narrationHint: string;
|
||||
categoryExpense: string; // 「交易分类」
|
||||
categoryIncome: string; // 「收入分类」
|
||||
transferTarget: string; // 「转入账户」
|
||||
accountExpense: string; // 「资金来源」
|
||||
accountIncome: string; // 「存入账户」
|
||||
accountTransfer: string; // 「转出账户」
|
||||
openApp: string; dismiss: string; confirm: string;
|
||||
ballOcr: string; // 悬浮球「识别账单」
|
||||
ballRemember: string; // 「记住此页」
|
||||
rememberSuccess: string; // 记住页面成功提示(不含 emoji)
|
||||
rememberFail: string; // 失败提示前缀(原生拼接 ': ' + e.message)
|
||||
pageRemembered: string; // BillingAccessibilityService Toast「已记住页面签名」前缀
|
||||
pageSignatureExists: string; // 「该页面签名已存在」前缀
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- 颜色一律 `"#RRGGBB"` 六位数 hex(theme tokens 里有 rgba 的字段不要直接下发——accent/cardBg/fg/border 等都是纯色,确认无 rgba;border 深色主题是 `rgba(255,255,255,0.08)`,下发前换算为 hex:深色用 `#14202429`? **不许发明值**——正确做法:原生 `Color.parseColor` 支持 `#AARRGGBB`,JS 侧把 rgba(255,255,255,0.08) 换算为 `#14FFFFFF`(alpha 0.08≈0x14)。写一个 `rgbaToHex` 小工具处理这种情况,纯色 hex 原样通过)。
|
||||
- 原生端用 `Color.parseColor` 解析,异常时回退默认值。
|
||||
|
||||
### 原生持久化
|
||||
|
||||
- SharedPreferences 文件名:`floating_ui_config`,单键 `config_json` 存整个 JSON。
|
||||
- 新增 `FloatingUiConfigStore.kt`(object):`save(context, ReadableMap)` / `load(context): FloatingUiConfig`(data class,字段缺省值=现状硬编码值,保证旧 JS 行为不变)。
|
||||
- `FloatingUiConfig` data class 字段名与上面契约一致(camelCase)。
|
||||
|
||||
### 事件契约变更(T3+T5)
|
||||
|
||||
- `showFloatingBill` 增加参数 `currency: String`(放在 `direction` 之后、`draftId` 之前)。
|
||||
- `billingConfirmed` / `billingOpenApp` 事件 map 增加 `putString("currency", currentCurrency)`。
|
||||
- 浮窗金额行左侧显示当前币种 chip,点击在 `listOf("CNY","USD","HKD","JPY","EUR","GBP")` 中循环切换;初始值 = 传入 currency(不在列表则插入到首位)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: JS 侧 — i18n 键 + floatingUiConfig 服务 + bridge 接口 + 推送接线 + 前台 Alert i18n
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`、`src/services/accessibilityBridge.ts`、`src/app/_layout.tsx`、`src/services/automationPipeline.ts`
|
||||
- Create: `src/services/floatingUiConfig.ts`、`tests/floating-ui-config.test.ts`
|
||||
|
||||
- [ ] **Step 1: i18n 新增 `floating.*` 键组(zh/en 对等,`%{name}` 插值语法)**
|
||||
|
||||
```
|
||||
floating.billTitle 调整交易草稿 / Adjust Draft
|
||||
floating.dirExpense 支出 / Expense (复用现有方向键也行,但浮层独立一组避免耦合)
|
||||
floating.dirIncome 收入 / Income
|
||||
floating.dirTransfer 转账 / Transfer
|
||||
floating.amountLabel 金额 / Amount
|
||||
floating.payeeLabel 交易对手 / Payee
|
||||
floating.narrationLabel 描述/备注 / Narration
|
||||
floating.narrationHint 输入交易叙述 / Enter narration
|
||||
floating.categoryExpense 交易分类 / Category
|
||||
floating.categoryIncome 收入分类 / Income Category
|
||||
floating.transferTarget 转入账户 / To Account
|
||||
floating.accountExpense 资金来源 / From Account
|
||||
floating.accountIncome 存入账户 / To Account
|
||||
floating.accountTransfer 转出账户 / From Account
|
||||
floating.openApp 打开应用 / Open App
|
||||
floating.dismiss 忽略 / Dismiss
|
||||
floating.confirm 确认入账 / Confirm
|
||||
floating.ballOcr 识别账单 / Scan Bill
|
||||
floating.ballRemember 记住此页 / Remember Page
|
||||
floating.rememberSuccess 已将当前页面加入识别白名单 / Page added to recognition whitelist
|
||||
floating.rememberFail 记录失败 / Failed to save
|
||||
floating.pageRemembered 已记住页面签名 / Page signature saved
|
||||
floating.pageSignatureExists 该页面签名已存在 / Page signature already exists
|
||||
```
|
||||
|
||||
(若现有键已有同文案可复用,但键组保持独立 `floating.*`;先 grep 避免重复键名。)
|
||||
|
||||
同时给前台 Alert 加键(放 `automation.*` 下,先读现有 automation 节):
|
||||
```
|
||||
automation.billDetectedTitle 识别到新账单 / New Bill Detected (无 emoji)
|
||||
automation.billDetectedReject 拒绝/丢弃 / Discard
|
||||
automation.billDetectedEdit 修改并入账 / Edit & Save
|
||||
automation.billDetectedConfirm 确认入账 / Confirm
|
||||
```
|
||||
Alert 的多行消息体(日期/商户/金额/分类/账户/叙述)逐行用既有标签键拼,不为每行加新键(行标签可复用表单/详情现有键,先 grep `transaction.payee` 之类;没有合适的就在 automation 节加 `billDetectedLine*` 键)。
|
||||
|
||||
- [ ] **Step 2: `src/services/floatingUiConfig.ts`**
|
||||
|
||||
```typescript
|
||||
export interface FloatingUiConfig { colors: {...}; labels: {...} } // 按契约
|
||||
|
||||
/** rgba(r,g,b,a) → #AARRGGBB;#RRGGBB 原样返回。 */
|
||||
export function colorToHex(color: string): string;
|
||||
|
||||
/** 从 theme tokens + t() 构建完整 config。 */
|
||||
export function buildFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): FloatingUiConfig;
|
||||
|
||||
/** 构建并推送到原生(bridge 不可用时静默返回 false)。 */
|
||||
export async function pushFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): Promise<boolean>;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: bridge 接口扩展**
|
||||
|
||||
`accessibilityBridge.ts`:`NativeAccessibilityBridge` 加 `setFloatingUiConfig(config: FloatingUiConfig): Promise<boolean>;`,文件头注释方法清单补一行。`showFloatingBill` 签名加 `currency: string`(direction 之后、draftId 之前)。
|
||||
|
||||
- [ ] **Step 4: _layout.tsx 推送接线**
|
||||
|
||||
- 启动时(setFloatingBallEnabled 同一处,`src/app/_layout.tsx:139` 附近)也 `pushFloatingUiConfig(...)`。注意此处拿不到 useTheme 的 theme(在 ThemeProvider 外层?先读 _layout 结构确认)——若拿不到,启动这次推送可移到下一步的组件里统一做,避免重复。
|
||||
- 在 ThemeProvider **内部**挂一个小组件(如 `FloatingUiConfigSyncer`,可直接写在 _layout.tsx 里):`const { theme } = useTheme(); const t = useT(); useEffect(() => { pushFloatingUiConfig(theme, t); }, [theme, t])` —— 主题切换/语言切换/启动都会重推。t 引用随 locale 变化(确认 useT 返回的 t 在语言切换时引用变化,先读 src/i18n 实现;若 t 引用稳定,则依赖里加 locale)。
|
||||
|
||||
- [ ] **Step 5: automationPipeline.ts 前台 Alert i18n + 去 emoji**
|
||||
|
||||
`src/services/automationPipeline.ts:298` 的 `Alert.alert('🌟 识别到新账单', ...)`:标题/按钮全部改 t();消息体保留原信息结构。该文件是 service 层非组件——确认文件里如何拿 t(若没有,用 `i18n.t(...)` 直接调,读 src/i18n/index.ts 导出了什么)。`showFloatingBill` 调用处(:262)传第 7 个参数 `currency`(用 :225 已提取的 currency 变量)。
|
||||
|
||||
- [ ] **Step 6: 测试 + 验证**
|
||||
|
||||
`tests/floating-ui-config.test.ts`:
|
||||
- `colorToHex`:`'#111318'` 原样;`'rgba(255,255,255,0.08)'` → `#14FFFFFF`;`'rgba(17,19,24,0.4)'` → `#66111318`(alpha 四舍五入 Math.round(a*255))
|
||||
- `buildFloatingUiConfig`:用 lightTheme + 真实 zh t() 构建,断言 colors.accent === '#111318'、labels.billTitle === '调整交易草稿'、所有契约字段非空(遍历 keys)
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿(i18n parity 测试覆盖新键)
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 原生 config 基础设施(FloatingUiConfigStore + bridge 方法 + FloatingTip/Toast 改读)
|
||||
|
||||
**Files:**
|
||||
- Create: `plugins/accessibility/android/FloatingUiConfigStore.kt`
|
||||
- Modify: `plugins/accessibility/android/AccessibilityBridgeModule.kt`、`plugins/accessibility/android/FloatingTip.kt`、`plugins/accessibility/android/BillingAccessibilityService.kt`
|
||||
|
||||
- [ ] **Step 1: FloatingUiConfigStore.kt**
|
||||
|
||||
- `data class FloatingUiConfig(...)`:colors/labels 全部字段,默认值 = 现状硬编码(accent `#5E6AD2`、cardBg `#FF050506`? —— **不**:默认值应是「现状视觉」,但现状 cardBg 是 0x8C050506 半透明。默认 cardBg 用 `#F2050506`? 简化:默认值就用现状各硬编码颜色换算成 `#AARRGGBB`/`#RRGGBB` 字符串,逐字段列注释对应原 Kotlin 常量)。labels 默认值 = 现状中文硬编码文案(去 emoji)。
|
||||
- `object FloatingUiConfigStore`:`private const val PREFS = "floating_ui_config"`;`fun save(context: Context, map: ReadableMap)`(遍历契约字段,缺失字段跳过不覆盖,JSONObject 组装后写入);`fun load(context: Context): FloatingUiConfig`(JSONObject 读取,缺失/解析失败逐字段回退默认;`optString`)。颜色字段提供 `fun parseColorOr(value: String, fallback: Int): Int` 工具(`Color.parseColor` try/catch)。
|
||||
- org.json 可用(Android 内置),无需新依赖。
|
||||
|
||||
- [ ] **Step 2: AccessibilityBridgeModule.setFloatingUiConfig**
|
||||
|
||||
```kotlin
|
||||
@ReactMethod
|
||||
fun setFloatingUiConfig(config: ReadableMap, promise: Promise) {
|
||||
try {
|
||||
FloatingUiConfigStore.save(reactContext, config)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("CONFIG_SAVE_FAIL", e.message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: FloatingTip/RepeatToast 改读 config**
|
||||
|
||||
- FloatingTip.show():背景色 `0xF0333333` → config accent(不透明化:解析后 `or 0xFF000000.toInt()`),文字色 → accentFg,ProgressBar `progressTintList` 设 accentFg。构造签名加可选 `config: FloatingUiConfig? = null`,null 时内部 `FloatingUiConfigStore.load(context)`。
|
||||
- RepeatToast 的 `"⚠ $message"` 前缀 emoji 去掉,只留 message(⚠ 属 emoji 审计范围)。
|
||||
- FloatingTip 调用处(FloatingHelper.kt:209/211)改传 config 文案:`labels.rememberSuccess`、`"${labels.rememberFail}: ${e.message}"`(T4 做也可以,此处先改文案读取,FloatingHelper 的彻底重设计在 T4)。
|
||||
|
||||
- [ ] **Step 4: BillingAccessibilityService 两处 Toast 文案**
|
||||
|
||||
:387 `"已记住页面签名:\n$sig"` 与 :419 `"该页面签名已存在:\n$sig"` → 改读 `FloatingUiConfigStore.load(this).labels` 的 pageRemembered / pageSignatureExists + `":\n$sig"`。
|
||||
|
||||
- [ ] **Step 5: 编译验证**
|
||||
|
||||
`android/` 已生成。把改动的 .kt 手动拷到 `android/app/src/main/java/com/beancount/mobile/accessibility/`(与 plugins 目录同名文件覆盖),然后:
|
||||
Run: `cd android && ./gradlew :app:compileDebugKotlin -q` → BUILD SUCCESSFUL
|
||||
(若编译环境不可用,记录为设备验证项并在报告中说明。)
|
||||
|
||||
---
|
||||
|
||||
### Task 3: FloatingBillView 重设计 + 币种
|
||||
|
||||
**Files:**
|
||||
- Modify: `plugins/accessibility/android/FloatingBillView.kt`、`plugins/accessibility/android/AccessibilityBridgeModule.kt`(showFloatingBill 加 currency 参数透传)
|
||||
|
||||
**视觉规格(对齐 docs/ui-redesign-design.md §3 Bento):**
|
||||
- 卡片:实色 cardBg、圆角 16dp、1dp border 描边、padding 16dp(原 12/8 加宽)
|
||||
- 标题:fgPrimary 13sp bold;分段选择器:选中=accent 底 accentFg 字,未选中=透明 fgSecondary 字,圆角 6dp
|
||||
- 字段标签:fgSecondary 10sp bold;输入框:inputBg 底、圆角 10dp、1dp border、fgPrimary 字、hint fgSecondary
|
||||
- chips:圆角 999(用 dp(14f) 近似胶囊)、未选中=inputBg+border 描边+fgSecondary 字、选中:分类行支出/收入=accent;转账方向第一行=transfer 色;账户行按方向=income/expense 色(语义色保留,但改从 config 读)
|
||||
- 按钮:高 40dp、圆角 12dp;confirm=accent 底 accentFg 字 bold;openApp/dismiss=inputBg 底 fgPrimary 字
|
||||
- 所有颜色经 `FloatingUiConfigStore.load(context)` + parseColorOr 兜底;所有文案经 labels
|
||||
|
||||
- [ ] **Step 1: 构造签名 + showFloatingBill 参数**
|
||||
|
||||
`FloatingBillView` 构造加 `initialCurrency: String = "CNY"`;`AccessibilityBridgeModule.showFloatingBill` 加 `currency: String` 参数(direction 后 draftId 前)并透传。
|
||||
|
||||
- [ ] **Step 2: 币种 chip**
|
||||
|
||||
金额行:水平布局,左侧币种 chip(inputBg+border,fgPrimary 字,显示 currentCurrency),右侧金额输入框(weight 1)。点击 chip 在 `listOf("CNY","USD","HKD","JPY","EUR","GBP")` 循环(initialCurrency 不在列表则临时插到首位)。sendSaveEvent/sendOpenAppEvent 的 map 加 `putString("currency", currentCurrency)`。
|
||||
|
||||
- [ ] **Step 3: 金额校验改正则 + BigDecimal**
|
||||
|
||||
`afterTextChanged`:`Regex("^\\d+(\\.\\d{1,2})?$")` 匹配且 `BigDecimal(text) > BigDecimal.ZERO` 才启用保存(BigDecimal 构造 try/catch)。禁用时按钮底色 inputBg + fgSecondary 字。
|
||||
|
||||
- [ ] **Step 4: 全面替换颜色与文案 + 自动消失 30s**
|
||||
|
||||
逐段按上面视觉规格重写 show() 与 rebuildChips()(结构不变,只换色值来源、文案来源、尺寸);`15000L` → `30000L`。标签按方向切换的文案(交易分类/收入分类/转入账户、资金来源/存入账户/转出账户)全部走 labels。
|
||||
|
||||
- [ ] **Step 5: 编译验证**(同 T2 Step 5 流程)
|
||||
|
||||
---
|
||||
|
||||
### Task 4: FloatingHelper 重设计 + 位置持久化
|
||||
|
||||
**Files:**
|
||||
- Modify: `plugins/accessibility/android/FloatingHelper.kt`
|
||||
|
||||
- [ ] **Step 1: 颜色与文案走 config**
|
||||
|
||||
- 指示竖线:accent(保持 70% 透明度:解析后 alpha 设为 0xB0)
|
||||
- 菜单:实色 cardBg、圆角 12dp、1dp border
|
||||
- 「识别账单」按钮:accent 底 accentFg 字(主操作);「记住此页」:inputBg 底 fgPrimary 字;按压态透明度变化保留(按下时 alpha 0.8)
|
||||
- ScanIconDrawable/PinIconDrawable 颜色:Scan 用 accentFg(在 accent 底按钮上),Pin 用 fgSecondary
|
||||
- 文案 labels.ballOcr / ballRemember / rememberSuccess / rememberFail(带 ': ' + message 拼接)
|
||||
|
||||
- [ ] **Step 2: 位置持久化**
|
||||
|
||||
- SharedPreferences 复用 `billing_accessibility_prefs`:键 `floating_ball_x` / `floating_ball_y`
|
||||
- `show()` 初始化:`params.x/y = prefs.getInt(...)`,无键时用现状默认(0/400);拖动抬起(吸附后)写 prefs
|
||||
- companion 的 lastX/lastY 保留为进程内缓存但初始从 prefs 读(或直接用局部变量+prefs,简化则删 companion——注意多实例语义,BillingAccessibilityService 单例,安全)
|
||||
|
||||
- [ ] **Step 3: 编译验证**(同 T2 Step 5 流程)
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 币种链路 JS 消费侧
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/services/automationPipeline.ts`、`src/app/_layout.tsx`
|
||||
|
||||
- [ ] **Step 1: billingConfirmed 处理器用 res.currency**
|
||||
|
||||
读 `src/services/automationPipeline.ts:50-110` 的 handleBillingConfirmed:`const currency = pending?.event?.currency || 'CNY'`(:78)→ 优先 `res.currency`(浮窗用户改过),`res.currency || pending?.event?.currency || 'CNY'`。确认 res 类型定义处加 currency 字段(NativeOpenAppEvent 之类,grep 类型声明一起改)。
|
||||
|
||||
- [ ] **Step 2: billingOpenApp 处理器**
|
||||
|
||||
`src/app/_layout.tsx:255` `const currency = 'CNY'` → `const currency = res.currency || 'CNY'`。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
|
||||
---
|
||||
|
||||
### Task 6: P6 审计 + 验收
|
||||
|
||||
- [ ] **Step 1: 审计**
|
||||
|
||||
```bash
|
||||
grep -rn "0xFF5E6AD2\|0x995E6AD2\|0xB05E6AD2\|0x1F5E6AD2\|0x405E6AD2" plugins/accessibility/android/ # 无输出(旧靛蓝清零,默认值除外——FloatingUiConfigStore 默认值允许保留并注释)
|
||||
grep -rn '"[^"]*[一-鿿]' plugins/accessibility/android/*.kt | grep -v "FloatingUiConfigStore\|//" # 除 Store 默认值与注释外无硬编码中文 UI 字符串
|
||||
grep -rn "⚠\|📌\|🌟" src/ plugins/ # 无输出
|
||||
grep -n "currency" src/services/accessibilityBridge.ts # showFloatingBill 含 currency
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 全量测试 + typecheck + Kotlin 编译**
|
||||
|
||||
Run: `npm test` → 全绿;`npm run typecheck` → 无错误;`cd android && ./gradlew :app:compileDebugKotlin -q` → 成功
|
||||
|
||||
- [ ] **Step 3: 手工走查(需设备)**
|
||||
|
||||
Run: `npm run android`
|
||||
1. 设置里切换 浅/深主题 → 触发一次浮窗(可用 automation 页调试入口或真实账单)→ 浮窗颜色跟随主题
|
||||
2. 切换英文 → 浮窗/悬浮球/提示文案全英文
|
||||
3. 拖动悬浮球 → 杀进程重启无障碍服务 → 位置保持
|
||||
4. 浮窗点币种 chip 切换 USD → 确认入账 → 交易币种为 USD
|
||||
5. 金额输入 `1e10` / `0` / `0.005` → 保存按钮禁用;`12.50` → 可用
|
||||
6. 浮窗 30 秒无操作自动消失;触摸后不消失
|
||||
|
||||
---
|
||||
|
||||
## 终审修订(已实施,以实际代码为准)
|
||||
|
||||
**终审日期**:2026-07-22,审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
|
||||
|
||||
| # | 级别 | 问题 | 修复 |
|
||||
|---|---|---|---|
|
||||
| C1 | Critical | FloatingBillView 标题 `setTextColor(colorAccentFg)` — 浅色主题下白卡白字、深色主题下黑底黑字,标题不可见 | 改为 `colorFgPrimary` |
|
||||
| I1 | Important | 容器边框从 accent 派生 alpha 而非用 config.border | 删除 `colorContainerBorder`,直接使用 `colorBorder` |
|
||||
| I2 | Important | FloatingHelper btnRemember 背景从 fgPrimary 派生,计划要求 inputBg | 加 `colorInputBg` 解析,btnRemember 改从 inputBg 派生 |
|
||||
| I3 | Important | 悬浮球菜单背景半透明(65% cardBg),计划要求实色 | 菜单背景直接使用 `colorCardBg`(full opacity),仅描边保留半透明 |
|
||||
| M1 | Minor | FloatingTip 背景未强制不透明化(plan §T2 Step 3) | 加 `or 0xFF000000.toInt()` 保护 |
|
||||
| M2 | Minor | 金额校验先 BigDecimal 解析再正则,正则先匹配可早期短路 | 调整为先 `pattern.matches(text)` 再 `value != null && value > BigDecimal.ZERO` |
|
||||
|
||||
**偏差说明(无需修复)**:
|
||||
- FloatingHelper 菜单背景 / 按钮背景 / 指示线的 alpha 分量组合(`(colorX and 0x00FFFFFF) or alpha`)是原生浮层叠加其他 App 所必需的透明度控制,不是颜色硬编码。颜色 RGB 分量完全来自 config。
|
||||
- FloatingTip 默认 fallback `#FF000000` 是纯黑(非靛蓝),因为 accent 在极浅色主题下是 `#111318`(近黑),fallback 用黑色是安全的极限退化。
|
||||
- ScanIconDrawable laserColor `0xFFF87171` 保留为语义色(扫描红光),不受主题控制。
|
||||
@@ -0,0 +1,192 @@
|
||||
# P7:权限快捷引导 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax. **不执行任何 git 操作**(用户要求,改动留工作区)。不创建额外任务清单。
|
||||
|
||||
**Goal:** 引导页权限步骤从纯文字改可操作状态清单;automation 页补齐通知监听/短信/存储三项权限的状态检测与快速跳转按钮。不新增原生权限,只加 UI 入口和一座通知监听状态检测 bridge 方法。
|
||||
|
||||
---
|
||||
|
||||
## 背景决策
|
||||
|
||||
- **不新增权限**。所有权限已在 AndroidManifest 中声明(通过 Config Plugin),P7 只加 UI 入口。
|
||||
- **通知监听状态检测**需要一个原生 bridge 方法(`isNotificationListenerEnabled`),因为 RN 侧无法直接检查。用 `NotificationManager.getEnabledListenerPackages()`。
|
||||
- **短信 + 存储**用 React Native 内置 `PermissionsAndroid` API。
|
||||
- **引导页 UI** 沿用现有 Card + Button 组件,与 P1-P5 设计系统一致。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 新增桥接方法 `isNotificationListenerEnabled`
|
||||
|
||||
**Files:**
|
||||
- Modify: `plugins/accessibility/android/AccessibilityBridgeModule.kt`
|
||||
- Modify: `src/services/accessibilityBridge.ts`
|
||||
|
||||
- [ ] **Step 1: AccessibilityBridgeModule.kt 加方法**
|
||||
|
||||
```kotlin
|
||||
@ReactMethod
|
||||
fun isNotificationListenerEnabled(promise: Promise) {
|
||||
try {
|
||||
val pm = reactContext.packageManager
|
||||
val enabledListeners = android.app.NotificationManager::class.java
|
||||
.getMethod("getEnabledListenerPackages")
|
||||
.invoke(reactContext.getSystemService(Context.NOTIFICATION_SERVICE)) as? List<String>
|
||||
val myPkg = reactContext.packageName
|
||||
promise.resolve(enabledListeners?.any { it.startsWith(myPkg) } == true)
|
||||
} catch (e: Exception) {
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:`getEnabledListenerPackages()` 在 Android 11+ 需 `isNotificationListenerEnabled(ComponentName)`;优先用反射避免 API level lint 错误,反射失败返回 false。
|
||||
|
||||
- [ ] **Step 2: JS 侧 bridge 接口**
|
||||
|
||||
`NativeAccessibilityBridge` 加 `isNotificationListenerEnabled(): Promise<boolean>;`
|
||||
|
||||
- [ ] **Step 3: 编译验证**
|
||||
|
||||
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 引导页权限步骤(`_onboarding.tsx` 重写 permissions 步骤)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/_onboarding.tsx`
|
||||
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`(少量新键)
|
||||
|
||||
- [ ] **Step 1: i18n 新键(zh/en 对等)**
|
||||
|
||||
在 `onboarding` section 加:
|
||||
```
|
||||
permAccessibility 无障碍服务 / Accessibility Service
|
||||
permNotification 通知监听 / Notification Listener
|
||||
permOverlay 悬浮窗 / Overlay
|
||||
permSms 短信读取 / SMS
|
||||
permStorage 相册/存储 / Photos & Storage
|
||||
permGranted 已授权 / Granted
|
||||
permNotGranted 未授权 / Not Granted
|
||||
permOpenSettings 前往设置 / Open Settings
|
||||
permRequest 请求权限 / Request
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 重写 permissions 步骤**
|
||||
|
||||
替换 `_onboarding.tsx:48` 的 `key: 'permissions'` 步骤内容。在当前 description 下方加权限状态清单(Card 包裹,每行:图标 + 名称 + 状态点 + 操作按钮)。
|
||||
|
||||
权限列表:
|
||||
1. **无障碍服务**(`BIND_ACCESSIBILITY_SERVICE`)—— 最核心。检测 `serviceRunning`(从 `getAccessibilityBridge().isServiceRunning()` 异步取)。未授权 → 跳转 `ACCESSIBILITY_SETTINGS`
|
||||
2. **通知监听**(`BIND_NOTIFICATION_LISTENER_SERVICE`)—— 检测 `bridge.isNotificationListenerEnabled()`。未授权 → 跳转 `ACTION_NOTIFICATION_LISTENER_SETTINGS`
|
||||
3. **短信**(`RECEIVE_SMS`)—— `PermissionsAndroid.check()`。未授权 → `PermissionsAndroid.request()`
|
||||
4. **存储**(`READ_MEDIA_IMAGES`,Android 13+ 用 `READ_MEDIA_IMAGES`,否则 `READ_EXTERNAL_STORAGE`)—— 同上
|
||||
|
||||
**实现细节**:
|
||||
- `const [permStates, setPermStates] = useState<Record<string, boolean | null>>({})` —— null = 加载中
|
||||
- `useEffect(() => { checkAllPermissions(); }, [])` —— 步骤切到 permissions 时触发
|
||||
- `checkAllPermissions`:调 `PermissionsAndroid.check()` 检查短信和存储;调 bridge 检查无障碍和通知监听
|
||||
- 每行渲染:`Ionicons` 图标 + `permLabel` + 右侧状态文字(绿「已授权」/ 灰「未授权」)+ 未授权时显示操作按钮
|
||||
- 「全部已授权」时显示一个大绿色勾 + 提示文字,按钮不显示
|
||||
|
||||
**权限 API 版本适配**:
|
||||
- `PermissionsAndroid.PERMISSIONS` 里 Android 13+ 是 `READ_MEDIA_IMAGES`,低版本用 `READ_EXTERNAL_STORAGE`
|
||||
- 统一用 `Platform.Version >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE'`
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
Run: `grep -rn "onboarding.perm" src/i18n/zh.ts src/i18n/en.ts` → 确认键对等
|
||||
|
||||
---
|
||||
|
||||
### Task 3: automation 页补齐权限入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/automation/index.tsx`
|
||||
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`(少量新键)
|
||||
|
||||
- [ ] **Step 1: i18n 新键**
|
||||
|
||||
在 `automation` section 加:
|
||||
```
|
||||
notificationTitle 通知监听服务 / Notification Listener
|
||||
notificationEnabled 已启用 / Enabled
|
||||
notificationDisabled 未启用 / Disabled
|
||||
notificationOpenSettings 前往系统设置 / Open Settings
|
||||
smsPermissionTitle 短信读取权限 / SMS Permission
|
||||
smsPermissionGranted 已授权 / Granted
|
||||
smsPermissionRequest 请求权限 / Request
|
||||
storagePermissionTitle 存储权限 / Storage Permission
|
||||
storagePermissionGranted 已授权 / Granted
|
||||
storagePermissionRequest 请求权限 / Request
|
||||
```
|
||||
|
||||
- [ ] **Step 2: automation 页加权限卡片**
|
||||
|
||||
在现有「无障碍服务状态与控制」Card **之后**加一个新 Card:`<Card title={t('automation.otherPermissions')}>`。
|
||||
|
||||
内容三行:
|
||||
1. **通知监听**:异步 `bridge.isNotificationListenerEnabled()` → 状态文字 + 未启用时「前往系统设置」按钮(`Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS')`)
|
||||
2. **短信**:`PermissionsAndroid.check('android.permission.RECEIVE_SMS')` + `PermissionsAndroid.request()`
|
||||
3. **存储**:`PermissionsAndroid.check(StoragePermission)` + `PermissionsAndroid.request()`
|
||||
|
||||
**实现模式**:
|
||||
```tsx
|
||||
const [notifEnabled, setNotifEnabled] = useState(false);
|
||||
const [smsGranted, setSmsGranted] = useState(false);
|
||||
const [storageGranted, setStorageGranted] = useState(false);
|
||||
useEffect(() => {
|
||||
// 异步获取各权限状态
|
||||
checkNotif(); checkSms(); checkStorage();
|
||||
}, []);
|
||||
```
|
||||
|
||||
每行渲染:`Ionicons` 图标 + 权限名称 + 状态文字 + 未授权时的按钮。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 编译 + 测试 + 终审
|
||||
|
||||
- [ ] **Step 1: 编译验证**
|
||||
|
||||
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 2: 全量测试**
|
||||
|
||||
`npm test` → 全绿;`npm run typecheck` → 0 错误
|
||||
|
||||
- [ ] **Step 3: 审计**
|
||||
|
||||
```bash
|
||||
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
|
||||
grep -rn "fontFamily" src/app/_onboarding.tsx src/app/automation/index.tsx # 无输出
|
||||
grep -rnE "📊|💰|💸|📝|🔄|🎉|✨|⚠|📌|🌟" src/i18n/ src/app/_onboarding.tsx src/app/automation/index.tsx # 无输出(预存 i18n fallbackWarn ⚠ 除外)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 手工走查(需设备)**
|
||||
|
||||
1. 清数据 → 启动应用 → 引导页第 5 步(权限)→ 看到 4 个权限状态
|
||||
2. 点未授权的无障碍 → 跳系统无障碍设置 → 返回看到状态已变
|
||||
3. 点未授权的通知监听 → 跳系统通知设置
|
||||
4. 点短信 → 弹出系统权限对话框
|
||||
5. 引导页全部完成后 → 进入 automation 页 → 看到新增的权限卡片
|
||||
|
||||
---
|
||||
|
||||
## 终审修订(已实施,以实际代码为准)
|
||||
|
||||
**终审日期**:2026-07-22,审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
|
||||
|
||||
| # | 级别 | 问题 | 处理 |
|
||||
|---|---|---|---|
|
||||
| — | — | 无 Critical / Important 发现 | — |
|
||||
|
||||
**偏差说明**:
|
||||
1. `onboarding.permOverlay`(悬浮窗)未加入引导页——计划 Task 2 决策列为可选项,实际未实现。不影响核心功能(sideload 自动授予 + 无障碍运行时浮窗走 ACCESSIBILITY_OVERLAY 不依赖此权限)。
|
||||
2. T3 中 `Platform.Version >= 33` 改为 `Number(Platform.Version) >= 33`——React Native 的 `Platform.Version` 类型为 `string | number`,直接比较会 TS 报错。
|
||||
3. T1 的 `isNotificationListenerEnabled` 使用反射而非 `enabledListenerPackages` 属性——minSdk=24 < API 27,反射是兼容方案。
|
||||
@@ -0,0 +1,338 @@
|
||||
# P8:自动记账可配置化 + 模型按需下载 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git 操作**。不创建额外任务清单。
|
||||
|
||||
**Goal:** automation 页统一管控所有自动记账能力(规则/OCR/AI Vision 三层独立开关 + 模型下载管理 + AI 配置整合),`settings/ai.tsx` 删除并重定向;OCR 模型从 APK assets 改为应用内下载(filesystem 路径加载 ONNX)。
|
||||
|
||||
**背景决策**:
|
||||
- 三层开关默认值:L1 规则 `true`、L2 OCR `true`、L3 AI Vision `false`(需用户配 API Key 后才可开启)
|
||||
- 模型下载:首次启用 L2 时检查模型是否存在,无则弹出下载提示
|
||||
- `settings/ai.tsx` 删除,原入口重定向到 automation 页
|
||||
- 去重/转账识别两个 setting 字段不动(它们不是「层」而是管道的独立步骤),但在 automation 页加开关
|
||||
- 模型从 assets 复制到 filesystem:预构建时仍打 APK assets 以兼容旧用户,应用首次启动将模型文件从 assets 拷贝到 documentDirectory,OcrModule 从 filesystem 加载。**这样之后卸载 assets 打包就变成了纯删除**(未来版本切到纯下载只需移除 assets + app.plugin.js 禁拷贝)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: settingsStore 新增字段 + 迁移
|
||||
|
||||
**Files:** `src/store/settingsStore.ts`
|
||||
|
||||
- [ ] **Step 1: 接口 + DEFAULTS + 持久化 + setter**
|
||||
|
||||
在 `SettingsState` 接口加 5 个新字段:
|
||||
```typescript
|
||||
/** 自动记账 L1:正则规则匹配 */
|
||||
layer1RuleEnabled: boolean;
|
||||
/** 自动记账 L2:本地 OCR 识别 */
|
||||
layer2OcrEnabled: boolean;
|
||||
/** 自动记账 L3:云端 AI Vision */
|
||||
layer3AiEnabled: boolean;
|
||||
/** OCR 模型版本(已下载),空字符串表示未安装 */
|
||||
ocrModelVersion: string;
|
||||
/** OCR 模型下载路径 */
|
||||
ocrModelDir: string;
|
||||
```
|
||||
|
||||
DEFAULTS:
|
||||
- `layer1RuleEnabled: true`
|
||||
- `layer2OcrEnabled: true`
|
||||
- `layer3AiEnabled: false`
|
||||
- `ocrModelVersion: ''`
|
||||
- `ocrModelDir: ''`
|
||||
|
||||
`PersistableSettings` 加对应 5 字段;`toPersistable` 加 5 行;新增 5 个 setter 函数。现有 `aiEnabled` 保留作为 LLM 的总开关语义(`layer3AiEnabled` 是在 automation 页独立控制,「启用」需同时满足 `aiEnabled && aiApiKey`)。
|
||||
|
||||
**注意**:`isNotificationListenerEnabled` 这类 bridge 方法在 `setFloatingBallEnabled` 附近——新字段与它们无关,不加 bridge 方法。
|
||||
|
||||
- [ ] **Step 2: 验证**
|
||||
|
||||
`npm run typecheck` → 0 错误;`npm test` → 全绿
|
||||
|
||||
---
|
||||
|
||||
### Task 2: OcrProcessor 逐级回退逻辑
|
||||
|
||||
**Files:** `src/domain/ocrProcessor.ts`、`src/services/ocrBridge.ts`、`src/services/automationPipeline.ts`
|
||||
|
||||
- [ ] **Step 1: OcrProcessorConfig 扩展**
|
||||
|
||||
`OcrProcessorConfig` 接口加:
|
||||
```typescript
|
||||
layer1Enabled: boolean; // 默认 true
|
||||
layer2Enabled: boolean; // 默认 true
|
||||
layer3Enabled: boolean; // 默认 false
|
||||
```
|
||||
|
||||
- [ ] **Step 2: doProcess() 逐级跳过**
|
||||
|
||||
在 `OcrProcessor.doProcess()` 中修改流程(约 :103-162):
|
||||
|
||||
```
|
||||
// 1. 横屏 / 图片去重 守卫不变
|
||||
|
||||
// 2. L1+L2 需要 OCR 文本
|
||||
if (config.layer1Enabled || config.layer2Enabled) {
|
||||
if (!ocrEngine) 返回 none(引擎不可用)
|
||||
ocrText = await ocrEngine.recognizeText(imageBase64)
|
||||
if (!ocrText) → 如果 L3 启用则 goto L3,否则返回 none
|
||||
} else {
|
||||
ocrText = ''
|
||||
}
|
||||
|
||||
// 3. Layer 1(仅当启用)
|
||||
if (config.layer1Enabled && ocrText && !isDetailPage) {
|
||||
result = matchOcrRule(ocrText, packageName)
|
||||
if (result) return { layer: 'layer1-rule', ... }
|
||||
}
|
||||
|
||||
// 4. Layer 2(仅当启用)
|
||||
if (config.layer2Enabled && ocrText) {
|
||||
result = parseOcrBill(ocrText, packageName)
|
||||
if (result) return { layer: 'layer2-ocr', ... }
|
||||
}
|
||||
|
||||
// 5. Layer 3(仅当启用)
|
||||
if (config.layer3Enabled) {
|
||||
return tryLayer3(imageBase64, ocrText)
|
||||
}
|
||||
|
||||
// 6. 全部未命中或全部关闭
|
||||
return { event: null, layer: 'none', skipped: 'non-bill' }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: automationPipeline.ts 的 getOcrProcessor 改读新字段**
|
||||
|
||||
`getOcrProcessor()` 约 :116-147 行:从 `settingsStore` 读取 `layer1RuleEnabled` / `layer2OcrEnabled` / `layer3AiEnabled` 传入 `OcrProcessorConfig`。
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
|
||||
`npm run typecheck` → 0 错误;`npm test` → 全绿
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 模型文件从 assets 拷贝到 filesystem + OcrModule 改路径
|
||||
|
||||
**Files:** `plugins/ppocr/android/OcrModule.kt`、`plugins/ppocr/app.plugin.js`、`plugins/accessibility/android/AccessibilityBridgeModule.kt`(新 bridge 方法)
|
||||
|
||||
- [ ] **Step 1: AccessibilityBridgeModule 加 copyModelFromAssets 方法**
|
||||
|
||||
```kotlin
|
||||
@ReactMethod
|
||||
fun copyOcrModelsFromAssets(promise: Promise) {
|
||||
try {
|
||||
val assetManager = reactContext.assets
|
||||
val destDir = java.io.File(reactContext.filesDir, "ocr_models_v6")
|
||||
if (!destDir.exists()) destDir.mkdirs()
|
||||
|
||||
val files = listOf("ppocrv6_det.onnx", "ppocrv6_rec.onnx", "ppocrv6_dict.txt")
|
||||
for (filename in files) {
|
||||
val destFile = java.io.File(destDir, filename)
|
||||
if (destFile.exists()) continue // skip existing
|
||||
val input = assetManager.open(filename)
|
||||
val output = java.io.FileOutputStream(destFile)
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
output.write(buffer, 0, bytesRead)
|
||||
}
|
||||
input.close()
|
||||
output.close()
|
||||
}
|
||||
promise.resolve(destDir.absolutePath)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("COPY_MODEL_FAIL", e.message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: OcrModule.kt 改为 filesystem 路径加载**
|
||||
|
||||
读 `OcrModule.kt` 中 `createSession` 调用的位置,改从传入路径加载而非 asset 文件名。当前构造可能接受 asset 路径——改为接收 filesystem 绝对路径。新增一个 `@ReactMethod setModelDir(dir: String)` 存储路径,或直接在 `initialize(assetManager: ...)` 的参数中改为 `initialize(modelDir: String, ...)`。
|
||||
|
||||
**注意**:ONNX Runtime Android 的 `OrtEnvironment.createSession(filePath)` 接受文件系统路径(String),不需要 asset 特殊处理——只需确认 `createSession` 的参数类支持文件路径,否则改用 `OrtEnvironment.createSession(byte[] modelBytes)` 从内存加载。**最终方案**:保持 `OrtEnvironment.createSession(filePath)` ,将 `modelDir + "/ppocrv6_det.onnx"` 作为绝对路径传入。
|
||||
|
||||
- [ ] **Step 3: JS 侧 bridge 接口 + modelDownloader 服务**
|
||||
|
||||
`NativeAccessibilityBridge` 加 `copyOcrModelsFromAssets(): Promise<string>` → 返回模型目录绝对路径。
|
||||
|
||||
新建 `src/services/modelDownloader.ts`:
|
||||
```typescript
|
||||
export async function ensureOcrModels(): Promise<string> {
|
||||
// 1. 如果 settingsStore.ocrModelDir 有效且文件存在 → 直接返回
|
||||
// 2. 尝试从 assets 拷贝到 documentDirectory/ocr_models_v6
|
||||
// 3. 返回 modelsDir 绝对路径,存储到 settingsStore.ocrModelDir + ocrModelVersion
|
||||
}
|
||||
```
|
||||
|
||||
`ocrModelVersion` 在从 assets 拷贝成功后写 `'v6-assets'`(区分下载版本和打包版本)。
|
||||
|
||||
- [ ] **Step 4: automationPipeline.ts 的 OcrProcessor 从 filesystem 初始化**
|
||||
|
||||
`getNativeOcrBridge()`或 `getOcrProcessor()` 中,在初始化 OCR 引擎前调用 `ensureOcrModels()` 获取模型路径,传入 `NativeOcrBridge` 构造(或 OcrModule initialize)。
|
||||
|
||||
- [ ] **Step 5: 验证**
|
||||
|
||||
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
|
||||
|
||||
---
|
||||
|
||||
### Task 4: automation 页 UI —— 三层开关 + 模型管理 + AI 配置整合
|
||||
|
||||
**Files:** `src/app/automation/index.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
|
||||
|
||||
- [ ] **Step 1: i18n 新键(zh/en 对等)**
|
||||
|
||||
在 `automation` section 加:
|
||||
```
|
||||
autoBookkeeping 自动记账层级 / Auto Bookkeeping Layers
|
||||
layer1Rule 规则匹配 / Rule Matching
|
||||
layer1RuleDesc 基于正则规则的快速账单识别 / Fast regex-based bill recognition
|
||||
layer2Ocr OCR 识别 / OCR Recognition
|
||||
layer2OcrDesc 本地 PP-OCRv6 模型识别 / On-device PP-OCRv6 model
|
||||
layer3Ai AI 视觉识别 / AI Vision
|
||||
layer3AiDesc 云端多模态大模型识别 / Cloud multimodal LLM
|
||||
layer3AiDisabled 请先配置 AI Key / Configure AI Key first
|
||||
ocrModelTitle OCR 模型 / OCR Model
|
||||
ocrModelNotInstalled 未安装 / Not Installed
|
||||
ocrModelInstalled 已安装 ({version}) / Installed ({version})
|
||||
ocrModelInstall 安装模型 / Install Model
|
||||
ocrModelInstalling 安装中... / Installing...
|
||||
ocrModelCopying 正在复制模型文件... / Copying model files...
|
||||
dedupSwitch 多通道联合去重 / Cross-channel Dedup
|
||||
transferSwitch 转账智能识别 / Transfer Recognition
|
||||
```
|
||||
|
||||
- [ ] **Step 2: automation 页 UI 重组织**
|
||||
|
||||
在现有截图监控按钮 Card **之后**加以下新 Card 区块:
|
||||
|
||||
**Card A:「自动记账层级」**
|
||||
- 三行 Switch:L1 规则匹配(always enabled,不依赖外部条件)
|
||||
- L2 OCR 识别(Switch;开启时检查模型→无模型则弹下载提示 Dialog)
|
||||
- L3 AI 视觉(Switch;如果 aiEnabled=false 或 aiApiKey 为空则 disabled+提示文字;开启后展开 Provider/Key/Model 输入)
|
||||
|
||||
**Card B:「OCR 模型」**
|
||||
- 状态行:版本号(未安装显示灰色「未安装」;已安装显示绿色「已安装 (v6-assets)」)
|
||||
- 操作按钮:安装/重新安装(从 assets 拷贝 → 进度状态 → 完成)
|
||||
|
||||
**Card C:「管道设置」**(复用现有 Card B 和 C 部分)
|
||||
- `dedupEnabled` Switch
|
||||
- `transferRecognitionEnabled` Switch
|
||||
|
||||
- [ ] **Step 3: 操作逻辑**
|
||||
|
||||
```typescript
|
||||
// 模型安装
|
||||
const handleInstallModel = async () => {
|
||||
setModelStatus('installing');
|
||||
try {
|
||||
const bridge = getAccessibilityBridge();
|
||||
if (!bridge) throw new Error('Bridge unavailable');
|
||||
const modelDir = await bridge.copyOcrModelsFromAssets();
|
||||
setOcrModelVersion('v6-assets');
|
||||
setOcrModelDir(modelDir);
|
||||
setModelStatus('done');
|
||||
} catch (e) {
|
||||
setModelStatus('error');
|
||||
Alert.alert('安装失败', String(e));
|
||||
}
|
||||
};
|
||||
|
||||
// L2 开关变化时检查模型
|
||||
const handleL2Toggle = (val: boolean) => {
|
||||
setLayer2OcrEnabled(val);
|
||||
if (val && !ocrModelVersion) {
|
||||
Alert.alert(t('automation.ocrModelTitle'), t('automation.ocrModelNotInstalled'), [
|
||||
{ text: t('common.cancel'), onPress: () => setLayer2OcrEnabled(false) },
|
||||
{ text: t('automation.ocrModelInstall'), onPress: handleInstallModel },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
// L3 开关变化时检查 AI Key
|
||||
const handleL3Toggle = (val: boolean) => {
|
||||
if (val && (!aiEnabled || !aiApiKey)) {
|
||||
Alert.alert(t('automation.layer3Ai'), t('automation.layer3AiDisabled'));
|
||||
return;
|
||||
}
|
||||
setLayer3AiEnabled(val);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: AI 配置内联**
|
||||
|
||||
L3 启用时展开的子区域:
|
||||
- `aiProviderId`:三选一 chip(openai/gemini/deepseek)
|
||||
- `aiApiKey`:输入框(secureTextEntry)
|
||||
- `aiBaseUrl`:输入框(默认值 placeholder)
|
||||
- `aiModel`:输入框(默认值 placeholder)
|
||||
- `aiEnabled`:Switch(总开关,控制 L3 的实际可用性)
|
||||
|
||||
- [ ] **Step 5: 验证**
|
||||
|
||||
`npm run typecheck` → 0 错误;`npm test` → 全绿
|
||||
|
||||
---
|
||||
|
||||
### Task 5: AI 设置页清理 + settings 导航更新
|
||||
|
||||
**Files:** `src/app/settings/ai.tsx`(重写为跳转)、`src/app/(tabs)/settings.tsx`(更新入口文案)
|
||||
|
||||
- [ ] **Step 1: settings/ai.tsx 改为 redirect**
|
||||
|
||||
不删除文件(保留路由),但内容改为:`useEffect(() => { router.replace('/automation'); }, [])` 跳转到 automation 页,期间显示 loading。
|
||||
|
||||
- [ ] **Step 2: settings.tsx 入口文案**
|
||||
|
||||
`src/app/(tabs)/settings.tsx` 中「智能记账 & AI」入口的文案保持不变,但实际点击跳转已由 expo-router 自动处理(ai.tsx → redirect → automation),不需要改路由注册。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
`npm run typecheck` → 0 错误;`npm test` → 全绿
|
||||
`grep -rn "settings/ai" src/app/(tabs)/settings.tsx` → 确认无需改动即可跳转
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 编译 + 测试 + 终审
|
||||
|
||||
- [ ] **Step 1: Kotlin 编译**
|
||||
|
||||
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 2: 全量测试**
|
||||
|
||||
`npm test` → 全绿;`npm run typecheck` → 0 错误
|
||||
|
||||
- [ ] **Step 3: 审计**
|
||||
|
||||
```bash
|
||||
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
|
||||
grep -rn "fontFamily" src/app/automation/index.tsx # 无输出
|
||||
grep -rn "📊|💰|💸|📝" src/app/automation/ # 无输出
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 手工走查清单**
|
||||
|
||||
1. settings 页点「智能记账」→ 跳转到 automation 页
|
||||
2. automation 页看到三个层级开关(L1 默认开 / L2 默认开 / L3 默认关灰)
|
||||
3. 关闭 L1 → 仍可记账(走 L2 OCR)
|
||||
4. 关闭 L2 → L1 失败不再调用 OCR
|
||||
5. 开启 L3 → 提示配 AI Key → 配置后可用
|
||||
6. OCR 模型未安装 → 点安装 → 从 assets 拷贝 → 显示绿色已安装
|
||||
7. L2 开关关闭再打开 → 已有模型直接可用,不再提示
|
||||
8. AI Provider 切换 openai/gemini/deepseek → 对应的 baseUrl 默认值变化
|
||||
|
||||
---
|
||||
|
||||
## 终审修订(已实施,以实际代码为准)
|
||||
|
||||
**日期**:2026-07-22,审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
|
||||
|
||||
| # | 级别 | 问题 | 处理 |
|
||||
|---|---|---|---|
|
||||
| — | — | 无 Critical / Important 发现 | — |
|
||||
|
||||
**偏差说明**:
|
||||
1. 管道设置 Card 复用现有 `settings.dedupLabel`/`settings.transferLabel` i18n 键(已在 settings/ai.tsx 中使用),未新键 `dedupSwitch`/`transferSwitch`——避免重复键。
|
||||
2. OcrModule.kt 从 assets 加载路径改为 `modelDir` 字段 + `setModelDir` 方法——保留原 assets 加载作为兜底(modelDir 为空时走原路径),保证向后兼容。
|
||||
3. `ensureOcrModels()` 在 `_layout.tsx` ready 前调用一次(异步非阻塞),`getOcrProcessor()` 中也传 `ocrModelDir` 作为安全网。
|
||||
@@ -0,0 +1,68 @@
|
||||
# 开发指南
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js 18+
|
||||
- JDK 21 + Android SDK 35 + NDK 27.1.12297006(仅 Android 构建需要)
|
||||
- 详见 [android-build-guide.md](android-build-guide.md)
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npm install --legacy-peer-deps # 安装依赖(必须带 --legacy-peer-deps)
|
||||
npm run start # 启动 Metro 开发服务器
|
||||
npm run android # expo run:android(设备/模拟器)
|
||||
npm test # Vitest 全量测试
|
||||
npm run typecheck # tsc --noEmit 类型检查
|
||||
|
||||
# 单文件测试
|
||||
npx vitest run tests/ledger.test.ts
|
||||
|
||||
# 按名称运行测试
|
||||
npx vitest run -t "拒绝不平衡交易"
|
||||
|
||||
# 重新生成原生项目
|
||||
npx expo prebuild --platform android
|
||||
```
|
||||
|
||||
## 编码规范
|
||||
|
||||
### TypeScript
|
||||
|
||||
- **strict 模式**已开启,路径别名 `@/*` → `src/*`
|
||||
- 非平凡改动后必须运行 `npm run typecheck`
|
||||
|
||||
### 金额计算
|
||||
|
||||
- 使用字符串十进制运算(`src/domain/decimal.ts`),**禁止**用 JS 浮点数处理金额
|
||||
- 关键 API:`addDecimals`、`subtractDecimals`、`multiplyDecimal`、`divideDecimals`、`negateDecimal`
|
||||
|
||||
### 领域层纯净性
|
||||
|
||||
- `src/domain/` 禁止导入 React / React Native / Expo 任何模块
|
||||
- 外部依赖通过接口注入:`MobileBeanBackend`、`OcrEngine`、`AiVisionProvider`
|
||||
- 新模块必须在 `src/domain/index.ts` 重导出
|
||||
|
||||
### 原生代码
|
||||
|
||||
- 原生 Kotlin/XML 只能放在 `plugins/<name>/` 下,不可直接编辑 `android/`
|
||||
- 每个 Config Plugin 函数**必须 `return config`**
|
||||
- `expo prebuild` 后检查 `onnxruntime-android:1.20.0` 版本
|
||||
|
||||
### 注释与文档
|
||||
|
||||
- 代码注释和 `plan.md` 使用**中文**
|
||||
- ID/校验和使用 FNV-1a 哈希(`hash()` in `ledger.ts`),不用加密哈希
|
||||
|
||||
## 测试
|
||||
|
||||
- 框架:**Vitest**(无配置文件,默认拾取 `tests/**/*.test.ts`)
|
||||
- 测试对象:主要覆盖 `src/domain/` 层,使用 mock 后端(`MemoryBackend`、`MockOcrEngine`)
|
||||
- 命名:`<模块或功能>.test.ts`,如 `decimal.test.ts`、`billPipeline.test.ts`
|
||||
- 新增 domain 逻辑时必须同步添加测试
|
||||
|
||||
## 提交规范
|
||||
|
||||
- 提交消息格式:`feat: <中文摘要>`,多行 body 按模块分区列举变更
|
||||
- 示例:`feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构`
|
||||
- PR 需描述变更内容与原因,UI 变更附截图,关联相关 issue
|
||||
Reference in New Issue
Block a user